Gekidou CRT - Global threads screen (#6140)

* Global threads

* Added translations

* User avatar stack

* In-Channel experience

* Misc Fixes

* Fixed fetchPostThread & added observer

* using the observable for participants & check fix

* Test case fix

* Fix tablet view thread screen switching

* No back button for tablets

* folders for thread options only if needed

* Using the existing observable

* Users stack refactor fix

* Reusing the user component

* Refactor fix

* Fixes double loaders when empty threads

* Feedback

* Moved some post options to common post options

* Combined follow/unfollow functions

* Feedback fixes

* Addressing Feedback

* Merge fix

* Threads button component moved

* Addressing feedbackk

* Not rendering message when it's empty, removed unwanted Props exports

* Addressing feedbac

* Updated snapshot

* Added emoji to removemarkdown component

* Moved MD rendering into the component

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Co-authored-by: koox00 <3829551+koox00@users.noreply.github.com>
This commit is contained in:
Anurag Shivarathri
2022-04-28 18:31:36 +05:30
committed by GitHub
parent 776f56efb1
commit dad63b87bb
51 changed files with 2240 additions and 259 deletions

View File

@@ -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}});
}

View File

@@ -151,7 +151,7 @@ export async function markPostAsDeleted(serverUrl: string, post: Post, prepareRe
});
if (!prepareRecordsOnly) {
operator.batchRecords([dbPost]);
await operator.batchRecords([dbPost]);
}
return {model};
}

View File

@@ -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`};

View File

@@ -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};

View File

@@ -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

View File

@@ -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);
}
}
};

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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'),
});

View File

@@ -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(
<FriendlyDate value={justNow}/>,
);
expect(justNowText.getByText('Now')).toBeTruthy();
const minutesAgo = new Date();
minutesAgo.setMinutes(minutesAgo.getMinutes() - 1);
const minutesAgoText = renderWithIntl(
<FriendlyDate value={minutesAgo}/>,
);
expect(minutesAgoText.getByText('1 min ago')).toBeTruthy();
const hoursAgo = new Date();
hoursAgo.setHours(hoursAgo.getHours() - 4);
const hoursAgoText = renderWithIntl(
<FriendlyDate value={hoursAgo}/>,
);
expect(hoursAgoText.getByText('4 hours ago')).toBeTruthy();
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayText = renderWithIntl(
<FriendlyDate value={yesterday}/>,
);
expect(yesterdayText.getByText('Yesterday')).toBeTruthy();
const daysAgo = new Date();
daysAgo.setDate(daysAgo.getDate() - 10);
const daysAgoText = renderWithIntl(
<FriendlyDate value={daysAgo}/>,
);
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(
<FriendlyDate
sourceDate={daysEdgeCaseTodayDate}
value={daysEdgeCase}
/>,
);
expect(daysEdgeCaseText.getByText('1 month ago')).toBeTruthy();
const daysAgoMax = new Date(2020, 4, 6);
const daysAgoMaxTodayDate = new Date(2020, 5, 5);
const daysAgoMaxText = renderWithIntl(
<FriendlyDate
sourceDate={daysAgoMaxTodayDate}
value={daysAgoMax}
/>,
);
expect(daysAgoMaxText.getByText('30 days ago')).toBeTruthy();
const monthsAgo = new Date();
monthsAgo.setMonth(monthsAgo.getMonth() - 2);
const monthsAgoText = renderWithIntl(
<FriendlyDate value={monthsAgo}/>,
);
expect(monthsAgoText.getByText('2 months ago')).toBeTruthy();
const yearsAgo = new Date();
yearsAgo.setFullYear(yearsAgo.getFullYear() - 2);
const yearsAgoText = renderWithIntl(
<FriendlyDate value={yearsAgo}/>,
);
expect(yearsAgoText.getByText('2 years ago')).toBeTruthy();
});
});

View File

@@ -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<ViewStyle>;
sourceDate?: number | Date;
value: number | Date;
};
function FriendlyDate({style, sourceDate, value}: Props) {
const intl = useIntl();
const formattedTime = getFriendlyDate(intl, value, sourceDate);
return (
<Text style={style}>{formattedTime}</Text>
);
}
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;

View File

@@ -204,7 +204,7 @@ const Header = ({
{title}
</Animated.Text>
}
{!isLargeTitle &&
{!isLargeTitle && Boolean(subtitle || subtitleCompanion) &&
<View style={styles.subtitleContainer}>
<Text
ellipsizeMode='tail'

View File

@@ -6,6 +6,7 @@ import {StyleProp, View, ViewStyle} from 'react-native';
import FormattedDate from '@components/formatted_date';
import FormattedText from '@components/formatted_text';
import {isSameYear, isToday, isYesterday} from '@utils/datetime';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@@ -37,27 +38,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
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);

View File

@@ -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<TextStyle>;
value: string;
};
const RemoveMarkdown = ({enableEmoji, enableHardBreak, enableSoftBreak, textStyle, value}: Props) => {
const renderEmoji = useCallback(({emojiName, literal}: MarkdownEmojiRenderer) => {
return (
<Emoji
emojiName={emojiName}
literal={literal}
testID='markdown_emoji'
textStyle={textStyle}
/>
);
}, [textStyle]);
const renderBreak = useCallback(() => {
return '\n';
}, []);
const renderText = ({literal}: {literal: string}) => {
return <Text style={textStyle}>{literal}</Text>;
};
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;

14
app/constants/datetime.ts Normal file
View File

@@ -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
},
};

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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],

View File

@@ -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<GlobalThreadsTab>('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 (
<SafeAreaView
edges={edges}
mode='margin'
style={styles.flex}
testID='global_threads'
>
<NavigationHeader
showBackButton={!isTablet}
isLargeTitle={false}
onBackPress={onBackPress}
title={
intl.formatMessage({
id: 'threads',
defaultMessage: 'Threads',
})
}
/>
<View style={containerStyle}>
<ThreadsList
forceQueryAfterAppState={appState}
setTab={setTab}
tab={tab}
testID={'global_threads.list'}
/>
</View>
</SafeAreaView>
);
};
export default GlobalThreads;

View File

@@ -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 (
<View style={style.container}>
<EmptyStateIllustration theme={theme}/>
<View style={style.textContainer}>
<Text style={style.title}>
{title}
</Text>
<Text style={style.subTitle}>
{subTitle}
</Text>
</View>
</View>
);
}
export default EmptyState;

View File

@@ -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 (
<View style={styles.container}>
<View>
<SearchHintSVG/>
</View>
<View style={styles.right}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.subtitle}>{subtitle}</Text>
</View>
</View>
);
}
export default EndOfList;

View File

@@ -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 (
<View style={styles.container}>
<View style={styles.menuContainer}>
<TouchableOpacity
onPress={handleViewAllThreads}
testID={`${testIDPrefix}.all_threads`}
>
<View style={allThreadsContainerStyle}>
<FormattedText
id='global_threads.allThreads'
defaultMessage='All your threads'
style={allThreadsStyle}
/>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={handleViewUnreadThreads}
testID={`${testIDPrefix}.unread_threads`}
>
<View style={unreadsContainerStyle}>
<View>
<FormattedText
id='global_threads.unreads'
defaultMessage='Unreads'
style={unreadsStyle}
/>
{hasUnreads ? (
<View
style={styles.unreadsDot}
testID={`${testIDPrefix}.unreads_dot`}
/>
) : null}
</View>
</View>
</TouchableOpacity>
</View>
<View style={styles.markAllReadIconContainer}>
<TouchableOpacity
disabled={!hasUnreads}
onPress={handleMarkAllAsRead}
testID={`${testIDPrefix}.mark_all_read`}
>
<CompassIcon
name='playlist-check'
style={markAllStyle}
/>
</TouchableOpacity>
</View>
</View>
);
};
export default Header;

View File

@@ -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 (
<Svg
width='228'
height='201'
viewBox='0 0 228 201'
fill='none'
>
<Path
d='M76.5 199.378C111.018 199.378 139 171.396 139 136.878C139 102.361 111.018 74.3785 76.5 74.3785C41.9822 74.3785 14 102.361 14 136.878C14 171.396 41.9822 199.378 76.5 199.378Z'
fill={theme.centerChannelBg}
/>
<Path
d='M76.5 199.378C111.018 199.378 139 171.396 139 136.878C139 102.361 111.018 74.3785 76.5 74.3785C41.9822 74.3785 14 102.361 14 136.878C14 171.396 41.9822 199.378 76.5 199.378Z'
fill={theme.centerChannelColor}
fillOpacity='0.16'
/>
<Path
d='M89.6523 103.062C91.1459 102.792 92.6812 102.82 94.1631 103.145C95.6169 103.562 96.9528 104.751 97 106.213C96.9429 107.201 96.5757 108.15 95.947 108.932C94.0916 111.638 91.7369 113.992 89 115.878C89 115.878 89.3772 105.599 89.6523 103.062Z'
fill='#FFBC1F'
/>
<Path
d='M61.8881 100.9C60.5985 100.621 59.2878 100.455 57.9702 100.402C57.2069 100.324 56.4364 100.443 55.7313 100.749C54.5522 101.361 54.0373 102.811 54 104.147C54.018 105.47 54.3127 106.773 54.8645 107.972C55.4163 109.171 56.2128 110.238 57.2015 111.103C59.1924 112.813 61.5051 114.097 64 114.878C63.1567 111.239 61.8881 100.9 61.8881 100.9Z'
fill='#FFBC1F'
/>
<G clipPath='url(#Mask0)'>
<Path
d='M141 180.509C133.276 174.256 123.746 170.784 114.869 166.304C107.487 162.577 100.497 158.12 94.0041 153C90.476 150.21 86.9555 147.034 85.4987 142.781C83.7536 137.687 85.2863 132.123 86.7582 126.946C88.2757 121.574 89.7249 116.181 91.1057 110.769C92.6232 104.826 93.6399 97.2073 92.995 92.712C92.6763 90.4379 91.0147 86.5946 89.5579 84.3052C87.1427 80.639 83.4063 78.0435 79.1254 77.0583C76.8757 76.6614 74.5591 76.9247 72.4561 77.8163C69.5013 78.9728 66.8499 80.7882 64.704 83.124C62.5581 85.4598 60.9744 88.2543 60.0736 91.2945C56.6593 103.15 64.277 115.476 63.6927 127.802C63.493 131.435 62.6531 135.005 61.2117 138.347C60.2283 140.792 58.8293 143.048 57.0766 145.017C54.459 147.837 51.0067 149.664 47.5697 151.446L21.0747 165.265C18.0398 166.842 15.0049 168.419 12.1748 170.268C9.67857 178.607 34.178 208.33 68.9963 210.073C114.733 212.385 131.986 201.462 141 180.509Z'
fill={theme.centerChannelBg}
/>
<Path
d='M141 180.509C133.276 174.256 123.746 170.784 114.869 166.304C107.487 162.577 100.497 158.12 94.0041 153C90.476 150.21 86.9555 147.034 85.4987 142.781C83.7536 137.687 85.2863 132.123 86.7582 126.946C88.2757 121.574 89.7249 116.181 91.1057 110.769C92.6232 104.826 93.6399 97.2073 92.995 92.712C92.6763 90.4379 91.0147 86.5946 89.5579 84.3052C87.1427 80.639 83.4063 78.0435 79.1254 77.0583C76.8757 76.6614 74.5591 76.9247 72.4561 77.8163C69.5013 78.9728 66.8499 80.7882 64.704 83.124C62.5581 85.4598 60.9744 88.2543 60.0736 91.2945C56.6593 103.15 64.277 115.476 63.6927 127.802C63.493 131.435 62.6531 135.005 61.2117 138.347C60.2283 140.792 58.8293 143.048 57.0766 145.017C54.459 147.837 51.0067 149.664 47.5697 151.446L21.0747 165.265C18.0398 166.842 15.0049 168.419 12.1748 170.268C9.67857 178.607 34.178 208.33 68.9963 210.073C114.733 212.385 131.986 201.462 141 180.509Z'
fill={theme.centerChannelColor}
fillOpacity='0.04'
/>
</G>
<Path
d='M72.3684 169.667C94.0078 169.538 99.9495 161.228 102.107 158.84C99.3264 156.995 96.6215 155.039 93.9926 152.971C90.4595 150.181 86.9339 147.004 85.4751 142.75C83.7276 137.655 85.2624 132.09 86.7364 126.911C88.256 121.538 89.7072 116.144 91.0901 110.73C92.6097 104.786 93.6279 97.1658 92.982 92.6696C92.6629 90.3949 90.9989 86.5507 89.5401 84.2609C87.1214 80.5937 83.3798 77.9977 79.0927 77.0122C76.8398 76.6152 74.5199 76.8786 72.414 77.7705C69.4544 78.9263 66.7987 80.7417 64.6496 83.0782C62.5005 85.4146 60.9149 88.2103 60.0139 91.2517C56.5872 103.11 64.2233 115.439 63.6382 127.768C63.4382 131.402 62.5971 134.972 61.1536 138.315C60.1688 140.76 58.7679 143.018 57.0127 144.987C54.3913 147.808 50.9342 149.635 47.4923 151.417L43.6932 153.388C44.9621 155.284 52.2487 169.789 72.3684 169.667Z'
fill='#FFBC1F'
/>
<Path
d='M84.9205 138.14C84.8876 137.284 84.9488 136.428 85.1028 135.585C85.1061 135.565 85.1061 135.545 85.1028 135.524C85.1001 133.503 85.5328 131.504 86.3717 129.663L86.2729 129.762C83.3029 132.637 79.3404 134.266 75.2025 134.311C73.5359 134.313 71.8803 134.041 70.3017 133.508C70.2068 133.475 70.1029 133.479 70.0112 133.52C69.9195 133.561 69.8468 133.635 69.8079 133.727V133.727C69.7785 133.797 69.77 133.873 69.7835 133.947C69.797 134.021 69.8319 134.09 69.8839 134.144C76.6386 140.301 84.7989 138.815 84.9433 138.633C84.9205 138.497 84.9205 138.315 84.9205 138.14Z'
fill='#CC8F00'
/>
<Path
d='M74.3606 118.774C74.9163 119.24 75.5645 119.583 76.2626 119.78C76.9606 119.978 77.6927 120.025 78.4105 119.92C79.1284 119.815 79.8158 119.559 80.4273 119.169C81.0389 118.779 81.5608 118.265 81.9587 117.66C82.3158 117.098 81.4268 116.59 81.0773 117.144C80.7606 117.617 80.3487 118.018 79.8679 118.323C79.387 118.628 78.8478 118.83 78.2846 118.915C77.7213 119.001 77.1464 118.969 76.5964 118.82C76.0464 118.672 75.5334 118.411 75.09 118.054C74.5886 117.629 73.8591 118.349 74.3302 118.774H74.3606Z'
fill='#66320A'
/>
<Path
d='M64.901 99.3407C66.9981 98.8934 70.3488 98.355 72.4307 99.2346C74.5126 100.114 74.0795 101.449 74.0035 103.094C73.9992 103.771 73.8543 104.44 73.578 105.058C73.1297 106.104 72.7118 106.233 71.6633 106.521C69.5358 107.12 66.0711 107.408 64.3539 105.801C63.0775 104.61 62.5684 99.7881 64.8554 99.3786C65.5012 99.2649 65.2277 98.2792 64.5819 98.393C64.0758 98.4575 63.6028 98.6787 63.2293 99.0255C62.8558 99.3724 62.6008 99.8274 62.5 100.326C62.1077 102.207 62.2687 104.161 62.9635 105.952C63.5941 107.257 65.2429 107.567 66.565 107.787C68.4492 108.102 70.3809 107.98 72.2104 107.431C73.6084 107.006 74.6721 106.385 74.9229 104.861C75.1508 103.458 75.5307 100.986 74.8469 99.6744C74.1631 98.3626 72.7194 98.2034 71.5037 98.0366C69.2103 97.7476 66.8842 97.8553 64.6275 98.355C63.9816 98.4915 64.2552 99.4772 64.901 99.3407V99.3407Z'
fill={theme.centerChannelBg}
/>
<Path
d='M89.382 99.7047C87.1863 98.935 84.8706 98.5627 82.5437 98.6053C81.3205 98.6053 79.7932 98.7342 79.0562 99.8867C78.7236 100.598 78.5647 101.377 78.5927 102.161C78.4209 103.239 78.3699 104.333 78.4408 105.422C78.6611 106.84 79.9072 107.461 81.1229 107.954C83.4403 108.894 88.9793 110.009 90.3394 107.067C91.3195 104.959 92.3833 100.63 89.382 99.7199C89.2511 99.6837 89.111 99.7009 88.9928 99.7677C88.8745 99.8345 88.7877 99.9455 88.7514 100.076C88.7151 100.207 88.7324 100.347 88.7993 100.465C88.8663 100.583 88.9775 100.669 89.1085 100.706C91.3044 101.373 90.0355 106.514 88.5538 107.476C87.6876 108.038 85.8641 107.878 84.8612 107.803C83.6141 107.718 82.3907 107.42 81.2445 106.923C79.6337 106.21 79.4665 104.861 79.5805 103.291C79.6945 101.722 79.7552 100 81.5788 99.7199C84.134 99.4495 86.717 99.7873 89.1161 100.706C89.7391 100.918 90.0127 99.9473 89.3896 99.7274L89.382 99.7047Z'
fill={theme.centerChannelBg}
/>
<Path
d='M74.8469 101.039C76.1119 100.68 77.4654 100.802 78.6459 101.38C79.231 101.676 79.7552 100.789 79.1626 100.501C77.7398 99.785 76.1003 99.6254 74.5658 100.053C74.4348 100.089 74.3232 100.174 74.2555 100.292C74.1878 100.409 74.1696 100.548 74.2048 100.679C74.2401 100.81 74.326 100.921 74.4435 100.989C74.5611 101.056 74.7007 101.074 74.8317 101.039H74.8469Z'
fill={theme.centerChannelBg}
/>
<Path
d='M93.4622 102.04C92.5832 102.065 91.7337 102.362 91.0308 102.889C90.5066 103.276 91.0308 104.163 91.5475 103.769C92.0977 103.343 92.7664 103.096 93.4622 103.064C94.1156 103.064 94.1232 102.017 93.4622 102.04V102.04Z'
fill={theme.centerChannelBg}
/>
<Path
d='M59.1492 100.857C60.1784 100.527 61.2937 100.589 62.2796 101.032C62.8723 101.305 63.3966 100.425 62.7963 100.152C61.5696 99.5763 60.1722 99.4763 58.8757 99.8715C58.8108 99.8894 58.7501 99.9199 58.6971 99.9612C58.6441 100.003 58.5997 100.054 58.5665 100.112C58.5334 100.171 58.5121 100.235 58.5038 100.302C58.4956 100.368 58.5006 100.436 58.5186 100.501C58.5365 100.566 58.5671 100.626 58.6085 100.679C58.6499 100.732 58.7014 100.776 58.7599 100.809C58.8185 100.842 58.883 100.864 58.9498 100.872C59.0166 100.88 59.0844 100.875 59.1492 100.857V100.857Z'
fill={theme.centerChannelBg}
/>
<Path
d='M67.9402 103.61C68.7417 103.248 69.6279 103.114 70.5008 103.223C71.1542 103.299 71.1542 102.275 70.5008 102.207C69.4463 102.093 68.3809 102.274 67.4235 102.73C66.8233 103.011 67.34 103.89 67.9402 103.61V103.61Z'
fill='#66320A'
/>
<Path
d='M83.6835 104.193C84.0466 104.158 84.413 104.199 84.7596 104.312C85.1061 104.426 85.4253 104.61 85.697 104.853C86.1832 105.293 86.9127 104.572 86.4188 104.133C86.0536 103.793 85.6223 103.533 85.1516 103.367C84.681 103.201 84.1812 103.134 83.6835 103.17C83.03 103.215 83.0224 104.239 83.6835 104.193V104.193Z'
fill='#66320A'
/>
<Path
d='M75.7815 101.191C75.6143 104.11 75.2724 107.014 75.0217 109.933C74.8697 111.753 74.3227 114.968 77.4379 113.724C78.0381 113.482 77.7798 112.488 77.1643 112.739C75.5763 113.383 75.8574 112.428 75.9334 111.313C76.0094 110.199 76.1234 108.963 76.2298 107.795C76.4197 105.596 76.6628 103.405 76.792 101.206C76.83 100.546 75.8043 100.546 75.7663 101.206L75.7815 101.191Z'
fill='#66320A'
/>
<Path
d='M95.9605 81.0128C95.8076 79.6869 95.3539 78.4132 94.6337 77.2885C93.9135 76.1639 92.9459 75.2179 91.8044 74.5224C88.6588 72.6496 84.7154 72.9377 81.0683 73.2941C81.3418 71.7776 79.5866 70.5721 78.029 70.4128C75.4381 70.155 72.9535 71.3909 70.6893 72.6875C67.917 74.1405 65.3976 76.03 63.228 78.2832C63.1423 78.3017 63.0637 78.3444 63.0017 78.4062C62.9397 78.4681 62.8969 78.5465 62.8784 78.632C62.875 78.6521 62.875 78.6726 62.8784 78.6926C61.8807 79.8823 61.0897 81.2304 60.5382 82.6809C60.2916 83.1174 60.1099 83.5874 59.9988 84.076C59.5914 85.6228 59.5783 87.2467 59.9608 88.7998C59.9642 89.0773 60.0635 89.3451 60.2419 89.558L60.3331 89.7476C60.2156 89.7368 60.0982 89.7684 60.0021 89.8365C59.9059 89.9047 59.8374 90.0049 59.8088 90.1191C59.7176 90.6423 59.6341 91.173 59.5429 91.6962C59.512 91.8277 59.5326 91.966 59.6003 92.083C59.6681 92.1999 59.778 92.2866 59.9076 92.3255C60.0376 92.3604 60.1761 92.3424 60.2928 92.2757C60.4096 92.2089 60.4951 92.0987 60.5306 91.9692C60.6218 91.4384 60.7054 90.9152 60.7966 90.3845C60.8176 90.2581 60.799 90.1284 60.7434 90.0129C60.7986 90.0238 60.8554 90.0238 60.9105 90.0129H60.9561C61.0647 90.0736 61.1917 90.0925 61.3132 90.066C63.9569 89.461 66.3073 87.9572 67.9616 85.8124C70.7957 85.2968 74.7011 85.6986 78.7661 86.1232C79.1896 86.6397 79.7047 87.0741 80.2857 87.4046C81.2329 87.858 82.2748 88.0789 83.3249 88.0491C84.9232 88.1033 86.5041 87.7036 87.8838 86.8966C87.9229 87.5975 88.2226 88.2587 88.7242 88.7508C89.2259 89.243 89.8933 89.5308 90.5963 89.558C90.974 89.5537 91.3476 89.4791 91.698 89.3381C92.1173 90.7068 92.3298 92.1302 92.3286 93.5614C92.3439 93.6864 92.4045 93.8016 92.499 93.885C92.5935 93.9685 92.7153 94.0146 92.8415 94.0146C92.9677 94.0146 93.0895 93.9685 93.184 93.885C93.2785 93.8016 93.3391 93.6864 93.3544 93.5614C93.3428 91.9575 93.0866 90.3647 92.5946 88.8377C92.8408 88.6649 93.0744 88.4749 93.2936 88.269C94.2643 87.3434 95.0095 86.208 95.4718 84.9501C95.9341 83.6922 96.1013 82.3453 95.9605 81.0128V81.0128Z'
fill='#66320A'
/>
<Path
d='M59.9228 93.6145C59.8733 93.564 59.8142 93.524 59.7489 93.4966C59.6837 93.4693 59.6136 93.4552 59.5429 93.4552C59.4721 93.4552 59.4021 93.4693 59.3368 93.4966C59.2716 93.524 59.2125 93.564 59.163 93.6145C59.1115 93.6633 59.0705 93.7221 59.0425 93.7873C59.0145 93.8525 59 93.9227 59 93.9936C59 94.0645 59.0145 94.1347 59.0425 94.1998C59.0705 94.265 59.1115 94.3238 59.163 94.3727L59.277 94.4864C59.3752 94.577 59.5041 94.6273 59.6379 94.6273C59.7716 94.6273 59.9005 94.577 59.9988 94.4864C60.0503 94.4376 60.0913 94.3787 60.1193 94.3136C60.1473 94.2484 60.1618 94.1782 60.1618 94.1073C60.1618 94.0364 60.1473 93.9662 60.1193 93.901C60.0913 93.8359 60.0503 93.7771 59.9988 93.7282L59.9228 93.6145Z'
fill='#66320A'
/>
<Path
d='M92.9897 94.3727C92.8546 94.3766 92.7262 94.4324 92.6314 94.5285C92.5365 94.6246 92.4825 94.7535 92.4806 94.8883V95.6466C92.4806 95.7813 92.5342 95.9105 92.6297 96.0058C92.7252 96.101 92.8547 96.1546 92.9897 96.1546C93.1247 96.1546 93.2542 96.101 93.3497 96.0058C93.4451 95.9105 93.4988 95.7813 93.4988 95.6466V94.8883C93.4969 94.7535 93.4429 94.6246 93.348 94.5285C93.2531 94.4324 93.1248 94.3766 92.9897 94.3727Z'
fill='#66320A'
/>
<Path
d='M43.5 153.378C50.6949 164.128 58.5583 171.905 80.5 169.41'
stroke='#66320A'
strokeWidth='0.91'
strokeMiterlimit='10'
strokeLinecap='round'
/>
<Path
d='M128.698 24.8786H215.802C217.398 24.8726 218.979 25.1799 220.456 25.7831C221.933 26.3862 223.276 27.2733 224.408 28.3937C225.541 29.5141 226.441 30.8459 227.058 32.313C227.674 33.7801 227.994 35.3538 228 36.9443V92.1179C227.995 93.709 227.676 95.2835 227.06 96.7515C226.444 98.2195 225.544 99.5522 224.411 100.673C223.278 101.795 221.935 102.682 220.458 103.286C218.98 103.89 217.398 104.197 215.802 104.191H202.95V124.878L183.643 104.184H128.698C127.102 104.19 125.521 103.882 124.044 103.279C122.567 102.676 121.224 101.789 120.092 100.669C118.959 99.5481 118.059 98.2163 117.442 96.7492C116.826 95.2821 116.506 93.7084 116.5 92.1179V36.9443C116.506 35.3538 116.826 33.7801 117.442 32.313C118.059 30.8459 118.959 29.5141 120.092 28.3937C121.224 27.2733 122.567 26.3862 124.044 25.7831C125.521 25.1799 127.102 24.8726 128.698 24.8786V24.8786Z'
fill={theme.buttonBg}
/>
<Path
d='M161.5 104.378H215.919C217.499 104.384 219.066 104.077 220.528 103.475C221.991 102.873 223.321 101.987 224.443 100.868C225.565 99.749 226.456 98.419 227.067 96.9539C227.677 95.4887 227.994 93.9171 228 92.3287V58.8785C228 58.8785 224.201 89.8115 223.524 92.5328C222.847 95.2542 221.493 99.3363 215.069 100.009C208.644 100.682 161.5 104.378 161.5 104.378Z'
fill='black'
fillOpacity='0.32'
/>
<Path
d='M141.5 56.8785C143.082 56.8785 144.629 57.3477 145.945 58.2267C147.26 59.1058 148.286 60.3552 148.891 61.817C149.497 63.2788 149.655 64.8874 149.346 66.4392C149.038 67.9911 148.276 69.4165 147.157 70.5353C146.038 71.6541 144.613 72.4161 143.061 72.7248C141.509 73.0334 139.9 72.875 138.439 72.2695C136.977 71.664 135.727 70.6386 134.848 69.323C133.969 68.0075 133.5 66.4607 133.5 64.8785C133.502 62.7574 134.346 60.7237 135.845 59.2239C137.345 57.724 139.379 56.8805 141.5 56.8785Z'
fill={theme.centerChannelBg}
/>
<Path
d='M171.508 56.8785C173.09 56.88 174.636 57.3505 175.95 58.2305C177.265 59.1105 178.289 60.3604 178.893 61.8224C179.498 63.2843 179.655 64.8926 179.345 66.444C179.036 67.9953 178.273 69.42 177.154 70.5381C176.035 71.6561 174.61 72.4173 173.058 72.7253C171.506 73.0334 169.898 72.8745 168.437 72.2688C166.975 71.663 165.726 70.6377 164.848 69.3222C163.969 68.0068 163.5 66.4604 163.5 64.8785C163.5 63.8273 163.707 62.7863 164.11 61.8152C164.512 60.8441 165.102 59.9619 165.846 59.2189C166.59 58.4759 167.472 57.8868 168.444 57.4852C169.415 57.0836 170.457 56.8775 171.508 56.8785V56.8785Z'
fill={theme.centerChannelBg}
/>
<Path
d='M201.5 56.8785C203.082 56.8785 204.629 57.3477 205.945 58.2267C207.26 59.1058 208.286 60.3552 208.891 61.817C209.497 63.2788 209.655 64.8874 209.346 66.4392C209.038 67.9911 208.276 69.4165 207.157 70.5353C206.038 71.6541 204.613 72.4161 203.061 72.7248C201.509 73.0334 199.9 72.875 198.439 72.2695C196.977 71.664 195.727 70.6386 194.848 69.323C193.969 68.0075 193.5 66.4607 193.5 64.8785C193.5 62.7567 194.343 60.7219 195.843 59.2216C197.343 57.7213 199.378 56.8785 201.5 56.8785Z'
fill={theme.centerChannelBg}
/>
<Path
d='M125.898 47.7743C126.814 44.666 128.358 41.7626 130.441 39.2305C132.525 36.6984 135.107 34.5875 138.039 33.0188C138.207 32.9305 138.341 32.7917 138.421 32.6236C138.5 32.4555 138.521 32.2671 138.479 32.0868C138.438 31.9065 138.336 31.7441 138.191 31.624C138.045 31.5039 137.863 31.4328 137.671 31.4212C132.016 31.0972 120.594 32.2577 124.185 47.6914C124.223 47.8778 124.325 48.0466 124.474 48.1709C124.623 48.2952 124.811 48.3679 125.008 48.3774C125.205 48.3869 125.4 48.3327 125.561 48.2235C125.722 48.1142 125.841 47.9561 125.898 47.7743V47.7743Z'
fill='white'
fillOpacity='0.24'
/>
<Path
d='M73.4957 0.378531H9.02713C7.84566 0.374539 6.67498 0.602539 5.58192 1.04951C4.48886 1.49648 3.49483 2.15366 2.65658 2.98354C1.81833 3.81343 1.15228 4.79975 0.696467 5.8862C0.24065 6.97265 0.00399142 8.13796 0 9.31558V50.1544C0.00399142 51.332 0.24065 52.4973 0.696467 53.5838C1.15228 54.6702 1.81833 55.6565 2.65658 56.4864C3.49483 57.3163 4.48886 57.9735 5.58192 58.4204C6.67498 58.8674 7.84566 59.0954 9.02713 59.0914H18.5414V74.3785L32.8128 59.0914H73.4729C74.6543 59.0954 75.825 58.8674 76.9181 58.4204C78.0112 57.9735 79.0052 57.3163 79.8434 56.4864C80.6817 55.6565 81.3477 54.6702 81.8035 53.5838C82.2593 52.4973 82.496 51.332 82.5 50.1544V9.31558C82.4919 6.94123 81.5395 4.66697 79.8515 2.9916C78.1635 1.31623 75.8778 0.376508 73.4957 0.378531V0.378531Z'
fill='#FFBC1F'
/>
<Path
d='M32.5 58.8784H73.4161C74.605 58.8823 75.783 58.6593 76.883 58.222C77.9829 57.7847 78.9832 57.1417 79.8267 56.3298C80.6702 55.5179 81.3405 54.5529 81.7991 53.49C82.2578 52.427 82.496 51.287 82.5 50.1349V25.8785C82.5 25.8785 79.6431 48.3089 79.1299 50.2833C78.6167 52.2577 77.598 55.2118 72.7727 55.7016C67.9473 56.1915 32.5 58.8784 32.5 58.8784Z'
fill='black'
fillOpacity='0.32'
/>
<Path
d='M19 23.8785C20.1867 23.8785 21.3467 24.2304 22.3334 24.8897C23.3201 25.5489 24.0892 26.486 24.5433 27.5824C24.9974 28.6787 25.1162 29.8851 24.8847 31.049C24.6532 32.2129 24.0818 33.282 23.2426 34.1211C22.4035 34.9602 21.3344 35.5317 20.1705 35.7632C19.0067 35.9947 17.8003 35.8759 16.7039 35.4218C15.6075 34.9676 14.6705 34.1986 14.0112 33.2119C13.3519 32.2252 13 31.0652 13 29.8785C12.999 29.0903 13.1535 28.3096 13.4546 27.5811C13.7558 26.8527 14.1977 26.1909 14.7551 25.6335C15.3124 25.0762 15.9743 24.6343 16.7027 24.3331C17.4311 24.0319 18.2118 23.8774 19 23.8785V23.8785Z'
fill={theme.centerChannelBg}
/>
<Path
d='M41.5 23.8785C42.6867 23.8785 43.8467 24.2304 44.8334 24.8897C45.8201 25.5489 46.5892 26.486 47.0433 27.5824C47.4974 28.6787 47.6162 29.8851 47.3847 31.049C47.1532 32.2129 46.5818 33.282 45.7426 34.1211C44.9035 34.9602 43.8344 35.5317 42.6705 35.7632C41.5067 35.9947 40.3003 35.8759 39.2039 35.4218C38.1075 34.9676 37.1705 34.1986 36.5112 33.2119C35.8519 32.2252 35.5 31.0652 35.5 29.8785C35.499 29.0903 35.6535 28.3096 35.9546 27.5811C36.2558 26.8527 36.6977 26.1909 37.2551 25.6335C37.8124 25.0762 38.4743 24.6343 39.2027 24.3331C39.9311 24.0319 40.7118 23.8774 41.5 23.8785V23.8785Z'
fill={theme.centerChannelBg}
/>
<Path
d='M63.9921 23.8785C65.1792 23.8769 66.34 24.2275 67.3277 24.8858C68.3154 25.5442 69.0857 26.4807 69.541 27.5769C69.9963 28.6731 70.1163 29.8798 69.8857 31.0442C69.6551 32.2086 69.0842 33.2784 68.2454 34.1183C67.4066 34.9582 66.3375 35.5305 65.1734 35.7626C64.0093 35.9948 62.8025 35.8764 61.7057 35.4225C60.6089 34.9686 59.6714 34.1996 59.0117 33.2127C58.3521 32.2259 58 31.0655 58 29.8785C57.999 29.0909 58.1532 28.3109 58.4539 27.583C58.7545 26.8551 59.1957 26.1936 59.7523 25.6363C60.3088 25.0791 60.9697 24.637 61.6972 24.3354C62.4247 24.0337 63.2046 23.8785 63.9921 23.8785V23.8785Z'
fill={theme.centerChannelBg}
/>
<Path
d='M6.7637 16.9281C7.43137 14.6443 8.55997 12.5107 10.0845 10.6501C11.609 8.78959 13.4993 7.23886 15.6466 6.08727C15.7738 6.02514 15.8761 5.92399 15.9374 5.79966C15.9987 5.67533 16.0156 5.53486 15.9854 5.40026C15.9552 5.26565 15.8797 5.14452 15.7707 5.05584C15.6616 4.96717 15.5252 4.91596 15.3828 4.91024C11.2401 4.67033 2.87695 5.525 5.49915 16.8681C5.52681 17.0065 5.60175 17.132 5.71197 17.2244C5.8222 17.3168 5.96133 17.3708 6.10707 17.3777C6.25282 17.3846 6.39675 17.3441 6.51581 17.2625C6.63486 17.181 6.72215 17.0632 6.7637 16.9281V16.9281Z'
fill='white'
fillOpacity='0.32'
/>
<Defs>
<ClipPath
id='Mask0'
>
<Path
d='M76.5 199.378C111.018 199.378 139 171.396 139 136.878C139 102.361 111.018 74.3784 76.5 74.3784C41.9822 74.3784 14 102.361 14 136.878C14 171.396 41.9822 199.378 76.5 199.378Z'
fill='#ffffff'
transform='translate(0 0)'
/>
</ClipPath>
</Defs>
</Svg>
);
}
export default EmptyStateIllustration;

View File

@@ -0,0 +1,20 @@
<svg width="72" height="48" viewBox="0 0 72 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_514_2545)">
<path d="M35.1353 12.5745H4.8754C4.32086 12.5726 3.77137 12.6798 3.25832 12.8898C2.74526 13.0998 2.27869 13.4087 1.88524 13.7986C1.49178 14.1886 1.17916 14.6521 0.965209 15.1627C0.751261 15.6732 0.640179 16.2208 0.638306 16.7742V35.9654C0.640179 36.5188 0.751261 37.0664 0.965209 37.577C1.17916 38.0875 1.49178 38.551 1.88524 38.941C2.27869 39.331 2.74526 39.6398 3.25832 39.8498C3.77137 40.0599 4.32086 40.167 4.8754 40.1651H9.34115V47.3489L16.0398 40.1651H35.1246C35.6791 40.167 36.2286 40.0599 36.7417 39.8498C37.2547 39.6398 37.7213 39.331 38.1147 38.941C38.5082 38.551 38.8208 38.0875 39.0348 37.577C39.2487 37.0664 39.3598 36.5188 39.3617 35.9654V16.7742C39.3579 15.6585 38.9108 14.5897 38.1185 13.8024C37.3262 13.0151 36.2534 12.5735 35.1353 12.5745Z" fill="#FFBC1F"/>
<path d="M16.0398 40.1652H35.1246C35.6791 40.167 36.2286 40.0599 36.7417 39.8498C37.2547 39.6398 37.7213 39.331 38.1148 38.941C38.5082 38.551 38.8208 38.0875 39.0348 37.577C39.2487 37.0664 39.3598 36.5188 39.3617 35.9654V24.3145C39.3617 24.3145 38.0291 35.0884 37.7897 36.0367C37.5504 36.985 37.0752 38.404 34.8245 38.6393C32.5738 38.8746 16.0398 40.1652 16.0398 40.1652Z" fill="#CC8F00"/>
<path d="M9.66973 23.6264C10.2089 23.6264 10.7359 23.7859 11.1842 24.0848C11.6324 24.3837 11.9818 24.8085 12.1881 25.3056C12.3944 25.8026 12.4484 26.3496 12.3432 26.8773C12.2381 27.4049 11.9785 27.8896 11.5972 28.27C11.216 28.6505 10.7303 28.9095 10.2015 29.0145C9.67275 29.1195 9.12467 29.0656 8.62657 28.8597C8.12848 28.6538 7.70276 28.3052 7.40323 27.8578C7.10371 27.4105 6.94383 26.8846 6.94383 26.3466C6.94336 25.9892 7.01355 25.6353 7.15038 25.305C7.2872 24.9748 7.48797 24.6747 7.74118 24.4221C7.9944 24.1694 8.29509 23.969 8.62602 23.8325C8.95695 23.6959 9.31163 23.6259 9.66973 23.6264Z" fill="white"/>
<path d="M20.0056 23.6264C20.5447 23.6264 21.0717 23.7859 21.52 24.0848C21.9683 24.3837 22.3177 24.8085 22.524 25.3056C22.7303 25.8026 22.7843 26.3496 22.6791 26.8773C22.5739 27.4049 22.3143 27.8896 21.9331 28.27C21.5518 28.6505 21.0661 28.9095 20.5374 29.0145C20.0086 29.1195 19.4605 29.0656 18.9624 28.8597C18.4643 28.6538 18.0386 28.3052 17.7391 27.8578C17.4396 27.4105 17.2797 26.8846 17.2797 26.3466C17.2792 25.9892 17.3494 25.6353 17.4862 25.305C17.6231 24.9748 17.8238 24.6747 18.077 24.4221C18.3303 24.1694 18.6309 23.969 18.9619 23.8325C19.2928 23.6959 19.6475 23.6259 20.0056 23.6264Z" fill="white"/>
<path d="M30.33 23.6264C30.8693 23.6257 31.3967 23.7846 31.8454 24.0831C32.2942 24.3815 32.6441 24.8061 32.851 25.3031C33.0579 25.8001 33.1123 26.3472 33.0076 26.8751C32.9028 27.403 32.6435 27.888 32.2624 28.2688C31.8813 28.6496 31.3956 28.909 30.8667 29.0142C30.3379 29.1195 29.7896 29.0658 29.2913 28.8601C28.793 28.6543 28.3671 28.3056 28.0674 27.8582C27.7677 27.4108 27.6077 26.8847 27.6077 26.3466C27.6073 25.9895 27.6773 25.6359 27.8139 25.3059C27.9505 24.9759 28.151 24.676 28.4038 24.4233C28.6566 24.1707 28.9569 23.9703 29.2874 23.8335C29.618 23.6968 29.9722 23.6264 30.33 23.6264Z" fill="white"/>
<path d="M3.7787 20.6104C4.08616 19.5243 4.60589 18.5097 5.30794 17.625C6.00999 16.7402 6.88049 16.0028 7.86932 15.4552C7.92789 15.4256 7.97498 15.3775 8.00322 15.3184C8.03145 15.2593 8.03923 15.1925 8.02534 15.1285C8.01145 15.0645 7.97668 15.0069 7.92646 14.9647C7.87625 14.9225 7.81343 14.8982 7.74785 14.8954C5.84009 14.7814 1.98883 15.1878 3.19637 20.5819C3.20911 20.6477 3.24362 20.7074 3.29437 20.7513C3.34513 20.7953 3.4092 20.8209 3.47632 20.8242C3.54343 20.8275 3.60972 20.8082 3.66454 20.7694C3.71937 20.7307 3.75956 20.6747 3.7787 20.6104Z" fill="#FFD470"/>
<path opacity="0.4" d="M32.3334 7.28985C34.7785 4.81365 37.8644 3.53823 41.5849 3.46979C45.237 3.53823 48.3042 4.80743 50.7866 7.28985C53.2628 9.77227 54.5383 12.8395 54.6067 16.4916C54.5383 20.2121 53.269 23.2918 50.7866 25.7431C48.3042 28.1882 45.237 29.4823 41.5849 29.6067C37.8644 29.4761 34.7847 28.1882 32.3334 25.7431C29.8883 23.2918 28.6004 20.2059 28.4698 16.4854C28.6004 12.8333 29.8883 9.76605 32.3334 7.28985Z" fill="white"/>
<path d="M53.5706 14.1677C52.464 10.572 50.3691 7.88054 47.2828 6.09865C44.1964 4.31676 40.8181 3.84829 37.1508 4.68781C35.1455 5.19998 33.3983 6.08534 31.8951 7.3429C33.576 5.50837 35.7082 4.23806 38.2863 3.52879C41.9535 2.68926 45.3485 3.16735 48.4626 4.96527C51.5767 6.76318 53.6882 9.4643 54.7948 13.06C55.4751 15.6505 55.4412 18.1322 54.6873 20.502C53.939 22.8749 52.5957 24.8682 50.6551 26.4733C52.1121 24.8854 53.1147 23.0207 53.6603 20.8706C54.2005 18.7172 54.1703 16.4876 53.5706 14.1677Z" fill="black" fill-opacity="0.4"/>
<path d="M57.6194 15.4776C57.3584 11.6178 55.7919 8.22807 52.9264 5.30846C49.663 2.19708 45.8961 0.638306 41.6257 0.638306C37.3554 0.638306 33.5947 2.19708 30.3313 5.30846C27.2047 8.55591 25.6383 12.2982 25.6383 16.5477C25.6383 20.7973 27.2047 24.5458 30.3313 27.787C33.3274 30.6448 36.7524 32.185 40.6001 32.4077C44.4478 32.6366 47.9971 31.5788 51.2605 29.2468L53.7034 31.6778L56.83 28.5664L54.3871 26.1355C56.7989 22.8942 57.8743 19.3375 57.6194 15.4776ZM50.8689 25.6963C48.3887 28.1272 45.3242 29.4139 41.6755 29.5376C37.9583 29.4077 34.8814 28.1272 32.4323 25.6963C29.9895 23.2653 28.7028 20.1973 28.5722 16.4983C28.7028 12.8673 29.9895 9.81778 32.4323 7.34971C34.8752 4.88783 37.9583 3.61978 41.6755 3.55174C45.3242 3.61978 48.3887 4.88165 50.8689 7.34971C53.3428 9.81778 54.6171 12.8673 54.6855 16.4983C54.6171 20.1973 53.3491 23.2653 50.8689 25.6963Z" fill="#BABEC9"/>
<path d="M67.6182 46.4678C66.8121 46.6983 66.2153 46.6173 65.8276 46.225L53.1453 32.3065C52.7577 31.9141 52.5977 31.4097 52.6592 30.7869C52.7207 30.1642 53.1084 29.4667 53.8222 28.682C54.5975 27.9659 55.2867 27.5549 55.9021 27.4552C56.5174 27.3556 57.0159 27.5362 57.4035 27.997L71.2488 40.8319C71.6364 41.2243 71.698 41.8097 71.4395 42.5943C71.1811 43.379 70.695 44.1637 69.9873 44.9483C69.2181 45.733 68.4243 46.2374 67.6182 46.4678Z" fill="#FFBC1F"/>
<path d="M68.5465 38.3191L63.362 43.5464L57.1116 36.6855L61.6961 32.0026L68.5465 38.3191Z" fill="#7A5600"/>
</g>
<defs>
<clipPath id="clip0_514_2545">
<rect width="72" height="48" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -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)));

View File

@@ -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));

View File

@@ -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 = (
<View
style={styles.mentionBadge}
testID={`${testIDPrefix}.unread_mentions`}
>
<Text style={styles.mentionBadgeText}>{thread.unreadMentions > 99 ? '99+' : thread.unreadMentions}</Text>
</View>
);
} else if (thread.unreadReplies) {
badgeComponent = (
<View
style={styles.unreadDot}
testID={`${testIDPrefix}.unread_dot`}
/>
);
}
}
let name;
let postBody;
if (!post || post.deleteAt > 0) {
name = (
<FormattedText
id='threads.deleted'
defaultMessage='Original Message Deleted'
style={[styles.threadStarter, styles.threadDeleted]}
numberOfLines={1}
/>
);
} else {
name = (
<Text
style={styles.threadStarter}
numberOfLines={1}
>
{threadStarterName}
</Text>
);
if (post?.message) {
postBody = (
<Text numberOfLines={2}>
<RemoveMarkdown
enableEmoji={true}
enableHardBreak={true}
enableSoftBreak={true}
textStyle={styles.message}
value={post.message}
/>
</Text>
);
}
}
return (
<TouchableHighlight
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
onLongPress={showThreadOptions}
onPress={showThread}
testID={`${testIDPrefix}.item`}
>
<View style={styles.container}>
<View style={styles.badgeContainer}>
{badgeComponent}
</View>
<View style={styles.postContainer}>
<View style={styles.header}>
<View style={styles.headerInfoContainer}>
{name}
{channel && threadStarterName !== channel?.displayName && (
<View style={styles.channelNameContainer}>
<Text
style={styles.channelName}
numberOfLines={1}
>
{channel?.displayName}
</Text>
</View>
)}
</View>
<FriendlyDate
value={thread.lastReplyAt}
style={styles.date}
/>
</View>
{postBody}
<ThreadFooter
author={author}
testID={`${testIDPrefix}.footer`}
thread={thread}
/>
</View>
</View>
</TouchableHighlight>
);
};
export default Thread;

View File

@@ -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));

View File

@@ -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 = (
<FormattedText
id='threads.newReplies'
defaultMessage='{count} new {count, plural, one {reply} other {replies}}'
style={style.unreadReplies}
testID={`${testID}.unread_replies`}
values={{
count: thread.unreadReplies,
}}
/>
);
} else if (thread.replyCount) {
repliesComponent = (
<FormattedText
id='threads.replies'
defaultMessage='{count} {count, plural, one {reply} other {replies}}'
style={style.replies}
testID={`${testID}.reply_count`}
values={{
count: thread.replyCount,
}}
/>
);
}
// 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 = (
<UserAvatarsStack
style={style.avatarsContainer}
users={participantsList}
/>
);
}
return (
<View style={style.container}>
{userAvatarsStack}
{repliesComponent}
</View>
);
};
export default ThreadFooter;

View File

@@ -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 (
<Loading
color={theme.buttonBg}
containerStyle={styles.loadingStyle}
/>
);
}
return (
<EmptyState isUnreads={tab === 'unreads'}/>
);
}, [isLoading, theme, tab]);
const listFooterComponent = useMemo(() => {
if (tab === 'unreads' || !threads.length) {
return null;
}
if (endReached) {
return (
<EndOfList/>
);
} else if (isLoading) {
return (
<Loading
color={theme.buttonBg}
containerStyle={styles.loadingStyle}
/>
);
}
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}) => (
<Thread
testID={testID}
teammateNameDisplay={teammateNameDisplay}
thread={item}
/>
), [teammateNameDisplay, testID]);
return (
<>
<Header
setTab={setTab}
tab={tab}
teamId={teamId}
testID={testID}
unreadsCount={unreadsCount}
/>
<FlatList
contentContainerStyle={styles.messagesContainer}
data={threads}
ListEmptyComponent={listEmptyComponent}
ListFooterComponent={listFooterComponent}
maxToRenderPerBatch={10}
onEndReached={handleEndReached}
removeClippedSubviews={true}
renderItem={renderItem}
/>
</>
);
};
export default ThreadsList;

View File

@@ -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<string, React.ReactNode> = {
[Screens.CHANNEL]: Channel,
[Screens.GLOBAL_THREADS]: GlobalThreads,
};
const AdditionalTabletView = () => {
const [selected, setSelected] = useState<SelectedView>();
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;

View File

@@ -404,73 +404,6 @@ exports[`components/categories_list should render team error 1`] = `
Find channels...
</Text>
</View>
<View
onMoveShouldSetResponder={[Function]}
onMoveShouldSetResponderCapture={[Function]}
onResponderEnd={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderReject={[Function]}
onResponderRelease={[Function]}
onResponderStart={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
onStartShouldSetResponderCapture={[Function]}
testID="channel_list.threads.button"
>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
>
<View
style={
Object {
"display": "flex",
"flexDirection": "row",
}
}
>
<Icon
name="message-text-outline"
style={
Object {
"color": "#ffffff",
"fontSize": 24,
"lineHeight": 28,
}
}
/>
<Text
style={
Array [
Array [
Object {
"fontFamily": "OpenSans-SemiBold",
"fontSize": 16,
"fontWeight": "600",
"lineHeight": 24,
},
],
Object {
"color": "#ffffff",
"paddingLeft": 12,
},
]
}
>
Threads
</Text>
</View>
</View>
</View>
<View
style={
Object {

View File

@@ -41,6 +41,20 @@ describe('components/categories_list', () => {
expect(wrapper.toJSON()).toBeTruthy();
});
it('should render channel list with thread menu', () => {
const wrapper = renderWithEverything(
<CategoriesList
isCRTEnabled={true}
isTablet={false}
teamsCount={1}
currentTeamId={TestHelper.basicTeam!.id}
channelsCount={1}
/>,
{database},
);
expect(wrapper.toJSON()).toBeTruthy();
});
it('should render team error', async () => {
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: ''}],

View File

@@ -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 = (
<>
<SearchField/>
<Threads/>
{isCRTEnabled && <ThreadsButton/>}
<Categories
currentTeamId={currentTeamId}
/>
@@ -86,4 +87,4 @@ const ChannelList = ({channelsCount, currentTeamId, iconPad, isTablet, teamsCoun
);
};
export default ChannelList;
export default CategoriesList;

View File

@@ -1,71 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Threads Component should match snapshot 1`] = `
<View
onMoveShouldSetResponder={[Function]}
onMoveShouldSetResponderCapture={[Function]}
onResponderEnd={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderReject={[Function]}
onResponderRelease={[Function]}
onResponderStart={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
onStartShouldSetResponderCapture={[Function]}
testID="channel_list.threads.button"
>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
>
<View
style={
Object {
"display": "flex",
"flexDirection": "row",
}
}
>
<Icon
name="message-text-outline"
style={
Object {
"color": "#ffffff",
"fontSize": 24,
"lineHeight": 28,
}
}
/>
<Text
style={
Array [
Array [
Object {
"fontFamily": "OpenSans-SemiBold",
"fontSize": 16,
"fontWeight": "600",
"lineHeight": 24,
},
],
Object {
"color": "#ffffff",
"paddingLeft": 12,
},
]
}
>
Threads
</Text>
</View>
</View>
</View>
`;

View File

@@ -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 (
<TouchableWithFeedback
onPress={() => goToScreen(Screens.CHANNEL, 'Channel', {}, {topBar: {visible: false}})}
testID='channel_list.threads.button'
>
<View style={styles.container}>
<CompassIcon
name='message-text-outline'
style={styles.icon}
/>
<Text style={[textStyle, styles.text]}>{'Threads'}</Text>
</View>
</TouchableWithFeedback>
);
};
export default ThreadsButton;

View File

@@ -0,0 +1,78 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Threads Component should match snapshot 1`] = `
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"opacity": 1,
}
}
>
<View
style={
Object {
"marginLeft": -18,
"marginRight": -20,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"flexDirection": "row",
"minHeight": 40,
"paddingHorizontal": 20,
},
undefined,
]
}
>
<Icon
name="message-text-outline"
style={
Array [
Object {
"color": "rgba(255,255,255,0.5)",
"fontSize": 24,
},
undefined,
]
}
/>
<Text
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": -1,
"paddingLeft": 12,
},
undefined,
undefined,
]
}
>
Threads
</Text>
</View>
</View>
</View>
`;

View File

@@ -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));

View File

@@ -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(
<Threads/>,
<Threads
currentChannelId='someChannelId'
unreadsAndMentions={{
unreads: 0,
mentions: 0,
}}
/>,
);
expect(toJSON()).toMatchSnapshot();

View File

@@ -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 (
<TouchableOpacity onPress={handlePress}>
<View style={customStyles.baseContainer}>
<View style={containerStyle}>
<CompassIcon
name='message-text-outline'
style={iconStyle}
/>
<FormattedText
id='threads'
defaultMessage='Threads'
style={textStyle}
/>
<Badge
value={mentions}
style={styles.badge}
visible={mentions > 0}
/>
</View>
</View>
</TouchableOpacity>
);
};
export default ThreadsButton;

View File

@@ -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) => {
/>
<CategoriesList
iconPad={canAddOtherServers && props.teamsCount <= 1}
isCRTEnabled={props.isCRTEnabled}
isTablet={isTablet}
teamsCount={props.teamsCount}
channelsCount={props.channelsCount}
currentTeamId={props.currentTeamId}
/>
{isTablet && Boolean(props.currentTeamId) &&
<Channel/>
<AdditionalTabletView/>
}
</Animated.View>
</SafeAreaView>

View File

@@ -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(),
}));

View File

@@ -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) {

View File

@@ -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));

View File

@@ -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 (
<BaseOption
i18nId={id}
defaultMessage={defaultMessage}
iconName='mark-as-unread'
onPress={onHandlePress}
testID='thread.options.mark_as_read'
/>
);
};
export default MarkAsUnreadOption;

View File

@@ -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 (
<BaseOption
i18nId={t('global_threads.options.open_in_channel')}
defaultMessage='Open in Channel'
iconName='globe'
onPress={onHandlePress}
testID='thread.options.open_in_channel'
/>
);
};
export default OpenInChannelOption;

View File

@@ -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 = [
<ReplyOption
key='reply'
location={Screens.THREAD_OPTIONS}
post={post}
/>,
<FollowThreadOption
key='unfollow'
thread={thread}
/>,
<OpenInChannelOption
key='open-in-channel'
threadId={thread.id}
/>,
<MarkAsUnreadOption
key='mark-as-unread'
teamId={team.id}
thread={thread}
post={post}
/>,
<SaveOption
key='save'
isSaved={isSaved}
postId={thread.id}
/>,
];
const managedConfig = useManagedConfig<ManagedConfig>();
const canCopyLink = managedConfig?.copyAndPasteProtection !== 'true';
if (canCopyLink) {
options.push(
<CopyPermalinkOption
key='copy-link'
post={post}
/>,
);
}
const renderContent = () => (
<>
{!isTablet && (
<View style={style.listHeader}>
<FormattedText
id='global_threads.options.title'
defaultMessage={'THREAD ACTIONS'}
style={style.listHeaderText}
/>
</View>
)}
{options}
</>
);
return (
<BottomSheet
renderContent={renderContent}
closeButtonId='close-thread-options'
componentId={Screens.THREAD_OPTIONS}
initialSnapIndex={0}
snapPoints={[bottomSheetSnapPoint(options.length, ITEM_HEIGHT, insets.bottom), 10]}
/>
);
};
export default ThreadOptions;

27
app/utils/datetime.ts Normal file
View File

@@ -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);
}

View File

@@ -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",

4
types/crt/index.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
type GlobalThreadsTab = 'all' | 'unreads';