Gekidou - Account Screen (#5708)

* Added DrawerItem component

* WIP Account Screen

* Added react-native-paper

* Added StatusLabel Component

* Extracted i18n

* TS fix DrawerItem component

* WIP Account Screen

* Added server name label under log out

* Updated translation

* WIP

* Fixes the Offline text style

* Added Metropolis fonts

* WIP

* Typo clean up

* WIP

* WIP

* WIP

* Added server display name

* Writing OpenSans properly

* WIP

* WIP

* Added OptionsModal

* Opening OptionsModal

* Added translation keys

* Writes status to local db

* Fix missing translation

* Fix OptionModal not dismissing

* Pushing status to server

* Refactored

* Added CustomStatusExpiry component

* Added sub components

* Added CustomLabel

* CustomStatus WIP

* Added Custom Status screen WIP

* WIP - unsetCustomStatus and CustomStatus constant

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* Retrieving RecentCustomStatuses from Preferences table

* WIP

* WIP

* WIP

* Added Clear After Modal

* WIP - Transations

* WIP

* Done with showing modal cst

* wip

* Clear After Modal - DONE

* fix

* Added missing API calls

* wip

* Causing screen refresh

* wip

* WIP

* WIP

* WIP

* Code clean up

* Added OOO alert box

* Refactored Options-Item

* Refactored OptionsModalList component

* Opening 'status' in BottomSheet instead of OptionsModal

* AddReaction screen - WIP

* Add Reaction screen - WIP

* Added EmojiPickerRow

* Added @components/emoji_picker - WIP

* Emoji Picker - WIP

* WIP

* WIP

* WIP

* SectionList - WIP

* Installed react-native-section_list_get_item_layout

* Adding API calls - WIP

* WIP

* Search Bar component - WIP

* WIP

* WIP

* WIP

* Rendering Emoticons now - have to tackle some fixmes

* Code clean up

* Code clean up - WIP

* Code clean up

* WIP

* Major clean up

* wip

* WIP

* Fix rendering issue with SectionIcons and SearchBar

* Tackled the CustomEmojiPage

* Code clean up

* WIP

* Done with loading User Profiles for Custom Emoji

* Code clean up

* Code Clean up

* Fix screen Account

* Added missing sql file for IOS Pod

* Updated Podfile.lock

* Using queryConfig instead of queryCommonSystemValues

* Fix - Custom status

* Fix - Custom Status - Error

* Fix - Clear Pass Status - WIP

* Fix - Custom Status Clear

* Need to fix CST clear

* WIP

* Status clear - working

* Using catchError operator

* remove unnecessary prop

* Status  BottomSheet now has colored indicators

* Added KeyboardTrackingView from 'react-native-keyboard-tracking-view'

* Code clean up

* WIP

* code clean up

* Added a safety check

* Fix - Display suggestions

* Code clean up based on PR Review

* Code clean up

* Code clean up

* Code clean up

* Corrections

* Fix tsc

* TS fix

* Removed unnecessary prop

* Fix SearchBar Ts

* Updated tests

* Delete search_bar.test.js.snap

* Merge branch 'gekidou' into gekidou_account_screen

* Revert "Merge branch 'gekidou' into gekidou_account_screen"

This reverts commit 5defc31321.

* Fix fonts

* Refactor home account screen

* fix theme provider

* refactor bottom sheet

* remove paper provider

* update drawer item snapshots

* Remove options modal screen

* remove react-native-ui-lib dependency

* Refactor & fix custom status & navigation (including tablet)

* Refactor emoji picker

Co-authored-by: Avinash Lingaloo <>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Avinash Lingaloo
2021-10-12 19:24:24 +04:00
committed by GitHub
parent 0807a20946
commit 7f91a6a78a
101 changed files with 5818 additions and 315 deletions

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {safeParseJSON} from '@utils/helpers';
import type SystemModel from '@typings/database/models/servers/system';
const MAXIMUM_RECENT_EMOJI = 27;
export const addRecentReaction = async (serverUrl: string, emojiName: string, prepareRecordsOnly = false) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let recent: string[] = [];
try {
const emojis = await operator.database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).find(SYSTEM_IDENTIFIERS.RECENT_REACTIONS);
recent.push(...(safeParseJSON(emojis.value) as string[] || []));
} catch {
// no previous values.. continue
}
try {
const recentEmojis = new Set(recent);
if (recentEmojis.has(emojiName)) {
recentEmojis.delete(emojiName);
}
recent = Array.from(recentEmojis);
recent.unshift(emojiName);
return operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.RECENT_REACTIONS,
value: JSON.stringify(recent.slice(0, MAXIMUM_RECENT_EMOJI)),
}],
prepareRecordsOnly,
});
} catch (error) {
return {error};
}
};

92
app/actions/local/user.ts Normal file
View File

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {queryRecentCustomStatuses} from '@queries/servers/system';
import {queryUserById} from '@queries/servers/user';
import {addRecentReaction} from './reactions';
import type Model from '@nozbe/watermelondb/Model';
import type UserModel from '@typings/database/models/servers/user';
export const updateLocalCustomStatus = async (serverUrl: string, user: UserModel, customStatus?: UserCustomStatus) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const models: Model[] = [];
const currentProps = {...user.props, customStatus: customStatus || {}};
const userModel = user.prepareUpdate((u: UserModel) => {
u.props = currentProps;
});
models.push(userModel);
if (customStatus) {
const recent = await updateRecentCustomStatuses(serverUrl, customStatus, true);
if (Array.isArray(recent)) {
models.push(...recent);
}
if (customStatus.emoji) {
const recentEmojis = await addRecentReaction(serverUrl, customStatus.emoji, true);
if (Array.isArray(recentEmojis)) {
models.push(...recentEmojis);
}
}
}
await operator.batchRecords(models);
return {};
};
export const updateRecentCustomStatuses = async (serverUrl: string, customStatus: UserCustomStatus, prepareRecordsOnly = false, remove = false) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const recent = await queryRecentCustomStatuses(operator.database);
const recentStatuses = (recent ? recent.value : []) as UserCustomStatus[];
const index = recentStatuses.findIndex((cs) => (
cs.emoji === customStatus.emoji &&
cs.text === customStatus.text &&
cs.duration === customStatus.duration
));
if (index !== -1) {
recentStatuses.splice(index, 1);
}
if (!remove) {
recentStatuses.unshift(customStatus);
}
return operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.RECENT_CUSTOM_STATUS,
value: JSON.stringify(recentStatuses),
}],
prepareRecordsOnly,
});
};
export const updateUserPresence = async (serverUrl: string, userStatus: UserStatus) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const user = await queryUserById(operator.database, userStatus.user_id);
if (user) {
user.prepareUpdate((record) => {
record.status = userStatus.status;
});
await operator.batchRecords([user]);
}
return {};
};

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {forceLogoutIfNecessary} from '@actions/remote/session';
import {Client} from '@client/rest';
import {Emoji, General} from '@constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {queryCustomEmojisByName} from '@queries/servers/custom_emoji';
export const fetchCustomEmojis = async (serverUrl: string, page = 0, perPage = General.PAGE_SIZE_DEFAULT, sort = Emoji.SORT_BY_NAME) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const data = await client.getCustomEmojis(page, perPage, sort);
if (data.length) {
await operator.handleCustomEmojis({
emojis: data,
prepareRecordsOnly: false,
});
}
return {data};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const searchCustomEmojis = async (serverUrl: string, term: string) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const data = await client.searchCustomEmoji(term);
if (data.length) {
const names = data.map((c) => c.name);
const exist = await queryCustomEmojisByName(operator.database, names);
const emojis = data.filter((d) => exist.findIndex((c) => c.name === d.name));
if (emojis.length) {
await operator.handleCustomEmojis({
emojis,
prepareRecordsOnly: false,
});
}
}
return {data};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};

View File

@@ -1,9 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {Model, Q} from '@nozbe/watermelondb';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {addRecentReaction} from '@actions/local/reactions';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {queryCurrentUserId} from '@queries/servers/system';
@@ -11,7 +12,6 @@ import {queryCurrentUserId} from '@queries/servers/system';
import {forceLogoutIfNecessary} from './session';
import type {Client} from '@client/rest';
import type SystemModel from '@typings/database/models/servers/system';
export const addReaction = async (serverUrl: string, postId: string, emojiName: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
@@ -29,16 +29,25 @@ export const addReaction = async (serverUrl: string, postId: string, emojiName:
try {
const currentUserId = await queryCurrentUserId(operator.database);
const reaction = await client.addReaction(currentUserId, postId, emojiName);
const models: Model[] = [];
await operator.handleReactions({
const reactions = await operator.handleReactions({
postsReactions: [{
post_id: postId,
reactions: [reaction],
}],
prepareRecordsOnly: false,
prepareRecordsOnly: true,
});
models.push(...reactions);
addRecentReaction(serverUrl, emojiName);
const recent = await addRecentReaction(serverUrl, emojiName, true);
if (Array.isArray(recent)) {
models.push(...recent);
}
if (models.length) {
await operator.batchRecords(models);
}
return {reaction};
} catch (error) {
@@ -83,33 +92,3 @@ export const removeReaction = async (serverUrl: string, postId: string, emojiNam
return {error};
}
};
export const addRecentReaction = async (serverUrl: string, emojiName: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const recent = [];
try {
const emojis = await operator.database.get(MM_TABLES.SERVER.SYSTEM).find(SYSTEM_IDENTIFIERS.RECENT_REACTIONS) as SystemModel;
recent.push(...emojis.value);
} catch {
// no previous values.. continue
}
try {
recent.unshift(emojiName);
await operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.RECENT_REACTIONS,
value: JSON.stringify(recent),
}],
prepareRecordsOnly: false,
});
return {error: undefined};
} catch (error) {
return {error};
}
};

View File

@@ -3,6 +3,7 @@
import {Q} from '@nozbe/watermelondb';
import {updateRecentCustomStatuses, updateUserPresence} from '@actions/local/user';
import {fetchRolesIfNeeded} from '@actions/remote/role';
import {Database, General} from '@constants';
import DatabaseManager from '@database/manager';
@@ -322,7 +323,7 @@ export const fetchStatusByIds = async (serverUrl: string, userIds: string[], fet
const users = await database.get(Database.MM_TABLES.SERVER.USER).query(Q.where('id', Q.oneOf(userIds))).fetch() as UserModel[];
for (const user of users) {
const status = statuses.find((s) => s.user_id === user.id);
user.prepareSatus(status?.status || General.OFFLINE);
user.prepareStatus(status?.status || General.OFFLINE);
}
await operator.batchRecords(users);
@@ -357,7 +358,6 @@ export const fetchUsersByIds = async (serverUrl: string, userIds: string[], fetc
const exisingUsers = await queryUsersById(operator.database, userIds);
const usersToLoad = userIds.filter((id) => (id !== currentUserId && !exisingUsers.find((u) => u.id === id)));
const users = await client.getProfilesByIds([...new Set(usersToLoad)]);
if (!fetchOnly) {
await operator.handleUsers({
users,
@@ -408,7 +408,7 @@ export const fetchUsersByUsernames = async (serverUrl: string, usernames: string
}
};
export const fetchMissinProfilesByIds = async (serverUrl: string, userIds: string[]) => {
export const fetchMissingProfilesByIds = async (serverUrl: string, userIds: string[]) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
@@ -427,7 +427,7 @@ export const fetchMissinProfilesByIds = async (serverUrl: string, userIds: strin
}
};
export const fetchMissinProfilesByUsernames = async (serverUrl: string, usernames: string[]) => {
export const fetchMissingProfilesByUsernames = async (serverUrl: string, usernames: string[]) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
@@ -445,3 +445,82 @@ export const fetchMissinProfilesByUsernames = async (serverUrl: string, username
return {error};
}
};
export const setStatus = async (serverUrl: string, status: UserStatus) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const data = await client.updateStatus(status);
updateUserPresence(serverUrl, status);
return {
data,
};
} catch (error: any) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const updateCustomStatus = async (serverUrl: string, user: UserModel, customStatus: UserCustomStatus) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
await client.updateCustomStatus(customStatus);
return {data: true};
} catch (error) {
return {error};
}
};
export const removeRecentCustomStatus = async (serverUrl: string, customStatus: UserCustomStatus) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
updateRecentCustomStatuses(serverUrl, customStatus, false, true);
try {
await client.removeRecentCustomStatus(customStatus);
} catch (error) {
return {error};
}
return {data: true};
};
export const unsetCustomStatus = async (serverUrl: string) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
await client.unsetCustomStatus();
} catch (error) {
return {error};
}
return {data: true};
};

View File

@@ -41,6 +41,9 @@ export interface ClientUsersMix {
getStatusesByIds: (userIds: string[]) => Promise<UserStatus[]>;
getStatus: (userId: string) => Promise<UserStatus>;
updateStatus: (status: UserStatus) => Promise<UserStatus>;
updateCustomStatus: (customStatus: UserCustomStatus) => Promise<{status: string}>;
unsetCustomStatus: () => Promise<{status: string}>;
removeRecentCustomStatus: (customStatus: UserCustomStatus) => Promise<{status: string}>;
}
const ClientUsers = (superclass: any) => class extends superclass {
@@ -380,6 +383,27 @@ const ClientUsers = (superclass: any) => class extends superclass {
{method: 'put', body: status},
);
};
updateCustomStatus = async (customStatus: UserCustomStatus) => {
return this.doFetch(
`${this.getUserRoute('me')}/status/custom`,
{method: 'put', body: customStatus},
);
};
unsetCustomStatus = async () => {
return this.doFetch(
`${this.getUserRoute('me')}/status/custom`,
{method: 'delete'},
);
};
removeRecentCustomStatus = async (customStatus: UserCustomStatus) => {
return this.doFetch(
`${this.getUserRoute('me')}/status/custom/recent/delete`,
{method: 'post', body: customStatus},
);
};
};
export default ClientUsers;

View File

@@ -4,8 +4,18 @@ exports[`components/custom_status/custom_status_emoji should match snapshot 1`]
<Text
testID="custom_status_emoji.calendar"
>
<Text>
<Text
style={
Array [
undefined,
Object {
"color": "#000",
"fontSize": 16,
},
]
}
>
📆
</Text>
</Text>
`;
@@ -14,8 +24,18 @@ exports[`components/custom_status/custom_status_emoji should match snapshot with
<Text
testID="custom_status_emoji.calendar"
>
<Text>
<Text
style={
Array [
undefined,
Object {
"color": "#000",
"fontSize": 34,
},
]
}
>
📆
</Text>
</Text>
`;

View File

@@ -5,6 +5,7 @@ import Database from '@nozbe/watermelondb/Database';
import React from 'react';
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
import {CustomStatusDuration} from '@constants';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';

View File

@@ -13,7 +13,7 @@ interface ComponentProps {
testID?: string;
}
const CustomStatusEmoji = ({customStatus, emojiSize, style, testID}: ComponentProps) => {
const CustomStatusEmoji = ({customStatus, emojiSize = 16, style, testID}: ComponentProps) => {
const testIdPrefix = testID ? `${testID}.` : '';
return (
<Text
@@ -28,8 +28,4 @@ const CustomStatusEmoji = ({customStatus, emojiSize, style, testID}: ComponentPr
);
};
CustomStatusEmoji.defaultProps = {
emojiSize: 16,
};
export default CustomStatusEmoji;

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import moment, {Moment} from 'moment-timezone';
import React from 'react';
import {Text, TextStyle} from 'react-native';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {getUserTimezone} from '@actions/local/timezone';
import FormattedDate from '@components/formatted_date';
import FormattedText from '@components/formatted_text';
import FormattedTime from '@components/formatted_time';
import {Preferences} from '@constants';
import {MM_TABLES} from '@constants/database';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {getCurrentMomentForTimezone} from '@utils/helpers';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PreferenceModel from '@typings/database/models/servers/preference';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
currentUser: UserModel;
isMilitaryTime: boolean;
showPrefix?: boolean;
showTimeCompulsory?: boolean;
showToday?: boolean;
testID?: string;
textStyles?: TextStyle;
theme: Theme;
time: Date | Moment;
withinBrackets?: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
text: {
fontSize: 15,
color: theme.centerChannelColor,
},
};
});
const CustomStatusExpiry = ({currentUser, isMilitaryTime, showPrefix, showTimeCompulsory, showToday, testID = '', textStyles = {}, theme, time, withinBrackets}: Props) => {
const userTimezone = getUserTimezone(currentUser);
const timezone = userTimezone.useAutomaticTimezone ? userTimezone.automaticTimezone : userTimezone.manualTimezone;
const styles = getStyleSheet(theme);
const currentMomentTime = getCurrentMomentForTimezone(timezone);
const expiryMomentTime = timezone ? moment(time).tz(timezone) : moment(time);
const plusSixDaysEndTime = currentMomentTime.clone().add(6, 'days').endOf('day');
const tomorrowEndTime = currentMomentTime.clone().add(1, 'day').endOf('day');
const todayEndTime = currentMomentTime.clone().endOf('day');
const isCurrentYear = currentMomentTime.get('y') === expiryMomentTime.get('y');
let dateComponent;
if ((showToday && expiryMomentTime.isBefore(todayEndTime)) || expiryMomentTime.isSame(todayEndTime)) {
dateComponent = (
<FormattedText
id='custom_status.expiry_time.today'
defaultMessage='Today'
/>
);
} else if (expiryMomentTime.isAfter(todayEndTime) && expiryMomentTime.isSameOrBefore(tomorrowEndTime)) {
dateComponent = (
<FormattedText
id='custom_status.expiry_time.tomorrow'
defaultMessage='Tomorrow'
/>
);
} else if (expiryMomentTime.isAfter(tomorrowEndTime)) {
let format = 'dddd';
if (expiryMomentTime.isAfter(plusSixDaysEndTime) && isCurrentYear) {
format = 'MMM DD';
} else if (!isCurrentYear) {
format = 'MMM DD, YYYY';
}
dateComponent = (
<FormattedDate
format={format}
timezone={timezone}
value={expiryMomentTime.toDate()}
/>
);
}
const useTime = showTimeCompulsory || !(expiryMomentTime.isSame(todayEndTime) || expiryMomentTime.isAfter(tomorrowEndTime));
return (
<Text
testID={testID}
style={[styles.text, textStyles]}
>
{withinBrackets && '('}
{showPrefix && (
<FormattedText
id='custom_status.expiry.until'
defaultMessage='Until'
/>
)}
{showPrefix && ' '}
{dateComponent}
{useTime && dateComponent && (
<>
{' '}
<FormattedText
id='custom_status.expiry.at'
defaultMessage='at'
/>
{' '}
</>
)}
{useTime && (
<FormattedTime
isMilitaryTime={isMilitaryTime}
timezone={timezone || ''}
value={expiryMomentTime.toDate()}
/>
)}
{withinBrackets && ')'}
</Text>
);
};
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
isMilitaryTime: database.get<PreferenceModel>(MM_TABLES.SERVER.PREFERENCE).
query(
Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS),
).observe().pipe(
switchMap(
(preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)),
),
),
}));
export default withDatabase(enhanced(CustomStatusExpiry));

View File

@@ -4,11 +4,10 @@
import React from 'react';
import {Text, TextStyle} from 'react-native';
import FormattedText from '@components/formatted_text';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
interface ComponentProps {
text: string | typeof FormattedText;
text: string | React.ReactNode;
theme: Theme;
textStyle?: TextStyle;
ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip';

View File

@@ -0,0 +1,220 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DrawerItem 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="test-id"
>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
>
<View
style={
Object {
"backgroundColor": "#ffffff",
"flexDirection": "row",
"minHeight": 50,
}
}
>
<View
style={
Object {
"alignItems": "center",
"height": 50,
"justifyContent": "center",
"marginLeft": 5,
"width": 45,
}
}
>
<Icon
name="icon-name"
style={
Array [
Object {
"color": "rgba(63,67,80,0.64)",
"fontSize": 24,
},
Object {
"color": "#d24b4e",
},
]
}
/>
</View>
<View
style={
Object {
"flex": 1,
}
}
>
<View
style={
Object {
"flex": 1,
"justifyContent": "center",
"paddingBottom": 14,
"paddingTop": 14,
}
}
>
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.5)",
"fontSize": 17,
"includeFontPadding": false,
"textAlignVertical": "center",
},
Object {
"color": "#d24b4e",
},
Object {
"textAlign": "center",
"textAlignVertical": "center",
},
]
}
>
default message
</Text>
</View>
<View
style={
Object {
"backgroundColor": "rgba(63,67,80,0.2)",
"height": 1,
}
}
/>
</View>
</View>
</View>
</View>
`;
exports[`DrawerItem should match snapshot without separator and centered false 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="test-id"
>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
>
<View
style={
Object {
"backgroundColor": "#ffffff",
"flexDirection": "row",
"minHeight": 50,
}
}
>
<View
style={
Object {
"alignItems": "center",
"height": 50,
"justifyContent": "center",
"marginLeft": 5,
"width": 45,
}
}
>
<Icon
name="icon-name"
style={
Array [
Object {
"color": "rgba(63,67,80,0.64)",
"fontSize": 24,
},
Object {
"color": "#d24b4e",
},
]
}
/>
</View>
<View
style={
Object {
"flex": 1,
}
}
>
<View
style={
Object {
"flex": 1,
"justifyContent": "center",
"paddingBottom": 14,
"paddingTop": 14,
}
}
>
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.5)",
"fontSize": 17,
"includeFontPadding": false,
"textAlignVertical": "center",
},
Object {
"color": "#d24b4e",
},
Object {},
]
}
>
default message
</Text>
</View>
</View>
</View>
</View>
</View>
`;

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Preferences} from '@constants';
import {renderWithIntl} from '@test/intl-test-helper';
import DrawerItem from './';
describe('DrawerItem', () => {
const baseProps = {
onPress: () => null,
testID: 'test-id',
centered: true,
defaultMessage: 'default message',
i18nId: 'i18-id',
iconName: 'icon-name',
isDestructor: true,
separator: true,
theme: Preferences.THEMES.denim,
};
test('should match snapshot', () => {
const wrapper = renderWithIntl(<DrawerItem {...baseProps}/>);
expect(wrapper.toJSON()).toMatchSnapshot();
});
test('should match snapshot without separator and centered false', () => {
const props = {
...baseProps,
centered: false,
separator: false,
};
const wrapper = renderWithIntl(
<DrawerItem {...props}/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,149 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactNode} from 'react';
import {Platform, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type DrawerItemProps = {
centered?: boolean;
defaultMessage?: string;
i18nId?: string;
iconName?: string;
isDestructor?: boolean;
labelComponent?: ReactNode;
leftComponent?: ReactNode;
onPress: () => void;
separator?: boolean;
testID: string;
theme: Theme;
};
const DrawerItem = (props: DrawerItemProps) => {
const {
centered,
defaultMessage = '',
i18nId,
iconName,
isDestructor = false,
labelComponent,
leftComponent,
onPress,
separator = true,
testID,
theme,
} = props;
const style = getStyleSheet(theme);
const destructor: any = {};
if (isDestructor) {
destructor.color = theme.errorTextColor;
}
let divider;
if (separator) {
divider = (<View style={style.divider}/>);
}
let icon;
if (leftComponent) {
icon = leftComponent;
} else if (iconName) {
icon = (
<CompassIcon
name={iconName}
style={[style.icon, destructor]}
/>
);
}
let label;
if (labelComponent) {
label = labelComponent;
} else if (i18nId) {
label = (
<FormattedText
id={i18nId}
defaultMessage={defaultMessage}
style={[
style.label,
destructor,
centered ? style.centerLabel : {},
]}
/>
);
}
return (
<TouchableWithFeedback
testID={testID}
onPress={onPress}
underlayColor={changeOpacity(theme.centerChannelColor, Platform.select({android: 0.1, ios: 0.3}) || 0.3)}
>
<View style={style.container}>
{icon && (
<View style={style.iconContainer}>
{icon}
</View>
)}
<View style={style.wrapper}>
<View style={style.labelContainer}>
{label}
</View>
{divider}
</View>
</View>
</TouchableWithFeedback>
);
};
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
backgroundColor: theme.centerChannelBg,
flexDirection: 'row',
minHeight: 50,
},
iconContainer: {
width: 45,
height: 50,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 5,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.64),
fontSize: 24,
},
wrapper: {
flex: 1,
},
labelContainer: {
flex: 1,
justifyContent: 'center',
paddingTop: 14,
paddingBottom: 14,
},
centerLabel: {
textAlign: 'center',
textAlignVertical: 'center',
},
label: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 17,
textAlignVertical: 'center',
includeFontPadding: false,
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1,
},
};
});
export default DrawerItem;

View File

@@ -11,7 +11,6 @@ import {
StyleSheet,
Text,
TextStyle,
View,
} from 'react-native';
import FastImage, {ImageStyle} from 'react-native-fast-image';
import {of as of$} from 'rxjs';
@@ -102,7 +101,7 @@ const Emoji = (props: Props) => {
return (
<Text
style={[textStyle, {fontSize: size}]}
style={[textStyle, {fontSize: size, color: '#000'}]}
testID={testID}
>
{code}
@@ -118,15 +117,13 @@ const Emoji = (props: Props) => {
return null;
}
return (
<View style={Platform.select({ios: {flex: 1, justifyContent: 'center'}})}>
<FastImage
key={key}
source={image}
style={[customEmojiStyle, {width, height}]}
resizeMode={FastImage.resizeMode.contain}
testID={testID}
/>
</View>
<FastImage
key={key}
source={image}
style={[customEmojiStyle, {width, height}]}
resizeMode={FastImage.resizeMode.contain}
testID={testID}
/>
);
}
@@ -139,31 +136,27 @@ const Emoji = (props: Props) => {
const key = Platform.OS === 'android' ? (`${imageUrl}-${height}-${width}`) : null;
return (
<View style={Platform.select({ios: {flex: 1, justifyContent: 'center'}})}>
<FastImage
key={key}
style={[customEmojiStyle, {width, height}]}
source={{uri: imageUrl}}
resizeMode={FastImage.resizeMode.contain}
testID={testID}
/>
</View>
<FastImage
key={key}
style={[customEmojiStyle, {width, height}]}
source={{uri: imageUrl}}
resizeMode={FastImage.resizeMode.contain}
testID={testID}
/>
);
};
const withSystemIds = withObservables([], ({database}: WithDatabaseArgs) => ({
enableCustomEmoji: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((config: SystemModel) => of$(config.value.EnableCustomEmoji)),
),
}));
const withCustomEmojis = withObservables(['emojiName'], ({database, emojiName}: WithDatabaseArgs & {emojiName: string}) => {
const hasEmojiBuiltIn = EmojiIndicesByAlias.has(emojiName);
const withCustomEmojis = withObservables(['enableCustomEmoji', 'emojiName'], ({enableCustomEmoji, database, emojiName}: WithDatabaseArgs & {enableCustomEmoji: string; emojiName: string}) => {
const displayTextOnly = enableCustomEmoji !== 'true';
const displayTextOnly = hasEmojiBuiltIn ? of$(false) : database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((config) => of$(config.value.EnableCustomEmoji !== 'true')),
);
return {
displayTextOnly: of$(displayTextOnly),
customEmojis: database.get(MM_TABLES.SERVER.CUSTOM_EMOJI).query(Q.where('name', emojiName)).observe(),
displayTextOnly,
customEmojis: hasEmojiBuiltIn ? of$([]) : database.get(MM_TABLES.SERVER.CUSTOM_EMOJI).query(Q.where('name', emojiName)).observe(),
};
});
export default withDatabase(withSystemIds(withCustomEmojis(Emoji)));
export default withDatabase(withCustomEmojis(Emoji));

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback} from 'react';
import {Text, TouchableOpacity, View} from 'react-native';
import Emoji from '@components/emoji';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type TouchableEmojiProps = {
name: string;
onEmojiPress: (emojiName: string) => void;
}
const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
height: 40,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
borderBottomWidth: 1,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2),
overflow: 'hidden',
},
emojiContainer: {
marginRight: 5,
},
emoji: {
color: '#000',
},
emojiText: {
fontSize: 13,
color: theme.centerChannelColor,
},
};
});
const EmojiTouchable = ({name, onEmojiPress}: TouchableEmojiProps) => {
const theme = useTheme();
const style = getStyleSheetFromTheme(theme);
const onPress = useCallback(() => onEmojiPress(name), []);
return (
<TouchableOpacity
onPress={onPress}
style={style.container}
>
<View style={style.emojiContainer}>
<Emoji
emojiName={name}
textStyle={style.emoji}
size={20}
/>
</View>
<Text style={style.emojiText}>{`:${name}:`}</Text>
</TouchableOpacity>
);
};
export default memo(EmojiTouchable);

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Fuse from 'fuse.js';
import React, {useCallback, useMemo} from 'react';
import {FlatList} from 'react-native';
import {Emojis, EmojiIndicesByAlias} from '@utils/emoji';
import {compareEmojis, getSkin} from '@utils/emoji/helpers';
import EmojiItem from './emoji_item';
import NoResults from './no_results';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
type Props = {
customEmojis: CustomEmojiModel[];
skinTone: string;
searchTerm: string;
onEmojiPress: (emojiName: string) => void;
};
const EmojiFiltered = ({customEmojis, skinTone, searchTerm, onEmojiPress}: Props) => {
const emojis = useMemo(() => {
const emoticons = new Set<string>();
for (const [key, index] of EmojiIndicesByAlias.entries()) {
const skin = getSkin(Emojis[index]);
if (!skin || skin === skinTone) {
emoticons.add(key);
}
}
for (const custom of customEmojis) {
emoticons.add(custom.name);
}
return Array.from(emoticons);
}, [skinTone, customEmojis]);
const fuse = useMemo(() => {
const options = {findAllMatches: true, ignoreLocation: true, includeMatches: true, shouldSort: false, includeScore: true};
return new Fuse(emojis, options);
}, [emojis]);
const data = useMemo(() => {
const searchTermLowerCase = searchTerm.toLowerCase();
if (!searchTerm) {
return [];
}
const sorter = (a: string, b: string) => {
return compareEmojis(a, b, searchTermLowerCase);
};
const fuzz = fuse.search(searchTermLowerCase);
if (fuzz) {
const results = fuzz.reduce((values, r) => {
const score = r?.score === undefined ? 1 : r.score;
const v = r?.matches?.[0]?.value;
if (score < 0.2 && v) {
values.push(v);
}
return values;
}, [] as string[]);
return results.sort(sorter);
}
return [];
}, [fuse, searchTerm]);
const keyExtractor = useCallback((item) => item, []);
const renderItem = useCallback(({item}) => {
return (
<EmojiItem
onEmojiPress={onEmojiPress}
name={item}
/>
);
}, []);
if (!data.length) {
return <NoResults searchTerm={searchTerm}/>;
}
return (
<FlatList
data={data}
initialNumToRender={30}
keyboardShouldPersistTaps='always'
keyExtractor={keyExtractor}
renderItem={renderItem}
removeClippedSubviews={false}
/>
);
};
export default EmojiFiltered;

View File

@@ -0,0 +1,90 @@
// 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 CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
searchTerm: string;
}
const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme) => {
return {
flex: {
flex: 1,
},
flexCenter: {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
},
notFoundIcon: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
width: 120,
height: 120,
borderRadius: 60,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
notFoundText: {
color: theme.centerChannelColor,
marginTop: 16,
},
notFoundText20: {
fontSize: 20,
fontWeight: '600',
},
notFoundText15: {
fontSize: 15,
},
};
});
const NoResults = ({searchTerm}: Props) => {
const theme = useTheme();
const intl = useIntl();
const styles = getStyleSheetFromTheme(theme);
const title = intl.formatMessage(
{
id: 'mobile.emoji_picker.search.not_found_title',
defaultMessage: 'No results found for "{searchTerm}"',
},
{
searchTerm,
},
);
const description = intl.formatMessage({
id: 'mobile.emoji_picker.search.not_found_description',
defaultMessage: 'Check the spelling or try another search.',
});
return (
<View style={[styles.flex, styles.flexCenter]}>
<View style={styles.flexCenter}>
<View style={styles.notFoundIcon}>
<CompassIcon
name='magnify'
size={72}
color={theme.buttonBg}
/>
</View>
<Text style={[styles.notFoundText, styles.notFoundText20]}>
{title}
</Text>
<Text style={[styles.notFoundText, styles.notFoundText15]}>
{description}
</Text>
</View>
</View>
);
};
export default NoResults;

View File

@@ -0,0 +1,172 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {LayoutChangeEvent, Platform, View} from 'react-native';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {of as of$} from 'rxjs';
import {catchError, switchMap} from 'rxjs/operators';
import {searchCustomEmojis} from '@actions/remote/custom_emoji';
import SearchBar from '@components/search_bar';
import {Preferences} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useServerUrl} from '@context/server_url';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import {safeParseJSON} from '@utils/helpers';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import EmojiFiltered from './filtered';
import EmojiSections from './sections';
import type {WithDatabaseArgs} from '@typings/database/database';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
import type PreferenceModel from '@typings/database/models/servers/preference';
import type SystemModel from '@typings/database/models/servers/system';
export const SCROLLVIEW_NATIVE_ID = 'emojiSelector';
const edges: Edge[] = ['bottom', 'left', 'right'];
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
flex: {
flex: 1,
},
container: {
flex: 1,
marginHorizontal: 12,
},
searchBar: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
paddingVertical: 5,
...Platform.select({
ios: {
paddingLeft: 8,
},
}),
height: 50,
},
searchBarInput: {
backgroundColor: theme.centerChannelBg,
color: theme.centerChannelColor,
fontSize: 13,
},
}));
type Props = {
customEmojis: CustomEmojiModel[];
customEmojisEnabled: boolean;
onEmojiPress: (emoji: string) => void;
recentEmojis: string[];
skinTone: string;
testID?: string;
}
const EmojiPicker = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmojis, skinTone, testID = ''}: Props) => {
const theme = useTheme();
const intl = useIntl();
const serverUrl = useServerUrl();
const [width, setWidth] = useState(0);
const [searchTerm, setSearchTerm] = useState<string|undefined>();
const styles = getStyleSheet(theme);
const onLayout = useCallback(({nativeEvent}: LayoutChangeEvent) => setWidth(nativeEvent.layout.width), []);
const onCancelSearch = useCallback(() => setSearchTerm(undefined), []);
const onChangeSearchTerm = useCallback((text) => {
setSearchTerm(text);
searchCustom(text);
}, []);
const searchCustom = debounce((text: string) => {
if (text && text.length > 1) {
searchCustomEmojis(serverUrl, text);
}
}, 500);
let EmojiList: React.ReactNode = null;
if (searchTerm) {
EmojiList = (
<EmojiFiltered
customEmojis={customEmojis}
skinTone={skinTone}
searchTerm={searchTerm}
onEmojiPress={onEmojiPress}
/>
);
} else {
EmojiList = (
<EmojiSections
customEmojis={customEmojis}
customEmojisEnabled={customEmojisEnabled}
onEmojiPress={onEmojiPress}
recentEmojis={recentEmojis}
skinTone={skinTone}
width={width}
/>
);
}
return (
<SafeAreaView
style={styles.flex}
edges={edges}
>
<View
style={styles.searchBar}
testID={testID}
>
<SearchBar
autoCapitalize='none'
backgroundColor='transparent'
cancelTitle={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
inputHeight={33}
inputStyle={styles.searchBarInput}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
onCancelButtonPress={onCancelSearch}
onChangeText={onChangeSearchTerm}
placeholder={intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
testID={`${testID}.search_bar`}
tintColorDelete={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorSearch={changeOpacity(theme.centerChannelColor, 0.8)}
titleCancelColor={theme.centerChannelColor}
value={searchTerm}
/>
</View>
<View
style={styles.container}
onLayout={onLayout}
>
{Boolean(width) &&
<>
{EmojiList}
</>
}
</View>
</SafeAreaView>
);
};
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
customEmojisEnabled: database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).
findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((config) => of$(config.value.EnableCustomEmoji === 'true')),
),
customEmojis: database.get<CustomEmojiModel>(MM_TABLES.SERVER.CUSTOM_EMOJI).query().observe(),
recentEmojis: database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).
findAndObserve(SYSTEM_IDENTIFIERS.RECENT_REACTIONS).
pipe(
switchMap((recent) => of$(safeParseJSON(recent.value) as string[])),
catchError(() => of$([])),
),
skinTone: database.get<PreferenceModel>(MM_TABLES.SERVER.PREFERENCE).query(
Q.where('category', Preferences.CATEGORY_EMOJI),
Q.where('name', Preferences.EMOJI_SKINTONE),
).observe().pipe(
switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')),
),
}));
export default withDatabase(enhanced(EmojiPicker));

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {TouchableOpacity} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
currentIndex: number;
icon: string;
index: number;
scrollToIndex: (index: number) => void;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
alignItems: 'center',
flex: 1,
height: 35,
justifyContent: 'center',
zIndex: 10,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.4),
},
selected: {
color: theme.centerChannelColor,
},
}));
const SectionIcon = ({currentIndex, icon, index, scrollToIndex, theme}: Props) => {
const style = getStyleSheet(theme);
const onPress = useCallback(preventDoubleTap(() => scrollToIndex(index)), []);
return (
<TouchableOpacity
onPress={onPress}
style={style.container}
>
<CompassIcon
name={icon}
size={20}
style={[style.icon, currentIndex === index ? style.selected : undefined]}
/>
</TouchableOpacity>
);
};
export default SectionIcon;

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import SectionIcon from './icon';
export const SCROLLVIEW_NATIVE_ID = 'emojiSelector';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
bottom: 10,
height: 35,
position: 'absolute',
width: '100%',
},
background: {
backgroundColor: theme.centerChannelBg,
},
pane: {
flexDirection: 'row',
borderRadius: 10,
paddingHorizontal: 10,
width: '100%',
borderColor: changeOpacity(theme.centerChannelColor, 0.3),
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
borderWidth: 1,
justifyContent: 'space-between',
},
}));
export type SectionIconType = {
key: string;
icon: string;
}
type Props = {
currentIndex: number;
sections: SectionIconType[];
scrollToIndex: (index: number) => void;
}
const EmojiSectionBar = ({currentIndex, sections, scrollToIndex}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<KeyboardTrackingView
scrollViewNativeID={SCROLLVIEW_NATIVE_ID}
normalList={true}
style={styles.container}
>
<View style={styles.background}>
<View style={styles.pane}>
{sections.map((section, index) => (
<SectionIcon
currentIndex={currentIndex}
key={section.key}
icon={section.icon}
index={index}
scrollToIndex={scrollToIndex}
theme={theme}
/>
))}
</View>
</View>
</KeyboardTrackingView>
);
};
export default EmojiSectionBar;

View File

@@ -0,0 +1,242 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {chunk} from 'lodash';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {NativeScrollEvent, NativeSyntheticEvent, SectionList, StyleSheet, View} from 'react-native';
import sectionListGetItemLayout from 'react-native-section-list-get-item-layout';
import {fetchCustomEmojis} from '@actions/remote/custom_emoji';
import {EMOJIS_PER_PAGE} from '@constants/emoji';
import {useServerUrl} from '@context/server_url';
import {CategoryNames, EmojiIndicesByCategory, CategoryTranslations, CategoryMessage} from '@utils/emoji';
import {fillEmoji} from '@utils/emoji/helpers';
import EmojiSectionBar, {SCROLLVIEW_NATIVE_ID, SectionIconType} from './icons_bar';
import SectionFooter from './section_footer';
import SectionHeader, {SECTION_HEADER_HEIGHT} from './section_header';
import TouchableEmoji from './touchable_emoji';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
export const EMOJI_SIZE = 30;
export const EMOJI_GUTTER = 8;
const ICONS: Record<string, string> = {
recent: 'clock-outline',
'smileys-emotion': 'emoticon-happy-outline',
'people-body': 'eye-outline',
'animals-nature': 'leaf-outline',
'food-drink': 'food-apple',
'travel-places': 'airplane-variant',
activities: 'basketball',
objects: 'lightbulb-outline',
symbols: 'heart-outline',
flags: 'flag-outline',
custom: 'emoticon-custom-outline',
};
const categoryToI18n: Record<string, CategoryTranslation> = {};
const getItemLayout = sectionListGetItemLayout({
getItemHeight: () => (EMOJI_SIZE + (EMOJI_GUTTER * 2)),
getSectionHeaderHeight: () => SECTION_HEADER_HEIGHT,
});
const styles = StyleSheet.create(({
row: {
flexDirection: 'row',
marginBottom: EMOJI_GUTTER,
},
emoji: {
height: EMOJI_SIZE + EMOJI_GUTTER,
marginHorizontal: 7,
width: EMOJI_SIZE + EMOJI_GUTTER,
},
}));
type Props = {
customEmojis: CustomEmojiModel[];
customEmojisEnabled: boolean;
onEmojiPress: (emoji: string) => void;
recentEmojis: string[];
skinTone: string;
width: number;
}
CategoryNames.forEach((name: string) => {
if (CategoryTranslations.has(name) && CategoryMessage.has(name)) {
categoryToI18n[name] = {
id: CategoryTranslations.get(name)!,
defaultMessage: CategoryMessage.get(name)!,
icon: ICONS[name],
};
}
});
const EmojiSections = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmojis, skinTone, width}: Props) => {
const serverUrl = useServerUrl();
const list = useRef<SectionList<EmojiSection>>();
const [sectionIndex, setSectionIndex] = useState(0);
const [customEmojiPage, setCustomEmojiPage] = useState(0);
const [fetchingCustomEmojis, setFetchingCustomEmojis] = useState(false);
const [loadedAllCustomEmojis, setLoadedAllCustomEmojis] = useState(false);
const sections: EmojiSection[] = useMemo(() => {
if (!width) {
return [];
}
const chunkSize = Math.floor(width / (EMOJI_SIZE + EMOJI_GUTTER));
return CategoryNames.map((category) => {
const emojiIndices = EmojiIndicesByCategory.get(skinTone)?.get(category);
let data: EmojiAlias[][];
switch (category) {
case 'custom': {
const builtInCustom = emojiIndices.map(fillEmoji);
// eslint-disable-next-line max-nested-callbacks
const custom = customEmojisEnabled ? customEmojis.map((ce) => ({
aliases: [],
name: ce.name,
short_name: '',
})) : [];
data = chunk<EmojiAlias>(builtInCustom.concat(custom), chunkSize);
break;
}
case 'recent':
// eslint-disable-next-line max-nested-callbacks
data = chunk<EmojiAlias>(recentEmojis.map((emoji) => ({
aliases: [],
name: emoji,
short_name: '',
})), chunkSize);
break;
default:
data = chunk(emojiIndices.map(fillEmoji), chunkSize);
break;
}
return {
...categoryToI18n[category],
data,
key: category,
};
}).filter((s: EmojiSection) => s.data.length);
}, [skinTone, customEmojis, customEmojisEnabled, width]);
const sectionIcons: SectionIconType[] = useMemo(() => {
return sections.map((s) => ({
key: s.key,
icon: s.icon,
}));
}, [sections]);
const emojiSectionsByOffset = useMemo(() => {
let lastOffset = 0;
return sections.map((s) => {
const start = lastOffset;
const nextOffset = s.data.length * (EMOJI_SIZE + (EMOJI_GUTTER * 2));
lastOffset += nextOffset;
return start;
});
}, [sections]);
const onLoadMoreCustomEmojis = useCallback(async () => {
if (!customEmojisEnabled || fetchingCustomEmojis || loadedAllCustomEmojis) {
return;
}
setFetchingCustomEmojis(true);
const {data, error} = await fetchCustomEmojis(serverUrl, customEmojiPage, EMOJIS_PER_PAGE);
if (data?.length) {
setCustomEmojiPage(customEmojiPage + 1);
} else if (!error && (data && data.length < EMOJIS_PER_PAGE)) {
setLoadedAllCustomEmojis(true);
}
setFetchingCustomEmojis(false);
}, [customEmojiPage, customEmojisEnabled, loadedAllCustomEmojis, fetchingCustomEmojis]);
const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
const {contentOffset} = e.nativeEvent;
let nextIndex = emojiSectionsByOffset.findIndex(
(offset) => contentOffset.y <= offset,
);
if (nextIndex === -1) {
nextIndex = emojiSectionsByOffset.length - 1;
} else if (nextIndex !== 0) {
nextIndex -= 1;
}
if (nextIndex !== sectionIndex) {
setSectionIndex(nextIndex);
}
}, [emojiSectionsByOffset, sectionIndex]);
const scrollToIndex = (index: number) => {
list.current?.scrollToLocation({sectionIndex: index, itemIndex: 0, animated: false, viewOffset: 0});
setSectionIndex(index);
};
const renderSectionHeader = useCallback(({section}) => {
return (
<SectionHeader section={section as EmojiSection}/>
);
}, []);
const renderFooter = useMemo(() => {
return fetchingCustomEmojis ? <SectionFooter/> : null;
}, [fetchingCustomEmojis]);
const renderItem = useCallback(({item}) => {
return (
<View style={styles.row}>
{item.map((emoji: EmojiAlias) => {
return (
<TouchableEmoji
key={emoji.name}
name={emoji.name}
onEmojiPress={onEmojiPress}
size={EMOJI_SIZE}
style={styles.emoji}
/>
);
})}
</View>
);
}, []);
return (
<>
<SectionList
getItemLayout={getItemLayout}
initialNumToRender={20}
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='always'
ListFooterComponent={renderFooter}
maxToRenderPerBatch={20}
nativeID={SCROLLVIEW_NATIVE_ID}
onEndReached={onLoadMoreCustomEmojis}
onEndReachedThreshold={2}
onScroll={onScroll}
// @ts-expect-error ref
ref={list}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
sections={sections}
contentContainerStyle={{paddingBottom: 50}}
windowSize={100}
/>
<EmojiSectionBar
currentIndex={sectionIndex}
scrollToIndex={scrollToIndex}
sections={sectionIcons}
/>
</>
);
};
export default EmojiSections;

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {ActivityIndicator, View} from 'react-native';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
const Footer = () => {
const theme = useTheme();
const styles = getStyleSheetFromTheme(theme);
return (
<View style={styles.loading}>
<ActivityIndicator color={theme.centerChannelColor}/>
</View>
);
};
const getStyleSheetFromTheme = makeStyleSheetFromTheme(() => {
return {
loading: {
flex: 1,
alignItems: 'center',
},
};
});
export default memo(Footer);

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {View} from 'react-native';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
section: EmojiSection;
}
export const SECTION_HEADER_HEIGHT = 28;
const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
sectionTitleContainer: {
height: SECTION_HEADER_HEIGHT,
justifyContent: 'center',
backgroundColor: theme.centerChannelBg,
},
sectionTitle: {
color: changeOpacity(theme.centerChannelColor, 0.2),
fontSize: 15,
fontWeight: '700',
},
};
});
const SectionHeader = ({section}: Props) => {
const theme = useTheme();
const styles = getStyleSheetFromTheme(theme);
return (
<View
style={styles.sectionTitleContainer}
key={section.id}
>
<FormattedText
style={styles.sectionTitle}
id={section.id}
defaultMessage={section.icon}
/>
</View>
);
};
export default memo(SectionHeader);

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {StyleProp, ViewStyle} from 'react-native';
import Emoji from '@components/emoji';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {preventDoubleTap} from '@utils/tap';
type Props = {
name: string;
onEmojiPress: (emoji: string) => void;
size?: number;
style: StyleProp<ViewStyle>;
}
const TouchableEmoji = ({name, onEmojiPress, size = 30, style}: Props) => {
const onPress = useCallback(preventDoubleTap(() => onEmojiPress(name)), []);
return (
<TouchableWithFeedback
onPress={onPress}
style={style}
type={'opacity'}
>
<Emoji
emojiName={name}
size={size}
/>
</TouchableWithFeedback>
);
};
export default React.memo(TouchableEmoji);

View File

@@ -5,7 +5,7 @@ import React, {useEffect} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native';
import {fetchMissinProfilesByIds, fetchMissinProfilesByUsernames} from '@actions/remote/user';
import {fetchMissingProfilesByIds, fetchMissingProfilesByUsernames} from '@actions/remote/user';
import Markdown from '@components/markdown';
import SystemAvatar from '@components/system_avatar';
import SystemHeader from '@components/system_header';
@@ -70,11 +70,11 @@ const CombinedUserActivity = ({
const loadUserProfiles = () => {
if (allUserIds.length) {
fetchMissinProfilesByIds(serverUrl, allUserIds);
fetchMissingProfilesByIds(serverUrl, allUserIds);
}
if (allUsernames.length) {
fetchMissinProfilesByUsernames(serverUrl, allUsernames);
fetchMissingProfilesByUsernames(serverUrl, allUsernames);
}
};

View File

@@ -24,7 +24,7 @@ const getStyleSheet = (scale: number, th: Theme) => {
moreImagesText: {
color: theme.sidebarHeaderTextColor,
fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale)),
fontFamily: 'Open Sans',
fontFamily: 'OpenSans',
textAlign: 'center',
},
};

View File

@@ -59,7 +59,7 @@ const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: Re
<Emoji
emojiName={emojiName}
size={20}
textStyle={{color: 'black', fontWeight: 'bold'}}
textStyle={{color: '#000'}}
customEmojiStyle={styles.customEmojiStyle}
testID={`reaction.emoji.${emojiName}`}
/>

View File

@@ -103,7 +103,7 @@ const Header = (props: HeaderProps) => {
theme={theme}
userId={post.userId}
/>
{showCustomStatusEmoji && customStatusExpired && Boolean(customStatus?.emoji) && (
{showCustomStatusEmoji && !customStatusExpired && Boolean(customStatus?.emoji) && (
<CustomStatusEmoji
customStatus={customStatus!}
style={style.customStatusEmoji}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback} from 'react';
import {Platform} from 'react-native';
import CompassIcon from '@components/compass_icon';
type ClearIconProps = {
deleteIconSizeAndroid: number;
onClear: () => void;
placeholderTextColor: string;
searchClearButtonTestID: string;
tintColorDelete: string;
titleCancelColor: string;
}
const ClearIcon = ({deleteIconSizeAndroid, onClear, placeholderTextColor, searchClearButtonTestID, tintColorDelete, titleCancelColor}: ClearIconProps) => {
const onPressClear = useCallback(() => onClear(), []);
if (Platform.OS === 'ios') {
return (
<CompassIcon
testID={searchClearButtonTestID}
name='close-circle'
size={18}
style={{color: tintColorDelete || 'grey'}}
onPress={onPressClear}
/>
);
}
return (
<CompassIcon
testID={searchClearButtonTestID}
name='close'
size={deleteIconSizeAndroid}
color={titleCancelColor || placeholderTextColor}
onPress={onPressClear}
/>
);
};
export default memo(ClearIcon);

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Platform, TouchableWithoutFeedback, ViewStyle} from 'react-native';
import CompassIcon from '@components/compass_icon';
type SearchIconProps = {
backArrowSize: number;
clearIconColorAndroid: string;
iOSStyle: ViewStyle;
onCancel: () => void;
searchCancelButtonTestID: string;
searchIconColor: string;
searchIconSize: number;
showArrow: boolean;
}
const SearchIcon = ({backArrowSize, clearIconColorAndroid, iOSStyle, onCancel, searchCancelButtonTestID, searchIconColor, searchIconSize, showArrow}: SearchIconProps) => {
if (Platform.OS === 'ios') {
return (
<CompassIcon
name='magnify'
size={24}
style={iOSStyle}
/>
);
}
if (showArrow) {
return (
<TouchableWithoutFeedback onPress={onCancel}>
<CompassIcon
testID={searchCancelButtonTestID}
name='arrow-left'
size={backArrowSize}
color={clearIconColorAndroid}
/>
</TouchableWithoutFeedback>
);
}
return (
<CompassIcon
name='magnify'
size={searchIconSize}
color={searchIconColor}
/>
);
};
export default SearchIcon;

View File

@@ -0,0 +1,316 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {IntlShape} from 'react-intl';
import {
Animated,
InteractionManager,
Keyboard,
StyleSheet,
View,
Platform,
ViewStyle,
ReturnKeyTypeOptions,
KeyboardTypeOptions,
NativeSyntheticEvent,
TextInputSelectionChangeEventData,
TextStyle,
} from 'react-native';
import {SearchBar} from 'react-native-elements';
import CompassIcon from '@components/compass_icon';
import ClearIcon from './components/clear_icon';
import SearchIcon from './components/search_icon';
import {getSearchStyles} from './styles';
const LEFT_COMPONENT_INITIAL_POSITION = Platform.OS === 'ios' ? 7 : 0;
type SearchProps = {
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters' | undefined;
autoFocus?: boolean;
backArrowSize?: number;
backgroundColor: string;
blurOnSubmit?: boolean;
cancelButtonStyle?: ViewStyle;
cancelTitle?: string;
containerHeight?: number;
containerStyle?: ViewStyle;
deleteIconSize?: number;
editable?: boolean;
inputHeight: number;
inputStyle?: TextStyle;
intl?: IntlShape;
keyboardAppearance?: 'default' | 'light' | 'dark' | undefined;
keyboardShouldPersist?: boolean;
keyboardType?: KeyboardTypeOptions | undefined;
leftComponent?: JSX.Element;
onBlur?: () => void;
onCancelButtonPress: (text?: string) => void;
onChangeText: (text: string) => void;
onFocus?: () => void;
onSearchButtonPress?: (value: string) => void;
onSelectionChange?: (e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
placeholder?: string;
placeholderTextColor?: string;
returnKeyType?: ReturnKeyTypeOptions | undefined;
searchBarRightMargin?: number;
searchIconSize?: number;
selectionColor?: string;
showArrow?: boolean;
showCancel?: boolean;
testID: string;
tintColorDelete: string;
tintColorSearch: string;
titleCancelColor: string;
value?: string;
};
type SearchState = {
leftComponentWidth: number;
}
export default class Search extends PureComponent<SearchProps, SearchState> {
static defaultProps = {
backArrowSize: 24,
blurOnSubmit: false,
containerHeight: 40,
deleteIconSize: 20,
editable: true,
keyboardShouldPersist: false,
keyboardType: 'default',
onBlur: () => true,
onSelectionChange: () => true,
placeholderTextColor: 'grey',
returnKeyType: 'search',
searchBarRightMargin: 0,
searchIconSize: 24,
showArrow: false,
showCancel: true,
value: '',
};
private readonly leftComponentAnimated: Animated.Value;
private readonly searchContainerAnimated: Animated.Value;
private searchContainerRef: any;
private inputKeywordRef: any;
private readonly searchStyle: any;
constructor(props: SearchProps | Readonly<SearchProps>) {
super(props);
this.state = {
leftComponentWidth: 0,
};
this.leftComponentAnimated = new Animated.Value(LEFT_COMPONENT_INITIAL_POSITION);
this.searchContainerAnimated = new Animated.Value(0);
const {backgroundColor, cancelButtonStyle, containerHeight, inputHeight, inputStyle, placeholderTextColor, searchBarRightMargin, tintColorDelete, tintColorSearch, titleCancelColor} = props;
this.searchStyle = getSearchStyles(backgroundColor, cancelButtonStyle, containerHeight!, inputHeight, inputStyle, placeholderTextColor!, searchBarRightMargin!, tintColorDelete, tintColorSearch, titleCancelColor);
}
setSearchContainerRef = (ref: any) => {
this.searchContainerRef = ref;
};
setInputKeywordRef = (ref: any) => {
this.inputKeywordRef = ref;
};
blur = () => {
this.inputKeywordRef.blur();
};
focus = () => {
this.inputKeywordRef.focus();
};
onBlur = async () => {
this.props?.onBlur?.();
if (this.props.leftComponent) {
await this.collapseAnimation();
}
};
onLeftComponentLayout = (event: { nativeEvent: { layout: { width: any } } }) => {
const leftComponentWidth = event.nativeEvent.layout.width;
this.setState({leftComponentWidth});
};
onSearch = async () => {
const {keyboardShouldPersist, onSearchButtonPress, value} = this.props;
if (!keyboardShouldPersist) {
await Keyboard.dismiss();
}
if (value) {
onSearchButtonPress?.(value);
}
};
onChangeText = (text: string) => {
const {onChangeText} = this.props;
if (onChangeText) {
onChangeText(text);
}
};
onFocus = () => {
const {leftComponent, onFocus} = this.props;
InteractionManager.runAfterInteractions(async () => {
onFocus?.();
if (leftComponent) {
await this.expandAnimation();
}
});
};
onClear = () => {
this.focus();
this.props.onChangeText('');
};
onCancel = () => {
const {onCancelButtonPress} = this.props;
Keyboard.dismiss();
InteractionManager.runAfterInteractions(() => {
return onCancelButtonPress?.();
});
};
onSelectionChange = (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
const {onSelectionChange} = this.props;
onSelectionChange?.(event);
};
expandAnimation = () => {
return new Promise((resolve) => {
Animated.parallel([
Animated.timing(this.leftComponentAnimated, {toValue: -115, duration: 200} as Animated.TimingAnimationConfig),
Animated.timing(this.searchContainerAnimated, {toValue: this.state.leftComponentWidth * -1, duration: 200} as Animated.TimingAnimationConfig),
]).start(resolve);
});
};
collapseAnimation = () => {
return new Promise((resolve) => {
Animated.parallel([
Animated.timing(this.leftComponentAnimated, {toValue: LEFT_COMPONENT_INITIAL_POSITION, duration: 200} as Animated.TimingAnimationConfig),
Animated.timing(this.searchContainerAnimated, {toValue: 0, duration: 200} as Animated.TimingAnimationConfig),
]).start(resolve);
});
};
render() {
const {autoCapitalize, autoFocus, backArrowSize, blurOnSubmit, cancelTitle, deleteIconSize, editable, intl, keyboardAppearance, keyboardType, leftComponent, placeholder, placeholderTextColor, returnKeyType, searchIconSize, selectionColor, showArrow, showCancel, testID, tintColorDelete, tintColorSearch, titleCancelColor, value} = this.props;
const searchClearButtonTestID = `${testID}.search.clear.button`;
const searchCancelButtonTestID = `${testID}.search.cancel.button`;
const searchInputTestID = `${testID}.search.input`;
const {cancelButtonPropStyle, containerStyle, inputContainerStyle, inputTextStyle, searchBarStyle, styles} = this.searchStyle;
return (
<View
testID={testID}
style={[searchBarStyle.container, this.props.containerStyle]}
>
{leftComponent && (
<Animated.View
style={[styles.leftComponent, {left: this.leftComponentAnimated}]}
onLayout={this.onLeftComponentLayout}
>
{leftComponent}
</Animated.View>
)}
<Animated.View
style={[
styles.fullWidth,
searchBarStyle.searchBarWrapper,
{marginLeft: this.searchContainerAnimated},
]}
>
<SearchBar
testID={searchInputTestID}
autoCapitalize={autoCapitalize}
autoCorrect={false}
autoFocus={autoFocus}
blurOnSubmit={blurOnSubmit}
cancelButtonProps={cancelButtonPropStyle}
cancelButtonTitle={cancelTitle || intl?.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'}) || ''}
cancelIcon={
// Making sure the icon won't change depending on whether the input is in focus on Android devices
Platform.OS === 'android' && (
<CompassIcon
testID={searchCancelButtonTestID}
name='arrow-left'
size={25}
color={searchBarStyle.clearIconColorAndroid}
onPress={this.onCancel}
/>
)
}
// @ts-expect-error: The clearIcon can also accept a ReactElement
clearIcon={
<ClearIcon
deleteIconSizeAndroid={deleteIconSize!}
onClear={this.onClear}
placeholderTextColor={placeholderTextColor!}
searchClearButtonTestID={searchClearButtonTestID}
tintColorDelete={tintColorDelete}
titleCancelColor={titleCancelColor}
/>
}
containerStyle={containerStyle}
disableFullscreenUI={true}
editable={editable}
enablesReturnKeyAutomatically={true}
inputContainerStyle={inputContainerStyle}
inputStyle={inputTextStyle}
keyboardAppearance={keyboardAppearance}
keyboardType={keyboardType}
leftIconContainerStyle={styles.leftIcon}
placeholder={placeholder || intl?.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'}) || ''}
placeholderTextColor={placeholderTextColor}
platform={Platform.OS as 'ios' | 'android'}
onBlur={this.onBlur}
onCancel={this.onCancel}
// @ts-expect-error: The TS definition for this SearchBar is messed up
onChangeText={this.onChangeText}
onClear={this.onClear}
onFocus={this.onFocus}
onSelectionChange={this.onSelectionChange}
onSubmitEditing={this.onSearch}
// @ts-expect-error: The searchIcon can also accept a ReactElement
searchIcon={
<SearchIcon
searchIconColor={tintColorSearch || placeholderTextColor!}
searchIconSize={searchIconSize!}
clearIconColorAndroid={titleCancelColor || placeholderTextColor!}
backArrowSize={backArrowSize!}
searchCancelButtonTestID={searchCancelButtonTestID}
onCancel={this.onCancel}
showArrow={showArrow!}
iOSStyle={StyleSheet.flatten([styles.fullWidth, searchBarStyle.searchIcon])}
/>
}
selectionColor={selectionColor}
showCancel={showCancel!}
ref={this.setInputKeywordRef}
returnKeyType={returnKeyType}
underlineColorAndroid='transparent'
value={value!}
/>
</Animated.View>
</View>
);
}
}

View File

@@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform, StyleSheet, TextStyle, ViewStyle} from 'react-native';
export const getSearchBarStyle = (backgroundColor: string, cancelButtonStyle: ViewStyle | undefined, containerHeight: number, inputHeight: number, inputStyle: TextStyle | undefined, placeholderTextColor: string, searchBarRightMargin: number, tintColorDelete: string, tintColorSearch: string, titleCancelColor: string) => ({
cancelButtonText: {
...cancelButtonStyle,
color: titleCancelColor,
},
container: {
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
height: containerHeight,
overflow: 'hidden',
},
clearIconColorIos: tintColorDelete || 'grey',
clearIconColorAndroid: titleCancelColor || placeholderTextColor,
inputStyle: {
...inputStyle,
backgroundColor: 'transparent',
height: inputHeight,
},
inputContainer: {
backgroundColor: inputStyle?.backgroundColor,
height: inputHeight,
},
searchBarWrapper: {
marginRight: searchBarRightMargin,
height: Platform.select({
ios: inputHeight || containerHeight - 10,
android: inputHeight,
}),
},
searchBarContainer: {
backgroundColor,
},
searchIcon: {
color: tintColorSearch || placeholderTextColor,
top: 8,
},
searchIconColor: tintColorSearch || placeholderTextColor,
});
export const getStyles = () => StyleSheet.create({
defaultColor: {
color: 'grey',
},
fullWidth: {
flex: 1,
},
inputContainer: {
borderRadius: Platform.select({
ios: 2,
android: 0,
}),
},
inputMargin: {
marginLeft: 4,
paddingTop: 0,
marginTop: Platform.select({
ios: 0,
android: 8,
}),
},
leftIcon: {
marginLeft: 4,
width: 30,
},
searchContainer: {
paddingTop: 0,
paddingBottom: 0,
},
text: {
fontSize: Platform.select({
ios: 14,
android: 15,
}),
color: '#fff',
},
leftComponent: {
position: 'relative',
marginLeft: 2,
},
});
export const getSearchStyles = (backgroundColor: string, cancelButtonStyle: ViewStyle | undefined, containerHeight: number, inputHeight: number, inputStyle: TextStyle | undefined, placeholderTextColor: string, searchBarRightMargin: number, tintColorDelete: string, tintColorSearch: string, titleCancelColor: string) => {
const searchBarStyle = getSearchBarStyle(backgroundColor, cancelButtonStyle, containerHeight, inputHeight, inputStyle, placeholderTextColor, searchBarRightMargin, tintColorDelete, tintColorSearch, titleCancelColor);
const styles = getStyles();
const inputTextStyle = {
...styles.text,
...styles.inputMargin,
...searchBarStyle.inputStyle,
};
const inputContainerStyle = {
...styles.inputContainer,
...searchBarStyle.inputContainer,
};
const containerStyle = {
...styles.searchContainer,
...styles.fullWidth,
...searchBarStyle.searchBarContainer,
};
const cancelButtonPropStyle = {
buttonTextStyle: {
...styles.text,
...searchBarStyle.cancelButtonText,
},
};
return {
cancelButtonPropStyle,
containerStyle,
inputContainerStyle,
inputTextStyle,
searchBarStyle,
styles,
};
};

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {StyleProp, Text, View, ViewStyle} from 'react-native';
import {StyleProp, Text, TextStyle, View, ViewStyle} from 'react-native';
import FastImage, {ImageStyle, Source} from 'react-native-fast-image';
import CompassIcon from '@components/compass_icon';
@@ -15,7 +15,9 @@ import {isValidUrl} from '@utils/url';
type SlideUpPanelProps = {
destructive?: boolean;
icon?: string | Source;
imageStyles?: StyleProp<TextStyle>;
onPress: () => void;
textStyles?: TextStyle;
testID?: string;
text: string;
}
@@ -32,14 +34,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
color: '#D0021B',
},
row: {
flex: 1,
width: '100%',
flexDirection: 'row',
},
iconContainer: {
alignItems: 'center',
height: 50,
justifyContent: 'center',
width: 60,
marginRight: 10,
},
noIconContainer: {
height: 50,
@@ -61,15 +62,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
opacity: 0.9,
letterSpacing: -0.45,
},
footer: {
marginHorizontal: 17,
borderBottomWidth: 0.5,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2),
},
};
});
const SlideUpPanelItem = ({destructive, icon, onPress, testID, text}: SlideUpPanelProps) => {
const SlideUpPanelItem = ({destructive, icon, imageStyles, onPress, testID, text, textStyles}: SlideUpPanelProps) => {
const theme = useTheme();
const handleOnPress = useCallback(preventDoubleTap(onPress, 500), []);
const style = getStyleSheet(theme);
@@ -77,7 +73,7 @@ const SlideUpPanelItem = ({destructive, icon, onPress, testID, text}: SlideUpPan
let image;
let iconStyle: StyleProp<ViewStyle> = [style.iconContainer];
if (icon) {
const imageStyle: StyleProp<ImageStyle> = [style.icon];
const imageStyle: StyleProp<ImageStyle> = [style.icon, imageStyles];
if (destructive) {
imageStyle.push(style.destructive);
}
@@ -105,27 +101,22 @@ const SlideUpPanelItem = ({destructive, icon, onPress, testID, text}: SlideUpPan
}
return (
<View
testID={testID}
<TouchableWithFeedback
onPress={handleOnPress}
style={style.container}
testID={testID}
type='native'
underlayColor={changeOpacity(theme.centerChannelColor, 0.5)}
>
<TouchableWithFeedback
onPress={handleOnPress}
style={style.row}
type='native'
underlayColor={changeOpacity(theme.centerChannelColor, 0.5)}
>
<View style={style.row}>
{Boolean(image) &&
<View style={iconStyle}>{image}</View>
}
<View style={style.textContainer}>
<Text style={[style.text, destructive ? style.destructive : null]}>{text}</Text>
</View>
<View style={style.row}>
{Boolean(image) &&
<View style={iconStyle}>{image}</View>
}
<View style={style.textContainer}>
<Text style={[style.text, destructive ? style.destructive : null, textStyles]}>{text}</Text>
</View>
</TouchableWithFeedback>
<View style={style.footer}/>
</View>
</View>
</TouchableWithFeedback>
);
};

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 {TextStyle} from 'react-native';
import FormattedText from '@components/formatted_text';
import {General} from '@constants';
import {useTheme} from '@context/theme';
import {t} from '@i18n';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type StatusLabelProps = {
status?: string;
labelStyle?: TextStyle;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
label: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 17,
textAlignVertical: 'center',
includeFontPadding: false,
},
};
});
const StatusLabel = ({status = General.OFFLINE, labelStyle}: StatusLabelProps) => {
const theme = useTheme();
const style = getStyleSheet(theme);
let i18nId = t('status_dropdown.set_offline');
let defaultMessage = 'Offline';
switch (status) {
case General.AWAY:
i18nId = t('status_dropdown.set_away');
defaultMessage = 'Away';
break;
case General.DND:
i18nId = t('status_dropdown.set_dnd');
defaultMessage = 'Do Not Disturb';
break;
case General.ONLINE:
i18nId = t('status_dropdown.set_online');
defaultMessage = 'Online';
break;
}
if (status === General.OUT_OF_OFFICE) {
i18nId = t('status_dropdown.set_ooo');
defaultMessage = 'Out Of Office';
}
return (
<FormattedText
id={i18nId}
defaultMessage={defaultMessage}
style={[style.label, labelStyle]}
testID={`user_status.label.${status}`}
/>
);
};
export default StatusLabel;

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Platform, Text, View} from 'react-native';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
action?: string;
onPress: () => void;
title: string;
testID: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
actionContainer: {
alignItems: 'flex-end',
justifyContent: 'center',
marginRight: 20,
},
action: {
color: theme.buttonBg,
fontFamily: 'OpenSans-Semibold',
fontSize: 16,
lineHeight: 24,
},
container: {
backgroundColor: theme.centerChannelBg,
borderBottomWidth: 1,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.08),
flexDirection: 'row',
height: 34,
width: '100%',
},
titleContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
title: {
color: theme.centerChannelColor,
fontFamily: 'OpenSans-Semibold',
fontSize: 18,
lineHeight: 24,
},
}));
const TabletTitle = ({action, onPress, testID, title}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<>
<View style={styles.container}>
<View style={styles.titleContainer}>
<Text style={styles.title}>{title}</Text>
</View>
{Boolean(action) &&
<View style={styles.actionContainer}>
<TouchableWithFeedback
onPress={onPress}
type={Platform.select({android: 'native', ios: 'opacity'})}
testID={testID}
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
>
<Text style={styles.action}>{action}</Text>
</TouchableWithFeedback>
</View>
}
</View>
</>
);
};
export default TabletTitle;

View File

@@ -4,16 +4,15 @@
/* eslint-disable new-cap */
import React, {memo} from 'react';
import {TouchableOpacity, TouchableWithoutFeedback, View, StyleProp, ViewStyle} from 'react-native';
import {Touchable, TouchableOpacity, TouchableWithoutFeedback, View, StyleProp, ViewStyle} from 'react-native';
import {TouchableNativeFeedback} from 'react-native-gesture-handler';
type TouchableProps = {
type TouchableProps = Touchable & {
testID: string;
children: React.ReactNode | React.ReactNode[];
underlayColor: string;
type: 'native' | 'opacity' | 'none';
style?: StyleProp<ViewStyle>;
[x: string]: any;
}
const TouchableWithFeedbackAndroid = ({testID, children, underlayColor, type = 'native', ...props}: TouchableProps) => {
@@ -23,7 +22,7 @@ const TouchableWithFeedbackAndroid = ({testID, children, underlayColor, type = '
<TouchableNativeFeedback
testID={testID}
{...props}
style={[props.style, {flex: undefined, flexDirection: undefined, width: '100%', height: '100%'}]}
style={[props.style]}
background={TouchableNativeFeedback.Ripple(underlayColor || '#fff', false)}
>
<View>

View File

@@ -2,14 +2,13 @@
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {PanResponder, TouchableHighlight, TouchableOpacity, TouchableWithoutFeedback, View} from 'react-native';
import {PanResponder, Touchable, TouchableHighlight, TouchableOpacity, TouchableWithoutFeedback, View} from 'react-native';
type TouchableProps = {
type TouchableProps = Touchable & {
cancelTouchOnPanning: boolean;
children: React.ReactNode | React.ReactNode[];
testID: string;
type: 'native' | 'opacity' | 'none';
[x: string]: any;
}
const TouchableWithFeedbackIOS = ({testID, children, type = 'native', cancelTouchOnPanning, ...props}: TouchableProps) => {

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {t} from '@i18n';
export enum CustomStatusDuration {
DONT_CLEAR = '',
THIRTY_MINUTES = 'thirty_minutes',
ONE_HOUR = 'one_hour',
FOUR_HOURS = 'four_hours',
TODAY = 'today',
THIS_WEEK = 'this_week',
DATE_AND_TIME = 'date_and_time',
}
const {
DONT_CLEAR,
THIRTY_MINUTES,
ONE_HOUR,
FOUR_HOURS,
TODAY,
THIS_WEEK,
DATE_AND_TIME,
} = CustomStatusDuration;
export const CST = {
[DONT_CLEAR]: {
id: t('custom_status.expiry_dropdown.dont_clear'),
defaultMessage: "Don't clear",
},
[THIRTY_MINUTES]: {
id: t('custom_status.expiry_dropdown.thirty_minutes'),
defaultMessage: '30 minutes',
},
[ONE_HOUR]: {
id: t('custom_status.expiry_dropdown.one_hour'),
defaultMessage: '1 hour',
},
[FOUR_HOURS]: {
id: t('custom_status.expiry_dropdown.four_hours'),
defaultMessage: '4 hours',
},
[TODAY]: {
id: t('custom_status.expiry_dropdown.today'),
defaultMessage: 'Today',
},
[THIS_WEEK]: {
id: t('custom_status.expiry_dropdown.this_week'),
defaultMessage: 'This week',
},
[DATE_AND_TIME]: {
id: t('custom_status.expiry_dropdown.date_and_time'),
defaultMessage: 'Date and Time',
},
};
export const CUSTOM_STATUS_TEXT_CHARACTER_LIMIT = 100;
export const SET_CUSTOM_STATUS_FAILURE = 'set_custom_status_failure';
export const CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES = 30;

View File

@@ -57,6 +57,7 @@ export const SYSTEM_IDENTIFIERS = {
INTEGRATION_TRIGGER_ID: 'IntegreationTriggerId',
LICENSE: 'license',
WEBSOCKET: 'WebSocket',
RECENT_CUSTOM_STATUS: 'recentCustomStatus',
};
export const GLOBAL_IDENTIFIERS = {

View File

@@ -3,6 +3,8 @@
export const ALL_EMOJIS = 'all_emojis';
export const MAX_ALLOWED_REACTIONS = 40;
export const SORT_BY_NAME = 'name';
export const EMOJIS_PER_PAGE = 200;
// reEmoji matches an emoji (eg. :taco:) at the start of a string.
export const reEmoji = /^:([a-z0-9_\-+]+):\B/i;
@@ -13,8 +15,8 @@ export const reEmoticon = /^(?:(:-?\))|(;-?\))|(:o)|(:-o)|(:-?])|(:-?d)|(x-d)|(:
// reMain matches some amount of plain text, starting at the beginning of the string and hopefully stopping right
// before the next emoji by looking for any character that could start an emoji (:, ;, x, or <)
export const reMain = /^[\s\S]+?(?=[:;x<]|$)/i;
export default {
ALL_EMOJIS,
MAX_ALLOWED_REACTIONS,
SORT_BY_NAME,
};

8
app/constants/events.ts Normal file
View File

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import keyMirror from '@utils/key_mirror';
export default keyMirror({
ACCOUNT_SELECT_TABLET_VIEW: null,
});

View File

@@ -4,9 +4,12 @@
import ActionType from './action_type';
import Apps from './apps';
import Attachment from './attachment';
import {CustomStatusDuration} from './custom_status';
import Database from './database';
import DeepLink from './deep_linking';
import Device from './device';
import Emoji from './emoji';
import Events from './events';
import Files from './files';
import General from './general';
import List from './list';
@@ -22,11 +25,14 @@ import WebsocketEvents from './websocket';
export {
ActionType,
Attachment,
Apps,
Attachment,
CustomStatusDuration,
Database,
DeepLink,
Device,
Emoji,
Events,
Files,
General,
List,

View File

@@ -6,6 +6,7 @@ const Preferences: Record<string, any> = {
CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME: 'channel_approximate_view_time',
CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show',
CATEGORY_GROUP_CHANNEL_SHOW: 'group_channel_show',
CATEGORY_EMOJI: 'emoji',
CATEGORY_FLAGGED_POST: 'flagged_post',
CATEGORY_FAVORITE_CHANNEL: 'favorite_channel',
CATEGORY_AUTO_RESET_MANUAL_STATUS: 'auto_reset_manual_status',
@@ -28,6 +29,7 @@ const Preferences: Record<string, any> = {
DISPLAY_PREFER_NICKNAME: 'nickname_full_name',
DISPLAY_PREFER_FULL_NAME: 'full_name',
DISPLAY_PREFER_USERNAME: 'username',
EMOJI_SKINTONE: 'emoji_skintone',
MENTION_KEYS: 'mention_keys',
USE_MILITARY_TIME: 'use_military_time',
CATEGORY_SIDEBAR_SETTINGS: 'sidebar_settings',

View File

@@ -2,12 +2,15 @@
// See LICENSE.txt for license information.
export const ABOUT = 'About';
export const EMOJI_PICKER = 'AddReaction';
export const APP_FORM = 'AppForm';
export const BOTTOM_SHEET = 'BottomSheet';
export const CHANNEL = 'Channel';
export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter';
export const CUSTOM_STATUS = 'CustomStatus';
export const FORGOT_PASSWORD = 'ForgotPassword';
export const INTEGRATION_SELECTOR = 'IntegrationSelector';
export const HOME = 'Home';
export const INTEGRATION_SELECTOR = 'IntegrationSelector';
export const LOGIN = 'Login';
export const LOGIN_OPTIONS = 'LoginOptions';
export const MAIN_SIDEBAR = 'MainSidebar';
@@ -21,12 +24,15 @@ export const THREAD = 'Thread';
export default {
ABOUT,
EMOJI_PICKER,
APP_FORM,
BOTTOM_SHEET,
CHANNEL,
CUSTOM_STATUS_CLEAR_AFTER,
CUSTOM_STATUS,
FORGOT_PASSWORD,
INTEGRATION_SELECTOR,
HOME,
INTEGRATION_SELECTOR,
LOGIN,
LOGIN_OPTIONS,
MAIN_SIDEBAR,

View File

@@ -5,17 +5,19 @@ import {Q} from '@nozbe/watermelondb';
import withObservables from '@nozbe/with-observables';
import React, {ComponentType, createContext, useEffect} from 'react';
import {Appearance, EventSubscription} from 'react-native';
import {of as of$} from 'rxjs';
import {catchError, switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import EphemeralStore from '@store/ephemeral_store';
import {setNavigationStackStyles} from '@utils/theme';
import {setNavigationStackStyles, setThemeDefaults} from '@utils/theme';
import type {PreferenceModel, SystemModel} from '@database/models/server';
import type Database from '@nozbe/watermelondb/Database';
type Props = {
currentTeamId: SystemModel[];
currentTeamId?: string;
children: React.ReactNode;
themes: PreferenceModel[];
}
@@ -38,12 +40,11 @@ const {Consumer, Provider} = ThemeContext;
const ThemeProvider = ({currentTeamId, children, themes}: Props) => {
const getTheme = (): Theme => {
if (currentTeamId.length) {
const teamId = currentTeamId[0]?.value;
const teamTheme = themes.find((t) => t.name === teamId) || themes[0];
if (currentTeamId) {
const teamTheme = themes.find((t) => t.name === currentTeamId) || themes[0];
if (teamTheme?.value) {
try {
const theme = JSON.parse(teamTheme.value) as Theme;
const theme = setThemeDefaults(JSON.parse(teamTheme.value) as Theme);
EphemeralStore.theme = theme;
requestAnimationFrame(() => {
setNavigationStackStyles(theme);
@@ -87,7 +88,10 @@ export function useTheme(): Theme {
}
const enhancedThemeProvider = withObservables([], ({database}: {database: Database}) => ({
currentTeamId: database.get(SYSTEM).query(Q.where('id', SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID)).observe(),
currentTeamId: database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe(
switchMap((row) => of$(row.value)),
catchError(() => of$(undefined)),
),
themes: database.get(PREFERENCE).query(Q.where('category', Preferences.CATEGORY_THEME)).observeWithColumns(['value']),
}));

View File

@@ -15,21 +15,22 @@ import type ServersModel from '@typings/database/models/app/servers';
type State = {
database: Database;
serverUrl: string;
}
};
const {SERVERS} = MM_TABLES.APP;
export function withServerDatabase<T>(
Component: ComponentType<T>,
): ComponentType<T> {
export function withServerDatabase<T>(Component: ComponentType<T>): ComponentType<T> {
return function ServerDatabaseComponent(props) {
const [state, setState] = useState<State|undefined>();
const [state, setState] = useState<State | undefined>();
const db = DatabaseManager.appDatabase?.database;
const observer = (servers: ServersModel[]) => {
const server = servers.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a));
const server = servers.reduce((a, b) =>
(b.lastActiveAt > a.lastActiveAt ? b : a),
);
const serverDatabase = DatabaseManager.serverDatabases[server?.url]?.database;
const serverDatabase =
DatabaseManager.serverDatabases[server?.url]?.database;
setState({
database: serverDatabase,
@@ -54,9 +55,7 @@ export function withServerDatabase<T>(
}
return (
<DatabaseProvider
database={(state.database)}
>
<DatabaseProvider database={state.database}>
<ServerUrlProvider url={state.serverUrl}>
<ThemeProvider database={state.database}>
<Component {...props}/>

View File

@@ -136,7 +136,7 @@ export default class UserModel extends Model {
/** teams : All the team that this user is part of */
@children(TEAM_MEMBERSHIP) teams!: TeamMembershipModel[];
prepareSatus = (status: string) => {
prepareStatus = (status: string) => {
this.prepareUpdate((u) => {
u.status = status;
});

View File

@@ -8,7 +8,7 @@ import {Notifications} from 'react-native-notifications';
import {appEntry, upgradeEntry} from '@actions/remote/entry';
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {getActiveServerUrl, getServerCredentials} from '@init/credentials';
import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials';
import {queryThemeForCurrentTeam} from '@queries/servers/preference';
import {queryCurrentUserId} from '@queries/servers/system';
import {goToScreen, resetToHome, resetToSelectServer} from '@screens/navigation';
@@ -43,7 +43,7 @@ const launchAppFromNotification = (notification: NotificationWithData) => {
};
const launchApp = async (props: LaunchProps, resetNavigation = true) => {
let serverUrl;
let serverUrl: string | undefined;
switch (props?.launchType) {
case LaunchType.DeepLink:
if (props.extra?.type !== DeepLinkType.Invalid) {
@@ -85,7 +85,11 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => {
`An error ocurred while upgrading the app to the new version.\n\nDetails: ${result.error}\n\nThe app will now quit.`,
[{
text: 'OK',
onPress: () => Emm.exitApp(),
onPress: async () => {
await DatabaseManager.destroyServerDatabase(serverUrl!);
await removeServerCredentials(serverUrl!);
Emm.exitApp();
},
}],
);
return;

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
export const queryAllCustomEmojis = async (database: Database): Promise<CustomEmojiModel[]> => {
try {
return database.get<CustomEmojiModel>(MM_TABLES.SERVER.CUSTOM_EMOJI).query().fetch();
} catch {
return [];
}
};
export const queryCustomEmojisByName = async (database: Database, names: string[]): Promise<CustomEmojiModel[]> => {
try {
return database.get<CustomEmojiModel>(MM_TABLES.SERVER.CUSTOM_EMOJI).query(Q.where('name', Q.oneOf(names))).fetch();
} catch {
return [];
}
};

View File

@@ -23,10 +23,13 @@ export const prepareMyPreferences = (operator: ServerDataOperator, preferences:
};
export const queryPreferencesByCategoryAndName = (database: Database, category: string, name: string) => {
return database.get(MM_TABLES.SERVER.PREFERENCE).query(
Q.where('category', category),
Q.where('name', name),
).fetch() as Promise<PreferenceModel[]>;
return database.
get<PreferenceModel>(MM_TABLES.SERVER.PREFERENCE).
query(
Q.where('category', category),
Q.where('name', name),
).
fetch();
};
export const queryThemeForCurrentTeam = async (database: Database) => {

View File

@@ -90,6 +90,15 @@ export const queryConfig = async (serverDatabase: Database) => {
}
};
export const queryRecentCustomStatuses = async (serverDatabase: Database) => {
try {
const recent = await serverDatabase.get<SystemModel>(SYSTEM).find(SYSTEM_IDENTIFIERS.RECENT_CUSTOM_STATUS);
return recent;
} catch {
return undefined;
}
};
export const queryExpandedLinks = async (serverDatabase: Database) => {
try {
const expandedLinks = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.EXPANDED_LINKS) as SystemModel;

View File

@@ -2,39 +2,55 @@
// See LICENSE.txt for license information.
import React, {ReactNode, useEffect, useRef} from 'react';
import {BackHandler, DeviceEventEmitter, StyleSheet, useWindowDimensions, View} from 'react-native';
import {BackHandler, DeviceEventEmitter, Keyboard, StyleSheet, useWindowDimensions, View} from 'react-native';
import {State, TapGestureHandler} from 'react-native-gesture-handler';
import {Navigation as RNN} from 'react-native-navigation';
import Animated from 'react-native-reanimated';
import RNBottomSheet from 'reanimated-bottom-sheet';
import {Navigation} from '@constants';
import {changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme';
import {Device, Navigation} from '@constants';
import {useTheme} from '@context/theme';
import {useSplitView} from '@hooks/device';
import {dismissModal} from '@screens/navigation';
import {hapticFeedback} from '@utils/general';
import Indicator from './indicator';
type SlideUpPanelProps = {
closeButtonId?: string;
initialSnapIndex?: number;
renderContent: () => ReactNode;
snapPoints?: Array<string | number>;
}
const BottomSheet = ({initialSnapIndex = 0, renderContent, snapPoints = ['90%', '50%', 50]}: SlideUpPanelProps) => {
const BottomSheet = ({closeButtonId, initialSnapIndex = 0, renderContent, snapPoints = ['90%', '50%', 50]}: SlideUpPanelProps) => {
const sheetRef = useRef<RNBottomSheet>(null);
const dimensions = useWindowDimensions();
const isSplitView = useSplitView();
const isTablet = Device.IS_TABLET && !isSplitView;
const theme = useTheme();
const lastSnap = snapPoints.length - 1;
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Navigation.NAVIGATION_CLOSE_MODAL, () => sheetRef.current?.snapTo(lastSnap));
const listener = DeviceEventEmitter.addListener(Navigation.NAVIGATION_CLOSE_MODAL, () => {
if (sheetRef.current) {
sheetRef.current.snapTo(lastSnap);
} else {
dismissModal();
}
});
return () => listener.remove();
}, []);
useEffect(() => {
const listener = BackHandler.addEventListener('hardwareBackPress', () => {
sheetRef.current?.snapTo(1);
if (sheetRef.current) {
sheetRef.current.snapTo(1);
} else {
dismissModal();
}
return true;
});
@@ -43,9 +59,20 @@ const BottomSheet = ({initialSnapIndex = 0, renderContent, snapPoints = ['90%',
useEffect(() => {
hapticFeedback();
Keyboard.dismiss();
sheetRef.current?.snapTo(initialSnapIndex);
}, []);
useEffect(() => {
const navigationEvents = RNN.events().registerNavigationButtonPressedListener(({buttonId}) => {
if (closeButtonId && buttonId === closeButtonId) {
dismissModal();
}
});
return () => navigationEvents.remove();
}, []);
const renderBackdrop = () => {
return (
<TapGestureHandler
@@ -69,9 +96,11 @@ const BottomSheet = ({initialSnapIndex = 0, renderContent, snapPoints = ['90%',
style={{
backgroundColor: theme.centerChannelBg,
opacity: 1,
padding: 16,
paddingHorizontal: 16,
paddingBottom: isTablet ? 20 : 16,
paddingTop: isTablet ? 0 : 16,
height: '100%',
width: Math.min(dimensions.width, 450),
width: isTablet ? '100%' : Math.min(dimensions.width, 450),
alignSelf: 'center',
}}
>
@@ -79,6 +108,16 @@ const BottomSheet = ({initialSnapIndex = 0, renderContent, snapPoints = ['90%',
</View>
);
if (isTablet) {
const styles = getStyleSheet(theme);
return (
<>
<View style={styles.separator}/>
{renderContainer()}
</>
);
}
return (
<>
<RNBottomSheet
@@ -97,4 +136,14 @@ const BottomSheet = ({initialSnapIndex = 0, renderContent, snapPoints = ['90%',
);
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
separator: {
height: 1,
borderTopWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.08),
},
};
});
export default BottomSheet;

View File

@@ -0,0 +1,102 @@
// 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, TouchableOpacity, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import CustomStatusExpiry from '@components/custom_status/custom_status_expiry';
import FormattedText from '@components/formatted_text';
import {CustomStatusDuration, CST} from '@constants/custom_status';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type UserModel from '@typings/database/models/servers/user';
import type {Moment} from 'moment-timezone';
type Props = {
currentUser: UserModel;
duration: CustomStatusDuration;
onOpenClearAfterModal: () => void;
theme: Theme;
expiresAt: Moment;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
rightIcon: {
position: 'absolute',
right: 18,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
expiryTimeLabel: {
fontSize: 17,
paddingLeft: 16,
textAlignVertical: 'center',
color: theme.centerChannelColor,
},
inputContainer: {
justifyContent: 'center',
height: 48,
backgroundColor: theme.centerChannelBg,
},
expiryTime: {
position: 'absolute',
right: 42,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
customStatusExpiry: {
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});
const ClearAfter = ({currentUser, duration, expiresAt, onOpenClearAfterModal, theme}: Props) => {
const intl = useIntl();
const style = getStyleSheet(theme);
const renderClearAfterTime = () => {
if (duration && duration === CustomStatusDuration.DATE_AND_TIME) {
return (
<View style={style.expiryTime}>
<CustomStatusExpiry
currentUser={currentUser}
textStyles={style.customStatusExpiry}
theme={theme}
time={expiresAt.toDate()}
/>
</View>
);
}
return (
<FormattedText
id={CST[duration].id}
defaultMessage={CST[duration].defaultMessage}
style={style.expiryTime}
/>
);
};
return (
<TouchableOpacity
testID={'custom_status.clear_after.action'}
onPress={onOpenClearAfterModal}
>
<View
testID={`custom_status.duration.${duration}`}
style={style.inputContainer}
>
<Text style={style.expiryTimeLabel}>{intl.formatMessage({id: 'mobile.custom_status.clear_after', defaultMessage: 'Clear After'})}</Text>
{renderClearAfterTime()}
<CompassIcon
name='chevron-right'
size={24}
style={style.rightIcon}
/>
</View>
</TouchableOpacity>
);
};
export default ClearAfter;

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text, TouchableOpacity} from 'react-native';
import CompassIcon from '@components/compass_icon';
import Emoji from '@components/emoji';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
onPress: () => void;
isStatusSet: boolean;
emoji?: string;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
iconContainer: {
position: 'absolute',
left: 14,
top: 10,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.64),
},
emoji: {
color: theme.centerChannelColor,
},
};
});
const CustomStatusEmoji = ({emoji, isStatusSet, onPress, theme}: Props) => {
const style = getStyleSheet(theme);
return (
<TouchableOpacity
testID={`custom_status.emoji.${isStatusSet ? (emoji || 'speech_balloon') : 'default'}`}
onPress={onPress}
style={style.iconContainer}
>
{isStatusSet ? (
<Text style={style.emoji}>
<Emoji
emojiName={emoji || 'speech_balloon'}
size={20}
/>
</Text>
) : (
<CompassIcon
name='emoticon-happy-outline'
size={24}
style={style.icon}
/>
)}
</TouchableOpacity>
);
};
export default CustomStatusEmoji;

View File

@@ -0,0 +1,103 @@
// 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 {TextInput, View} from 'react-native';
import ClearButton from '@components/custom_status/clear_button';
import {CUSTOM_STATUS_TEXT_CHARACTER_LIMIT} from '@constants/custom_status';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import CustomStatusEmoji from './custom_status_emoji';
type Props = {
emoji?: string;
isStatusSet: boolean;
onChangeText: (value: string) => void;
onClearHandle: () => void;
onOpenEmojiPicker: () => void;
text?: string;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1,
marginRight: 16,
marginLeft: 52,
},
clearButton: {
position: 'absolute',
top: 3,
right: 14,
},
input: {
alignSelf: 'stretch',
color: theme.centerChannelColor,
width: '100%',
fontSize: 17,
paddingHorizontal: 52,
textAlignVertical: 'center',
height: '100%',
},
inputContainer: {
justifyContent: 'center',
height: 48,
backgroundColor: theme.centerChannelBg,
},
};
});
const CustomStatusInput = ({emoji, isStatusSet, onChangeText, onClearHandle, onOpenEmojiPicker, text, theme}: Props) => {
const style = getStyleSheet(theme);
const intl = useIntl();
const placeholder = intl.formatMessage({id: 'custom_status.set_status', defaultMessage: 'Set a Status'});
return (
<View style={style.inputContainer}>
<TextInput
testID='custom_status.input'
autoCapitalize='none'
autoCorrect={false}
blurOnSubmit={false}
disableFullscreenUI={true}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
keyboardType='default'
maxLength={CUSTOM_STATUS_TEXT_CHARACTER_LIMIT}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
returnKeyType='go'
style={style.input}
secureTextEntry={false}
underlineColorAndroid='transparent'
value={text}
/>
{isStatusSet && (
<View style={style.divider}/>
)}
<CustomStatusEmoji
emoji={emoji}
isStatusSet={isStatusSet}
onPress={onOpenEmojiPicker}
theme={theme}
/>
{isStatusSet ? (
<View
style={style.clearButton}
testID='custom_status.input.clear.button'
>
<ClearButton
handlePress={onClearHandle}
theme={theme}
/>
</View>
) : null}
</View>
);
};
export default CustomStatusInput;

View File

@@ -0,0 +1,144 @@
// 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, TouchableOpacity, View} from 'react-native';
import ClearButton from '@components/custom_status/clear_button';
import CustomStatusText from '@components/custom_status/custom_status_text';
import Emoji from '@components/emoji';
import {CST, CustomStatusDuration} from '@constants/custom_status';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
duration: CustomStatusDuration;
emoji?: string;
expires_at?: string;
handleClear?: (status: UserCustomStatus) => void;
handleSuggestionClick: (status: UserCustomStatus) => void;
isExpirySupported: boolean;
separator: boolean;
text?: string;
theme: Theme;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
backgroundColor: theme.centerChannelBg,
flexDirection: 'row',
minHeight: 50,
},
iconContainer: {
width: 45,
height: 46,
left: 14,
top: 12,
marginRight: 6,
color: theme.centerChannelColor,
},
wrapper: {
flex: 1,
},
textContainer: {
paddingTop: 14,
paddingBottom: 14,
justifyContent: 'center',
width: '70%',
flex: 1,
},
clearButtonContainer: {
position: 'absolute',
top: 4,
right: 13,
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1,
marginRight: 16,
},
customStatusDuration: {
color: changeOpacity(theme.centerChannelColor, 0.6),
fontSize: 15,
},
customStatusText: {
color: theme.centerChannelColor,
},
};
});
const CustomStatusSuggestion = ({duration, emoji, expires_at, handleClear, handleSuggestionClick, isExpirySupported, separator, text, theme}: Props) => {
const style = getStyleSheet(theme);
const intl = useIntl();
const handleClick = useCallback(preventDoubleTap(() => {
handleSuggestionClick({emoji, text, duration});
}), []);
const handleSuggestionClear = useCallback(() => {
if (handleClear) {
handleClear({emoji, text, duration, expires_at});
}
}, []);
const showCustomStatus = Boolean(duration && duration !== CustomStatusDuration.DATE_AND_TIME && isExpirySupported);
const clearButton =
handleClear && expires_at ? (
<View style={style.clearButtonContainer}>
<ClearButton
handlePress={handleSuggestionClear}
theme={theme}
iconName='close'
size={18}
testID='custom_status_suggestion.clear.button'
/>
</View>
) : null;
return (
<TouchableOpacity
testID={`custom_status_suggestion.${text}`}
onPress={handleClick}
>
<View style={style.container}>
{emoji && (
<Text style={style.iconContainer}>
<Emoji
emojiName={emoji}
size={20}
/>
</Text>
)}
<View style={style.wrapper}>
<View style={style.textContainer}>
{Boolean(text) && (
<View>
<CustomStatusText
text={text}
theme={theme}
textStyle={style.customStatusText}
/>
</View>
)}
{showCustomStatus && (
<View style={{paddingTop: 5}}>
<CustomStatusText
text={intl.formatMessage(CST[duration])}
theme={theme}
textStyle={style.customStatusDuration}
/>
</View>
)}
</View>
{clearButton}
{separator && <View style={style.divider}/>}
</View>
</View>
</TouchableOpacity>
);
};
export default CustomStatusSuggestion;

View File

@@ -0,0 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {IntlShape} from 'react-intl';
import {View} from 'react-native';
import FormattedText from '@components/formatted_text';
import {CustomStatusDuration} from '@constants/custom_status';
import {t} from '@i18n';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import CustomStatusSuggestion from './custom_status_suggestion';
type Props = {
intl: IntlShape;
isExpirySupported: boolean;
onHandleCustomStatusSuggestionClick: (status: UserCustomStatus) => void;
recentCustomStatuses: UserCustomStatus[];
theme: Theme;
};
type DefaultUserCustomStatus = {
emoji: string;
message: string;
messageDefault: string;
durationDefault: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
separator: {
marginTop: 32,
},
title: {
fontSize: 17,
marginBottom: 12,
color: changeOpacity(theme.centerChannelColor, 0.5),
marginLeft: 16,
},
block: {
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1),
borderBottomWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.1),
borderTopWidth: 1,
},
};
});
const defaultCustomStatusSuggestions: DefaultUserCustomStatus[] = [
{emoji: 'calendar', message: t('custom_status.suggestions.in_a_meeting'), messageDefault: 'In a meeting', durationDefault: CustomStatusDuration.ONE_HOUR},
{emoji: 'hamburger', message: t('custom_status.suggestions.out_for_lunch'), messageDefault: 'Out for lunch', durationDefault: CustomStatusDuration.THIRTY_MINUTES},
{emoji: 'sneezing_face', message: t('custom_status.suggestions.out_sick'), messageDefault: 'Out sick', durationDefault: CustomStatusDuration.TODAY},
{emoji: 'house', message: t('custom_status.suggestions.working_from_home'), messageDefault: 'Working from home', durationDefault: CustomStatusDuration.TODAY},
{emoji: 'palm_tree', message: t('custom_status.suggestions.on_a_vacation'), messageDefault: 'On a vacation', durationDefault: CustomStatusDuration.THIS_WEEK},
];
const CustomStatusSuggestions = ({
intl,
isExpirySupported,
onHandleCustomStatusSuggestionClick,
recentCustomStatuses,
theme,
}: Props) => {
const style = getStyleSheet(theme);
const recentCustomStatusTexts = recentCustomStatuses.map((status: UserCustomStatus) => status.text);
const customStatusSuggestions = defaultCustomStatusSuggestions.
map((status) => ({
emoji: status.emoji,
text: intl.formatMessage({id: status.message, defaultMessage: status.messageDefault}),
duration: status.durationDefault,
})).
filter((status: UserCustomStatus) => !recentCustomStatusTexts.includes(status.text)).
map((status: UserCustomStatus, index: number, arr: UserCustomStatus[]) => (
<CustomStatusSuggestion
key={status.text}
handleSuggestionClick={onHandleCustomStatusSuggestionClick} // this.handleCustomStatusSuggestionClick
emoji={status.emoji}
text={status.text}
theme={theme}
separator={index !== arr.length - 1}
duration={status.duration}
isExpirySupported={isExpirySupported}
/>
));
if (customStatusSuggestions.length === 0) {
return null;
}
return (
<>
<View style={style.separator}/>
<View testID='custom_status.suggestions'>
<FormattedText
id={t('custom_status.suggestions.title')}
defaultMessage='SUGGESTIONS'
style={style.title}
/>
<View style={style.block}>{customStatusSuggestions}</View>
</View>
</>
);
};
export default CustomStatusSuggestions;

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import FormattedText from '@components/formatted_text';
import {t} from '@i18n';
import CustomStatusSuggestion from '@screens/custom_status/components/custom_status_suggestion';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
isExpirySupported: boolean;
onHandleClear: (status: UserCustomStatus) => void;
onHandleSuggestionClick: (status: UserCustomStatus) => void;
recentCustomStatuses: UserCustomStatus[];
theme: Theme;
}
const RecentCustomStatuses = ({isExpirySupported, onHandleClear, onHandleSuggestionClick, recentCustomStatuses, theme}: Props) => {
const style = getStyleSheet(theme);
if (recentCustomStatuses.length === 0) {
return null;
}
return (
<>
<View style={style.separator}/>
<View testID='custom_status.recents'>
<FormattedText
id={t('custom_status.suggestions.recent_title')}
defaultMessage='RECENT'
style={style.title}
/>
<View style={style.block}>
{recentCustomStatuses.map((status: UserCustomStatus, index: number) => (
<CustomStatusSuggestion
key={`${status.text}-${index.toString()}`}
handleSuggestionClick={onHandleSuggestionClick}
handleClear={onHandleClear}
emoji={status?.emoji}
text={status?.text}
theme={theme}
separator={index !== recentCustomStatuses.length - 1}
duration={status.duration}
expires_at={status.expires_at}
isExpirySupported={isExpirySupported}
/>
))}
</View>
</View >
</>
);
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
separator: {
marginTop: 32,
},
title: {
fontSize: 17,
marginBottom: 12,
color: changeOpacity(theme.centerChannelColor, 0.5),
marginLeft: 16,
},
block: {
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1),
borderBottomWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.1),
borderTopWidth: 1,
},
};
});
export default RecentCustomStatuses;

View File

@@ -0,0 +1,425 @@
// 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 moment, {Moment} from 'moment-timezone';
import React from 'react';
import {injectIntl, IntlShape} from 'react-intl';
import {BackHandler, DeviceEventEmitter, Keyboard, KeyboardAvoidingView, Platform, ScrollView, View} from 'react-native';
import {EventSubscription, Navigation, NavigationButtonPressedEvent, NavigationComponent, NavigationComponentProps} from 'react-native-navigation';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {of as of$} from 'rxjs';
import {switchMap, catchError} from 'rxjs/operators';
import {updateLocalCustomStatus} from '@actions/local/user';
import {removeRecentCustomStatus, updateCustomStatus, unsetCustomStatus} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import StatusBar from '@components/status_bar';
import TabletTitle from '@components/tablet_title';
import {CustomStatusDuration, Events, Screens} from '@constants';
import {SET_CUSTOM_STATUS_FAILURE} from '@constants/custom_status';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {withServerUrl} from '@context/server_url';
import {withTheme} from '@context/theme';
import {dismissModal, goToScreen, mergeNavigationOptions, showModal} from '@screens/navigation';
import {getCurrentMomentForTimezone, getRoundedTime, isCustomStatusExpirySupported, safeParseJSON} from '@utils/helpers';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {
getTimezone,
getUserCustomStatus,
isCustomStatusExpired as verifyExpiredStatus,
} from '@utils/user';
import ClearAfter from './components/clear_after';
import CustomStatusInput from './components/custom_status_input';
import CustomStatusSuggestions from './components/custom_status_suggestions';
import RecentCustomStatuses from './components/recent_custom_statuses';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
interface Props extends NavigationComponentProps {
customStatusExpirySupported: boolean;
currentUser: UserModel;
intl: IntlShape;
isModal?: boolean;
isTablet?: boolean;
recentCustomStatuses: UserCustomStatus[];
serverUrl: string;
theme: Theme;
}
type State = {
emoji?: string;
text?: string;
duration: CustomStatusDuration;
expires_at: Moment;
};
const {SERVER: {SYSTEM, USER}} = MM_TABLES;
const {DONT_CLEAR, THIRTY_MINUTES, ONE_HOUR, FOUR_HOURS, TODAY, THIS_WEEK, DATE_AND_TIME} = CustomStatusDuration;
const DEFAULT_DURATION: CustomStatusDuration = TODAY;
const BTN_UPDATE_STATUS = 'update-custom-status';
const edges: Edge[] = ['bottom', 'left', 'right'];
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.03),
},
contentContainerStyle: {
height: '99%',
},
scrollView: {
flex: 1,
paddingTop: 32,
},
separator: {
marginTop: 32,
},
block: {
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1),
borderBottomWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.1),
borderTopWidth: 1,
},
};
});
class CustomStatusModal extends NavigationComponent<Props, State> {
private navigationEventListener: EventSubscription | undefined;
private isCustomStatusExpired: boolean | undefined;
private backListener: EventSubscription | undefined;
constructor(props: Props) {
super(props);
const {intl, theme, componentId} = props;
mergeNavigationOptions(componentId, {
topBar: {
rightButtons: [
{
enabled: true,
id: BTN_UPDATE_STATUS,
showAsAction: 'always',
testID: 'custom_status.done.button',
text: intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'}),
color: theme.sidebarHeaderTextColor,
},
],
},
});
this.setUp();
}
setUp = () => {
const {currentUser} = this.props;
const userTimezone = getTimezone(currentUser.timezone);
const customStatus = this.getCustomStatus();
this.isCustomStatusExpired = verifyExpiredStatus(currentUser);
const currentTime = getCurrentMomentForTimezone(userTimezone ?? '');
let initialCustomExpiryTime: Moment = getRoundedTime(currentTime);
const isCurrentCustomStatusSet = !this.isCustomStatusExpired && (customStatus?.text || customStatus?.emoji);
if (isCurrentCustomStatusSet && customStatus?.duration === DATE_AND_TIME && customStatus?.expires_at) {
initialCustomExpiryTime = moment(customStatus?.expires_at);
}
this.state = {
duration: isCurrentCustomStatusSet ? customStatus?.duration ?? DONT_CLEAR : DEFAULT_DURATION,
emoji: isCurrentCustomStatusSet ? customStatus?.emoji : '',
expires_at: initialCustomExpiryTime,
text: isCurrentCustomStatusSet ? customStatus?.text : '',
};
};
getCustomStatus = () => {
const {currentUser} = this.props;
return getUserCustomStatus(currentUser);
};
componentDidMount() {
this.navigationEventListener = Navigation.events().bindComponent(this);
this.backListener = BackHandler.addEventListener('hardwareBackPress', this.onBackPress);
}
componentWillUnmount() {
this.navigationEventListener?.remove();
this.backListener?.remove();
}
navigationButtonPressed({buttonId}: NavigationButtonPressedEvent) {
switch (buttonId) {
case BTN_UPDATE_STATUS:
this.handleSetStatus();
break;
}
}
onBackPress = () => {
if (this.props.isTablet) {
DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, '');
} else {
dismissModal();
}
return true;
};
handleSetStatus = async () => {
const {customStatusExpirySupported, currentUser, serverUrl} = this.props;
const {emoji, text, duration} = this.state;
const customStatus = this.getCustomStatus();
const isStatusSet = emoji || text;
if (isStatusSet) {
let isStatusSame = customStatus?.emoji === emoji && customStatus?.text === text && customStatus?.duration === duration;
const expiresAt = this.calculateExpiryTime(duration);
if (isStatusSame && duration === DATE_AND_TIME) {
isStatusSame = customStatus?.expires_at === expiresAt;
}
if (!isStatusSame) {
const status: UserCustomStatus = {
emoji: emoji || 'speech_balloon',
text: text?.trim(),
duration: DONT_CLEAR,
};
if (customStatusExpirySupported) {
status.duration = duration;
status.expires_at = expiresAt;
}
const {error} = await updateCustomStatus(serverUrl, currentUser, status);
if (error) {
DeviceEventEmitter.emit(SET_CUSTOM_STATUS_FAILURE);
return;
}
updateLocalCustomStatus(serverUrl, currentUser, status);
this.setState({
duration: status.duration,
emoji: status.emoji,
expires_at: moment(status.expires_at),
text: status.text,
});
}
} else if (customStatus?.emoji) {
const unsetResponse = await unsetCustomStatus(serverUrl);
if (unsetResponse?.data) {
updateLocalCustomStatus(serverUrl, currentUser, undefined);
}
}
Keyboard.dismiss();
if (this.props.isTablet) {
DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, '');
} else {
dismissModal();
}
};
calculateExpiryTime = (duration: CustomStatusDuration): string => {
const {currentUser} = this.props;
const userTimezone = getTimezone(currentUser.timezone);
const currentTime = getCurrentMomentForTimezone(userTimezone);
const {expires_at} = this.state;
switch (duration) {
case THIRTY_MINUTES:
return currentTime.add(30, 'minutes').seconds(0).milliseconds(0).toISOString();
case ONE_HOUR:
return currentTime.add(1, 'hour').seconds(0).milliseconds(0).toISOString();
case FOUR_HOURS:
return currentTime.add(4, 'hours').seconds(0).milliseconds(0).toISOString();
case TODAY:
return currentTime.endOf('day').toISOString();
case THIS_WEEK:
return currentTime.endOf('week').toISOString();
case DATE_AND_TIME:
return expires_at.toISOString();
case DONT_CLEAR:
default:
return '';
}
};
handleTextChange = (text: string) => {
this.setState({text});
};
handleRecentCustomStatusClear = (status: UserCustomStatus) => removeRecentCustomStatus(this.props.serverUrl, status);
clearHandle = () => this.setState({emoji: '', text: '', duration: DEFAULT_DURATION});
handleCustomStatusSuggestionClick = (status: UserCustomStatus) => {
const {emoji, text, duration} = status;
this.setState({emoji, text, duration});
};
handleRecentCustomStatusSuggestionClick = (status: UserCustomStatus) => {
const {emoji, text, duration} = status;
this.setState({emoji, text, duration: duration || DONT_CLEAR});
if (duration === DATE_AND_TIME) {
this.openClearAfterModal();
}
};
openEmojiPicker = preventDoubleTap(() => {
const {theme, intl} = this.props;
CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor).then((source) => {
const screen = Screens.EMOJI_PICKER;
const title = intl.formatMessage({id: 'mobile.custom_status.choose_emoji', defaultMessage: 'Choose an emoji'});
const passProps = {closeButton: source, onEmojiPress: this.handleEmojiClick};
showModal(screen, title, passProps);
});
});
handleEmojiClick = (emoji: string) => {
this.setState({emoji});
};
handleClearAfterClick = (duration: CustomStatusDuration, expires_at: string) =>
this.setState({
duration,
expires_at: duration === DATE_AND_TIME && expires_at ? moment(expires_at) : this.state.expires_at,
});
openClearAfterModal = async () => {
const {intl, theme} = this.props;
const screen = Screens.CUSTOM_STATUS_CLEAR_AFTER;
const title = intl.formatMessage({id: 'mobile.custom_status.clear_after.title', defaultMessage: 'Clear Custom Status After'});
const passProps = {
handleClearAfterClick: this.handleClearAfterClick,
initialDuration: this.state.duration,
intl,
theme,
};
if (this.props.isTablet) {
showModal(screen, title, passProps);
} else {
goToScreen(screen, title, passProps);
}
};
render() {
const {duration, emoji, expires_at, text} = this.state;
const {customStatusExpirySupported, currentUser, intl, recentCustomStatuses, theme} = this.props;
const isStatusSet = Boolean(emoji || text);
const style = getStyleSheet(theme);
return (
<>
{this.props.isTablet &&
<TabletTitle
action={intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'})}
onPress={this.handleSetStatus}
testID='custom_status.done.button'
title={intl.formatMessage({id: 'mobile.routes.custom_status', defaultMessage: 'Set a Status'})}
/>
}
<SafeAreaView
edges={edges}
style={style.container}
testID='custom_status.screen'
>
<KeyboardAvoidingView
behavior='padding'
enabled={Platform.OS === 'ios'}
keyboardVerticalOffset={100}
contentContainerStyle={style.contentContainerStyle}
>
<ScrollView
bounces={false}
keyboardDismissMode='none'
keyboardShouldPersistTaps='always'
>
<StatusBar theme={theme}/>
<View style={style.scrollView}>
<View style={style.block}>
<CustomStatusInput
emoji={emoji}
isStatusSet={isStatusSet}
onChangeText={this.handleTextChange}
onClearHandle={this.clearHandle}
onOpenEmojiPicker={this.openEmojiPicker}
text={text}
theme={theme}
/>
{isStatusSet && customStatusExpirySupported && (
<ClearAfter
currentUser={currentUser}
duration={duration}
expiresAt={expires_at}
onOpenClearAfterModal={this.openClearAfterModal}
theme={theme}
/>
)}
</View>
{recentCustomStatuses.length > 0 && (
<RecentCustomStatuses
isExpirySupported={customStatusExpirySupported}
onHandleClear={this.handleRecentCustomStatusClear}
onHandleSuggestionClick={this.handleRecentCustomStatusSuggestionClick}
recentCustomStatuses={recentCustomStatuses}
theme={theme}
/>
)
}
<CustomStatusSuggestions
intl={intl}
isExpirySupported={customStatusExpirySupported}
onHandleCustomStatusSuggestionClick={this.handleCustomStatusSuggestionClick}
recentCustomStatuses={recentCustomStatuses}
theme={theme}
/>
</View>
<View style={style.separator}/>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</>
);
}
}
const augmentCSM = injectIntl(withTheme(withServerUrl(CustomStatusModal)));
const enhancedCSM = withObservables([], ({database}: WithDatabaseArgs) => {
const config = database.
get<SystemModel>(SYSTEM).
findAndObserve(SYSTEM_IDENTIFIERS.CONFIG);
return {
currentUser: database.
get<SystemModel>(SYSTEM).
findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).
pipe(
switchMap((id) => database.get<UserModel>(USER).findAndObserve(id.value)),
),
customStatusExpirySupported: config.pipe(
switchMap((cfg) => of$(isCustomStatusExpirySupported((cfg.value as ClientConfig).Version))),
),
recentCustomStatuses: database.
get<SystemModel>(SYSTEM).
findAndObserve(SYSTEM_IDENTIFIERS.RECENT_CUSTOM_STATUS).pipe(
switchMap(
(recentStatuses) => of$(
safeParseJSON(recentStatuses.value) as unknown as UserCustomStatus[],
),
),
catchError(() => of$([])),
),
};
});
export default withDatabase(enhancedCSM(augmentCSM));

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment, {Moment} from 'moment';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {View, TouchableOpacity} from 'react-native';
import CompassIcon from '@components/compass_icon';
import CustomStatusExpiry from '@components/custom_status/custom_status_expiry';
import CustomStatusText from '@components/custom_status/custom_status_text';
import {CustomStatusDuration, CST} from '@constants/custom_status';
import {useTheme} from '@context/theme';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import DateTimePicker from './date_time_selector';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
currentUser: UserModel;
duration: CustomStatusDuration;
expiryTime?: string;
handleItemClick: (duration: CustomStatusDuration, expiresAt: string) => void;
isSelected: boolean;
separator: boolean;
showDateTimePicker?: boolean;
showExpiryTime?: boolean;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
backgroundColor: theme.centerChannelBg,
display: 'flex',
flexDirection: 'row',
padding: 10,
},
textContainer: {
marginLeft: 5,
marginBottom: 2,
alignItems: 'center',
width: '70%',
flex: 1,
flexDirection: 'row',
position: 'relative',
},
rightPosition: {
position: 'absolute',
right: 14,
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1,
marginHorizontal: 16,
},
button: {
borderRadius: 1000,
color: theme.buttonBg,
},
customStatusExpiry: {
color: theme.linkColor,
},
};
});
const ClearAfterMenuItem = ({currentUser, duration, expiryTime = '', handleItemClick, isSelected, separator, showDateTimePicker = false, showExpiryTime = false}: Props) => {
const theme = useTheme();
const intl = useIntl();
const style = getStyleSheet(theme);
const expiryMenuItems: { [key in CustomStatusDuration]: string } = {
[CustomStatusDuration.DONT_CLEAR]: intl.formatMessage(CST[CustomStatusDuration.DONT_CLEAR]),
[CustomStatusDuration.THIRTY_MINUTES]: intl.formatMessage(CST[CustomStatusDuration.THIRTY_MINUTES]),
[CustomStatusDuration.ONE_HOUR]: intl.formatMessage(CST[CustomStatusDuration.ONE_HOUR]),
[CustomStatusDuration.FOUR_HOURS]: intl.formatMessage(CST[CustomStatusDuration.FOUR_HOURS]),
[CustomStatusDuration.TODAY]: intl.formatMessage(CST[CustomStatusDuration.TODAY]),
[CustomStatusDuration.THIS_WEEK]: intl.formatMessage(CST[CustomStatusDuration.THIS_WEEK]),
[CustomStatusDuration.DATE_AND_TIME]: intl.formatMessage({id: 'custom_status.expiry_dropdown.custom', defaultMessage: 'Custom'}),
};
const handleClick = preventDoubleTap(() => {
handleItemClick(duration, expiryTime);
});
const handleCustomExpiresAtChange = useCallback((expiresAt: Moment) => {
handleItemClick(duration, expiresAt.toISOString());
}, [handleItemClick, duration]);
return (
<View>
<TouchableOpacity
testID={`clear_after.menu_item.${duration}`}
onPress={handleClick}
>
<View style={style.container}>
<View style={style.textContainer}>
<CustomStatusText
text={expiryMenuItems[duration]}
theme={theme}
textStyle={{color: theme.centerChannelColor}}
/>
{isSelected && (
<View style={style.rightPosition}>
<CompassIcon
name={'check'}
size={24}
style={style.button}
/>
</View>
)}
{showExpiryTime && expiryTime !== '' && (
<View style={style.rightPosition}>
<CustomStatusExpiry
currentUser={currentUser}
theme={theme}
time={moment(expiryTime).toDate()}
textStyles={style.customStatusExpiry}
showTimeCompulsory={true}
showToday={true}
/>
</View>
)}
</View>
</View>
{separator && <View style={style.divider}/>}
</TouchableOpacity>
{showDateTimePicker && (
<DateTimePicker
currentUser={currentUser}
theme={theme}
handleChange={handleCustomExpiresAtChange}
/>
)}
</View>
);
};
export default ClearAfterMenuItem;

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import DateTimePicker from '@react-native-community/datetimepicker';
import moment, {Moment} from 'moment-timezone';
import React, {useState} from 'react';
import {View, Button, Platform} from 'react-native';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES} from '@constants/custom_status';
import {MM_TABLES} from '@constants/database';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {getCurrentMomentForTimezone, getRoundedTime, getUtcOffsetForTimeZone} from '@utils/helpers';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {getTimezone} from '@utils/user';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PreferenceModel from '@typings/database/models/servers/preference';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
currentUser: UserModel;
isMilitaryTime: boolean;
theme: Theme;
handleChange: (currentDate: Moment) => void;
}
type AndroidMode = 'date' | 'time';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
paddingTop: 10,
backgroundColor: theme.centerChannelBg,
},
buttonContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-evenly',
marginBottom: 10,
},
};
});
const DateTimeSelector = ({currentUser, handleChange, isMilitaryTime, theme}: Props) => {
const styles = getStyleSheet(theme);
const timezone = getTimezone(currentUser.timezone);
const currentTime = getCurrentMomentForTimezone(timezone);
const timezoneOffSetInMinutes = timezone ? getUtcOffsetForTimeZone(timezone) : undefined;
const minimumDate = getRoundedTime(currentTime);
const [date, setDate] = useState<Moment>(minimumDate);
const [mode, setMode] = useState<AndroidMode>('date');
const [show, setShow] = useState<boolean>(false);
const onChange = (_: React.ChangeEvent<HTMLInputElement>, selectedDate: Date) => {
const currentDate = selectedDate || date;
setShow(Platform.OS === 'ios');
if (moment(currentDate).isAfter(minimumDate)) {
setDate(moment(currentDate));
handleChange(moment(currentDate));
}
};
const showMode = (currentMode: AndroidMode) => {
setShow(true);
setMode(currentMode);
};
const showDatepicker = () => {
showMode('date');
handleChange(moment(date));
};
const showTimepicker = () => {
showMode('time');
handleChange(moment(date));
};
return (
<View style={styles.container}>
<View style={styles.buttonContainer}>
<Button
testID={'clear_after.menu_item.date_and_time.button.date'}
onPress={showDatepicker}
title='Select Date'
color={theme.buttonBg}
/>
<Button
testID={'clear_after.menu_item.date_and_time.button.time'}
onPress={showTimepicker}
title='Select Time'
color={theme.buttonBg}
/>
</View>
{show && (
<DateTimePicker
testID='clear_after.date_time_picker'
value={date.toDate()}
mode={mode}
is24Hour={isMilitaryTime}
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={onChange}
textColor={theme.centerChannelColor}
minimumDate={minimumDate.toDate()}
minuteInterval={CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES}
timeZoneOffsetInMinutes={timezoneOffSetInMinutes}
/>
)}
</View>
);
};
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
isMilitaryTime: database.get<PreferenceModel>(MM_TABLES.SERVER.PREFERENCE).
query(
Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS),
).observe().pipe(
switchMap(
(preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)),
),
),
}));
export default withDatabase(enhanced(DateTimeSelector));

View File

@@ -0,0 +1,219 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React from 'react';
import {injectIntl, IntlShape} from 'react-intl';
import {BackHandler, NativeEventSubscription, SafeAreaView, StatusBar, View} from 'react-native';
import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
import {
Navigation,
NavigationButtonPressedEvent,
NavigationComponent,
NavigationComponentProps,
Options,
} from 'react-native-navigation';
import {switchMap} from 'rxjs/operators';
import {CustomStatusDuration} from '@constants/custom_status';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {dismissModal, mergeNavigationOptions, popTopScreen} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import ClearAfterMenuItem from './components/clear_after_menu_item';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
interface Props extends NavigationComponentProps {
currentUser: UserModel;
handleClearAfterClick: (duration: CustomStatusDuration, expiresAt: string) => void;
initialDuration: CustomStatusDuration;
intl: IntlShape;
isModal?: boolean;
theme: Theme;
}
type State = {
duration: CustomStatusDuration;
expiresAt: string;
showExpiryTime: boolean;
}
const {SERVER: {SYSTEM, USER}} = MM_TABLES;
const CLEAR_AFTER = 'update-custom-status-clear-after';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.03),
},
scrollView: {
flex: 1,
paddingTop: 32,
paddingBottom: 32,
},
block: {
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1),
borderBottomWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.1),
borderTopWidth: 1,
},
};
});
class ClearAfterModal extends NavigationComponent<Props, State> {
private backListener: NativeEventSubscription | undefined;
constructor(props: Props) {
super(props);
const options: Options = {
topBar: {
rightButtons: [{
color: props.theme.sidebarHeaderTextColor,
enabled: true,
id: CLEAR_AFTER,
showAsAction: 'always',
testID: 'clear_after.done.button',
text: props.intl.formatMessage({
id: 'mobile.custom_status.modal_confirm',
defaultMessage: 'Done',
}),
}],
},
};
mergeNavigationOptions(props.componentId, options);
this.state = {
duration: props.initialDuration,
expiresAt: '',
showExpiryTime: false,
};
}
componentDidMount() {
Navigation.events().bindComponent(this);
this.backListener = BackHandler.addEventListener('hardwareBackPress', this.onBackPress);
}
componentWillUnmount() {
this.backListener?.remove();
}
navigationButtonPressed({buttonId}: NavigationButtonPressedEvent) {
switch (buttonId) {
case CLEAR_AFTER:
this.onDone();
break;
}
}
onBackPress = () => {
if (this.props.isModal) {
dismissModal();
} else {
popTopScreen();
}
return true;
}
onDone = () => {
const {handleClearAfterClick, isModal} = this.props;
handleClearAfterClick(this.state.duration, this.state.expiresAt);
if (isModal) {
dismissModal();
return;
}
popTopScreen();
};
handleItemClick = (duration: CustomStatusDuration, expiresAt: string) =>
this.setState({
duration,
expiresAt,
showExpiryTime: duration === CustomStatusDuration.DATE_AND_TIME && expiresAt !== '',
});
renderClearAfterMenu = () => {
const {currentUser, theme} = this.props;
const style = getStyleSheet(theme);
const {duration} = this.state;
const clearAfterMenu = Object.values(CustomStatusDuration).map(
(item, index, arr) => {
if (index === arr.length - 1) {
return null;
}
return (
<ClearAfterMenuItem
currentUser={currentUser}
duration={item}
handleItemClick={this.handleItemClick}
isSelected={duration === item}
key={item}
separator={index !== arr.length - 2}
/>
);
},
);
if (clearAfterMenu.length === 0) {
return null;
}
return (
<View testID='clear_after.menu'>
<View style={style.block}>{clearAfterMenu}</View>
</View>
);
};
render() {
const {currentUser, theme} = this.props;
const style = getStyleSheet(theme);
const {duration, expiresAt, showExpiryTime} = this.state;
return (
<SafeAreaView
style={style.container}
testID='clear_after.screen'
>
<StatusBar/>
<KeyboardAwareScrollView bounces={false}>
<View style={style.scrollView}>
{this.renderClearAfterMenu()}
</View>
<View style={style.block}>
<ClearAfterMenuItem
currentUser={currentUser}
duration={CustomStatusDuration.DATE_AND_TIME}
expiryTime={expiresAt}
handleItemClick={this.handleItemClick}
isSelected={duration === CustomStatusDuration.DATE_AND_TIME && expiresAt === ''}
separator={false}
showDateTimePicker={duration === CustomStatusDuration.DATE_AND_TIME}
showExpiryTime={showExpiryTime}
/>
</View>
</KeyboardAwareScrollView>
</SafeAreaView>
);
}
}
const enhancedCAM = withObservables([], ({database}: WithDatabaseArgs) => ({
currentUser: database.get<SystemModel>(SYSTEM).
findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).
pipe(
switchMap((id) => database.get<UserModel>(USER).findAndObserve(id.value)),
),
}));
export default withDatabase(enhancedCAM(injectIntl(ClearAfterModal)));

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import {Keyboard} from 'react-native';
import {Navigation} from 'react-native-navigation';
import EmojiPicker from '@app/components/emoji_picker';
import {dismissModal, setButtons} from '@screens/navigation';
type Props = {
componentId: string;
onEmojiPress: (emoji: string) => void;
closeButton: never;
};
const EmojiPickerScreen = ({closeButton, componentId, onEmojiPress}: Props) => {
useEffect(() => {
setButtons(componentId, {
leftButtons: [
{
icon: closeButton,
id: 'close-add-reaction',
testID: 'close.add_reaction.button',
},
] as unknown as never[],
rightButtons: [],
});
const unsubscribe = Navigation.events().registerComponentListener({
navigationButtonPressed: ({buttonId}: { buttonId: string }) => {
if (buttonId === 'close-add-reaction') {
close();
}
},
}, componentId);
return () => {
unsubscribe.remove();
};
}, []);
const close = useCallback(() => {
Keyboard.dismiss();
dismissModal();
}, []);
const handleEmojiPress = useCallback((emoji: string) => {
onEmojiPress(emoji);
close();
}, []);
return <EmojiPicker onEmojiPress={handleEmojiPress}/>;
};
export default EmojiPickerScreen;

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React from 'react';
import {View} from 'react-native';
import ClearButton from '@components/custom_status/clear_button';
import CustomStatusExpiry from '@components/custom_status/custom_status_expiry';
import FormattedText from '@components/formatted_text';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import CustomStatusText from './custom_status_text';
import type UserModel from '@typings/database/models/servers/user';
type CustomLabelProps = {
customStatus: UserCustomStatus;
isCustomStatusExpirySupported: boolean;
isStatusSet: boolean;
showRetryMessage: boolean;
theme: Theme;
currentUser: UserModel;
onClearCustomStatus: () => void;
};
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
clearButton: {
position: 'absolute',
top: 4,
right: 14,
},
customStatusTextContainer: {
width: '70%',
},
customStatusExpiryText: {
paddingTop: 3,
fontSize: 15,
color: changeOpacity(theme.centerChannelColor, 0.35),
},
retryMessage: {
color: theme.errorTextColor,
paddingBottom: 25,
},
};
});
const CustomLabel = ({currentUser, customStatus, isCustomStatusExpirySupported, isStatusSet, onClearCustomStatus, showRetryMessage, theme}: CustomLabelProps) => {
const style = getStyleSheet(theme);
return (
<>
<View style={style.customStatusTextContainer}>
<CustomStatusText
theme={theme}
isStatusSet={Boolean(isStatusSet)}
customStatus={customStatus}
/>
{Boolean(isStatusSet && isCustomStatusExpirySupported && customStatus?.duration) && (
<CustomStatusExpiry
currentUser={currentUser}
time={moment(customStatus?.expires_at)}
theme={theme}
textStyles={style.customStatusExpiryText}
withinBrackets={true}
showPrefix={true}
testID={'custom_status.expiry'}
/>
)}
</View>
{showRetryMessage && (
<FormattedText
id={'custom_status.failure_message'}
defaultMessage='Failed to update status. Try again'
style={style.retryMessage}
/>
)}
{isStatusSet && (
<View style={style.clearButton}>
<ClearButton
handlePress={onClearCustomStatus}
theme={theme}
testID='settings.sidebar.custom_status.action.clear'
/>
</View>
)}
</>
);
};
export default CustomLabel;

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import Emoji from '@components/emoji';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type CustomStatusEmojiProps = {
emoji?: string;
isStatusSet: boolean;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
customStatusIcon: {
color: changeOpacity(theme.centerChannelColor, 0.64),
},
};
});
const CustomStatusEmoji = ({emoji, isStatusSet, theme}: CustomStatusEmojiProps) => {
const styles = getStyleSheet(theme);
return (
<View
testID={`custom_status.emoji.${isStatusSet ? emoji : 'default'}`}
>
{isStatusSet && emoji ? (
<Emoji
emojiName={emoji}
size={20}
/>
) : (
<CompassIcon
name='emoticon-happy-outline'
size={24}
style={styles.customStatusIcon}
/>
)}
</View>
);
};
export default CustomStatusEmoji;

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import CustomText from '@components/custom_status/custom_status_text';
import FormattedText from '@components/formatted_text';
import {makeStyleSheetFromTheme} from '@utils/theme';
type CustomStatusTextProps = {
customStatus?: UserCustomStatus;
isStatusSet: boolean;
theme: Theme;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
text: {
color: theme.centerChannelColor,
},
}));
const CustomStatusText = ({isStatusSet, customStatus, theme}: CustomStatusTextProps) => {
let text: React.ReactNode | string;
text = (
<FormattedText
id='mobile.routes.custom_status'
defaultMessage='Set a Status'
/>
);
if (isStatusSet && customStatus?.text) {
text = customStatus.text;
}
const styles = getStyleSheet(theme);
return (
<CustomText
text={text}
theme={theme}
textStyle={styles.text}
/>
);
};
export default CustomStatusText;

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter} from 'react-native';
import {updateLocalCustomStatus} from '@actions/local/user';
import {unsetCustomStatus} from '@actions/remote/user';
import DrawerItem from '@components/drawer_item';
import {Events, Screens} from '@constants';
import {SET_CUSTOM_STATUS_FAILURE} from '@constants/custom_status';
import {useServerUrl} from '@context/server_url';
import {useTheme} from '@context/theme';
import {showModal} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {getUserCustomStatus, isCustomStatusExpired as checkCustomStatusIsExpired} from '@utils/user';
import CustomLabel from './custom_label';
import CustomStatusEmoji from './custom_status_emoji';
import type UserModel from '@typings/database/models/servers/user';
type CustomStatusProps = {
isCustomStatusExpirySupported: boolean;
isTablet: boolean;
currentUser: UserModel;
}
const CustomStatus = ({isCustomStatusExpirySupported, isTablet, currentUser}: CustomStatusProps) => {
const theme = useTheme();
const intl = useIntl();
const serverUrl = useServerUrl();
const [showRetryMessage, setShowRetryMessage] = useState<boolean>(false);
const customStatus = getUserCustomStatus(currentUser);
const isCustomStatusExpired = checkCustomStatusIsExpired(currentUser);
const isStatusSet = !isCustomStatusExpired && (customStatus?.text || customStatus?.emoji);
useEffect(() => {
const onSetCustomStatusError = () => {
setShowRetryMessage(true);
};
const listener = DeviceEventEmitter.addListener(SET_CUSTOM_STATUS_FAILURE, onSetCustomStatusError);
return () => listener.remove();
}, []);
const clearCustomStatus = useCallback(preventDoubleTap(async () => {
setShowRetryMessage(false);
const {error} = await unsetCustomStatus(serverUrl);
if (error) {
setShowRetryMessage(true);
return;
}
updateLocalCustomStatus(serverUrl, currentUser, undefined);
}), []);
const goToCustomStatusScreen = useCallback(preventDoubleTap(() => {
if (isTablet) {
DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, Screens.CUSTOM_STATUS);
} else {
showModal(Screens.CUSTOM_STATUS, intl.formatMessage({id: 'mobile.routes.custom_status', defaultMessage: 'Set a Status'}));
}
setShowRetryMessage(false);
}), [isTablet]);
return (
<DrawerItem
testID='settings.sidebar.custom_status.action'
labelComponent={
<CustomLabel
currentUser={currentUser}
theme={theme}
customStatus={customStatus!}
isCustomStatusExpirySupported={isCustomStatusExpirySupported}
isStatusSet={Boolean(isStatusSet)}
onClearCustomStatus={clearCustomStatus}
showRetryMessage={showRetryMessage}
/>
}
leftComponent={
<CustomStatusEmoji
emoji={customStatus?.emoji}
isStatusSet={Boolean(isStatusSet)}
theme={theme}
/>}
separator={false}
onPress={goToCustomStatusScreen}
theme={theme}
/>
);
};
export default CustomStatus;

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useWindowDimensions, View} from 'react-native';
import {Shadow} from 'react-native-neomorph-shadows';
import {View as ViewConstants} from '@constants';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import CustomStatus from './custom_status';
import Logout from './logout';
import SavedMessages from './saved_messages';
import Settings from './settings';
import UserPresence from './user_presence';
import YourProfile from './your_profile';
import type UserModel from '@typings/database/models/servers/user';
type AccountScreenProps = {
user: UserModel;
enableCustomUserStatuses: boolean;
isCustomStatusExpirySupported: boolean;
isTablet: boolean;
theme: Theme;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
backgroundColor: theme.centerChannelBg,
flex: 1,
borderTopRightRadius: 12,
borderTopLeftRadius: 12,
paddingTop: 12,
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1,
marginHorizontal: 15,
},
menuLabel: {
color: theme.centerChannelColor,
fontSize: 16,
lineHeight: 24,
fontFamily: 'OpenSans',
},
};
});
const AccountOptions = ({user, enableCustomUserStatuses, isCustomStatusExpirySupported, isTablet, theme}: AccountScreenProps) => {
const styles = getStyleSheet(theme);
const dimensions = useWindowDimensions();
const width = dimensions.width - (isTablet ? ViewConstants.TABLET.SIDEBAR_WIDTH : 0);
return (
<Shadow
style={{
height: dimensions.height,
width,
shadowColor: 'rgba(61, 60, 64, 0.08)',
shadowOffset: {width: 0, height: -2},
shadowOpacity: 1,
shadowRadius: 6,
}}
>
<View style={styles.container}>
<UserPresence
currentUser={user}
style={styles.menuLabel}
theme={theme}
/>
{enableCustomUserStatuses &&
<CustomStatus
isCustomStatusExpirySupported={isCustomStatusExpirySupported}
isTablet={isTablet}
currentUser={user}
/>}
<View style={styles.divider}/>
<YourProfile
isTablet={isTablet}
style={styles.menuLabel}
theme={theme}
/>
<SavedMessages
isTablet={isTablet}
style={styles.menuLabel}
theme={theme}
/>
<Settings
isTablet={isTablet}
style={styles.menuLabel}
theme={theme}
/>
<View style={styles.divider}/>
<Logout
style={styles.menuLabel}
theme={theme}
/>
</View>
</Shadow>
);
};
export default AccountOptions;

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {TextStyle, View} from 'react-native';
import {logout} from '@actions/remote/session';
import DrawerItem from '@components/drawer_item';
import FormattedText from '@components/formatted_text';
import {useServerUrl} from '@context/server_url';
import DatabaseManager from '@database/manager';
import {queryServer} from '@queries/app/servers';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
style: TextStyle;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
label: {
color: theme.dndIndicator,
marginTop: 5,
},
logOutFrom: {
color: changeOpacity(theme.centerChannelColor, 0.64),
fontSize: 12,
height: 30,
lineHeight: 16,
fontFamily: 'OpenSans',
},
}));
const Settings = ({style, theme}: Props) => {
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const [serverName, setServerName] = useState(serverUrl);
const onLogout = useCallback(preventDoubleTap(() => {
logout(serverUrl);
}), []);
useEffect(() => {
const appDatabase = DatabaseManager.appDatabase?.database;
if (appDatabase) {
queryServer(appDatabase, serverUrl).then((server) => {
if (server) {
setServerName(server.displayName);
}
});
}
}, [serverUrl]);
return (
<DrawerItem
testID='account.logout.action'
labelComponent={(
<View>
<FormattedText
id='account.logout'
defaultMessage='Log out'
style={[style, styles.label]}
/>
<FormattedText
id={'account.logout_from'}
defaultMessage={'Log out of {serverName}'}
values={{serverName}}
style={styles.logOutFrom}
/>
</View>
)}
iconName='exit-to-app'
isDestructor={true}
onPress={onLogout}
separator={false}
theme={theme}
/>
);
};
export default Settings;

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {TextStyle} from 'react-native';
import DrawerItem from '@components/drawer_item';
import FormattedText from '@components/formatted_text';
import {preventDoubleTap} from '@utils/tap';
type Props = {
isTablet: boolean;
style: TextStyle;
theme: Theme;
}
const SavedMessages = ({isTablet, style, theme}: Props) => {
const openSavedMessages = useCallback(preventDoubleTap(() => {
// TODO: Open Saved messages screen in either a screen or in line for tablets
}), [isTablet]);
return (
<DrawerItem
testID='account.saved_messages.action'
labelComponent={
<FormattedText
id='account.saved_messages'
defaultMessage='Saved Messages'
style={style}
/>
}
iconName='bookmark-outline'
onPress={openSavedMessages}
separator={false}
theme={theme}
/>
);
};
export default SavedMessages;

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {TextStyle} from 'react-native';
import DrawerItem from '@components/drawer_item';
import FormattedText from '@components/formatted_text';
import {preventDoubleTap} from '@utils/tap';
type Props = {
isTablet: boolean;
style: TextStyle;
theme: Theme;
}
const Settings = ({isTablet, style, theme}: Props) => {
const openSettings = useCallback(preventDoubleTap(() => {
// TODO: Open Saved messages screen in either a screen or in line for tablets
}), [isTablet]);
return (
<DrawerItem
testID='account.settings.action'
labelComponent={
<FormattedText
id='account.settings'
defaultMessage='Settings'
style={style}
/>
}
iconName='settings-outline'
onPress={openSettings}
separator={false}
theme={theme}
/>
);
};
export default Settings;

View File

@@ -0,0 +1,140 @@
// 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 {DeviceEventEmitter, TextStyle} from 'react-native';
import {setStatus} from '@actions/remote/user';
import {useServerUrl} from '@app/context/server_url';
import DrawerItem from '@components/drawer_item';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import StatusLabel from '@components/status_label';
import UserStatusIndicator from '@components/user_status';
import {Navigation} from '@constants';
import General from '@constants/general';
import {bottomSheet, dismissModal} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity} from '@utils/theme';
import {confirmOutOfOfficeDisabled} from '@utils/user';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
currentUser: UserModel;
style: TextStyle;
theme: Theme;
};
const {OUT_OF_OFFICE, OFFLINE, AWAY, ONLINE, DND} = General;
const UserStatus = ({currentUser, style, theme}: Props) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const handleSetStatus = useCallback(preventDoubleTap(() => {
const renderContent = () => {
return (
<>
<SlideUpPanelItem
icon='check-circle'
imageStyles={{color: theme.onlineIndicator}}
onPress={() => setUserStatus(ONLINE)}
testID='user_status.bottom_sheet.online'
text={intl.formatMessage({
id: 'mobile.set_status.online',
defaultMessage: 'Online',
})}
textStyles={style}
/>
<SlideUpPanelItem
icon='clock'
imageStyles={{color: theme.awayIndicator}}
onPress={() => setUserStatus(AWAY)}
testID='user_status.bottom_sheet.away'
text={intl.formatMessage({
id: 'mobile.set_status.away',
defaultMessage: 'Away',
})}
textStyles={style}
/>
<SlideUpPanelItem
icon='minus-circle'
imageStyles={{color: theme.dndIndicator}}
onPress={() => setUserStatus(DND)}
testID='user_status.bottom_sheet.dnd'
text={intl.formatMessage({
id: 'mobile.set_status.dnd',
defaultMessage: 'Do Not Disturb',
})}
textStyles={style}
/>
<SlideUpPanelItem
icon='circle-outline'
imageStyles={{color: changeOpacity('#B8B8B8', 0.64)}}
onPress={() => setUserStatus(OFFLINE)}
testID='user_status.bottom_sheet.offline'
text={intl.formatMessage({
id: 'mobile.set_status.offline',
defaultMessage: 'Offline',
})}
textStyles={style}
/>
</>
);
};
bottomSheet({
closeButtonId: 'close-set-user-status',
renderContent,
snapPoints: [(5 * ITEM_HEIGHT) + 10, 10],
title: intl.formatMessage({id: 'account.user_status.title', defaultMessage: 'User Presence'}),
theme,
});
}), [theme]);
const updateStatus = useCallback((status: string) => {
const userStatus = {
user_id: currentUser.id,
status,
manual: true,
last_activity_at: Date.now(),
};
setStatus(serverUrl, userStatus);
}, []);
const setUserStatus = useCallback((status: string) => {
if (currentUser.status === OUT_OF_OFFICE) {
dismissModal();
return confirmOutOfOfficeDisabled(intl, status, updateStatus);
}
updateStatus(status);
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
return null;
}, []);
return (
<DrawerItem
testID='account.status.action'
labelComponent={
<StatusLabel
labelStyle={style}
status={currentUser.status}
/>
}
leftComponent={
<UserStatusIndicator
size={24}
status={currentUser.status}
/>
}
separator={false}
onPress={handleSetStatus}
theme={theme}
/>
);
};
export default UserStatus;

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {TextStyle} from 'react-native';
import DrawerItem from '@components/drawer_item';
import FormattedText from '@components/formatted_text';
import {preventDoubleTap} from '@utils/tap';
type Props = {
isTablet: boolean;
style: TextStyle;
theme: Theme;
}
const YourProfile = ({isTablet, style, theme}: Props) => {
const openProfile = useCallback(preventDoubleTap(() => {
// TODO: Open Profile screen in either a screen or in line for tablets
}), [isTablet]);
return (
<DrawerItem
testID='account.your_profile.action'
labelComponent={
<FormattedText
id='account.your_profile'
defaultMessage='Your Profile'
style={style}
/>
}
iconName='account-outline'
onPress={openProfile}
separator={false}
theme={theme}
/>
);
};
export default YourProfile;

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 {Events, Screens} from '@constants';
import CustomStatus from '@screens/custom_status';
type SelectedView = {
id: string;
Component: any;
}
const TabletView: Record<string, React.ReactNode> = {
[Screens.CUSTOM_STATUS]: CustomStatus,
};
const AccountTabletView = () => {
const [selected, setSelected] = useState<SelectedView | undefined>();
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.ACCOUNT_SELECT_TABLET_VIEW, (id: string) => {
const component = TabletView[id];
let tabletView: SelectedView | undefined;
if (component) {
tabletView = {
Component: component,
id,
};
}
setSelected(tabletView);
});
return () => listener.remove();
}, []);
if (!selected) {
return null;
}
return React.createElement(selected.Component, {componentId: selected.id, isTablet: true});
};
export default AccountTabletView;

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text, View} from 'react-native';
import ProfilePicture from '@components/profile_picture';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
user: UserModel;
showFullName: boolean;
theme: Theme;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
backgroundColor: theme.sidebarBg,
paddingBottom: 20,
top: 0,
paddingTop: 22,
paddingLeft: 20,
},
statusStyle: {
right: 10,
bottom: 10,
borderColor: theme.sidebarBg,
backgroundColor: theme.sidebarBg,
},
textFullName: {
fontSize: 28,
lineHeight: 36,
color: theme.sidebarText,
fontFamily: 'Metropolis-SemiBold',
marginTop: 16,
},
textUserName: {
fontSize: 16,
lineHeight: 24,
color: theme.sidebarText,
fontFamily: 'OpenSans',
marginTop: 4,
},
};
});
const AccountUserInfo = ({user, showFullName, theme}: Props) => {
const styles = getStyleSheet(theme);
const nickName = user.nickname ? ` (${user.nickname})` : '';
const title = `${user.firstName} ${user.lastName}${nickName}`;
const userName = `@${user.username}`;
return (
<View style={styles.container}>
<ProfilePicture
size={120}
iconSize={28}
showStatus={true}
author={user}
testID={'account.profile_picture'}
statusStyle={styles.statusStyle}
statusSize={24}
/>
{showFullName && <Text style={styles.textFullName}>{title}</Text>}
<Text style={showFullName ? styles.textUserName : styles.textFullName}>{`${userName}`}</Text>
</View>
);
};
export default AccountUserInfo;

View File

@@ -1,17 +1,87 @@
// 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 {useRoute} from '@react-navigation/native';
import React, {useCallback, useState} from 'react';
import {Text} from 'react-native';
import {ScrollView, StatusBar, View} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {SafeAreaView} from 'react-native-safe-area-context';
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import tinycolor from 'tinycolor2';
const AccountScreen = () => {
import {Device, View as ViewConstants} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useTheme} from '@context/theme';
import {useSplitView} from '@hooks/device';
import {isCustomStatusExpirySupported, isMinimumServerVersion} from '@utils/helpers';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import AccountOptions from './components/options';
import AccountTabletView from './components/tablet_view';
import AccountUserInfo from './components/user_info';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
type AccountScreenProps = {
currentUser: UserModel;
customStatusExpirySupported: boolean;
enableCustomUserStatuses: boolean;
showFullName: boolean;
};
const {SERVER: {SYSTEM, USER}} = MM_TABLES;
const edges: Edge[] = ['bottom', 'left', 'right'];
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
backgroundColor: theme.sidebarBg,
},
flex: {
flex: 1,
},
flexRow: {
flex: 1,
flexDirection: 'row',
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1,
marginHorizontal: 15,
},
tabletContainer: {
backgroundColor: theme.centerChannelBg,
flex: 1,
},
tabletDivider: {
borderLeftWidth: 1,
borderLeftColor: changeOpacity(theme.centerChannelColor, 0.16),
},
};
});
const AccountScreen = ({currentUser, enableCustomUserStatuses, customStatusExpirySupported, showFullName}: AccountScreenProps) => {
const theme = useTheme();
const [start, setStart] = useState(false);
const route = useRoute();
const isSplitView = useSplitView();
const insets = useSafeAreaInsets();
const isTablet = Device.IS_TABLET && !isSplitView;
const barStyle = tinycolor(theme.sidebarBg).isDark() ? 'light-content' : 'dark-content';
let tabletSidebarStyle;
if (isTablet) {
const {TABLET} = ViewConstants;
tabletSidebarStyle = {maxWidth: TABLET.SIDEBAR_WIDTH};
}
const params = route.params! as {direction: string};
const toLeft = params.direction === 'left';
const [start, setStart] = useState(false);
const onLayout = useCallback(() => {
setStart(true);
@@ -31,18 +101,83 @@ const AccountScreen = () => {
};
}, [start]);
const styles = getStyleSheet(theme);
return (
<SafeAreaView
style={{flex: 1, backgroundColor: 'green'}}
edges={edges}
style={styles.container}
>
<View style={[{height: insets.top, flexDirection: 'row'}]}>
<View style={[styles.container, tabletSidebarStyle]}/>
{isTablet && <View style={styles.tabletContainer}/>}
</View>
<StatusBar
barStyle={barStyle}
backgroundColor='rgba(20, 33, 62, 0.42)'
/>
<Animated.View
onLayout={onLayout}
style={[{flex: 1, justifyContent: 'center', alignItems: 'center'}, animated]}
style={[styles.flexRow, animated]}
>
<Text style={{fontSize: 20, color: '#fff'}}>{'Account Screen'}</Text>
<ScrollView
contentContainerStyle={styles.flex}
alwaysBounceVertical={false}
style={tabletSidebarStyle}
>
<AccountUserInfo
user={currentUser}
showFullName={showFullName}
theme={theme}
/>
<AccountOptions
enableCustomUserStatuses={enableCustomUserStatuses}
isCustomStatusExpirySupported={customStatusExpirySupported}
isTablet={isTablet}
user={currentUser}
theme={theme}
/>
</ScrollView>
{isTablet &&
<View style={[styles.tabletContainer, styles.tabletDivider]}>
<AccountTabletView/>
</View>
}
</Animated.View>
</SafeAreaView>
);
};
export default AccountScreen;
const withUserConfig = withObservables([], ({database}: WithDatabaseArgs) => {
const config = database.
get<SystemModel>(SYSTEM).
findAndObserve(SYSTEM_IDENTIFIERS.CONFIG);
const showFullName = config.pipe((switchMap((cfg) => of$((cfg.value as ClientConfig).ShowFullName === 'true'))));
const enableCustomUserStatuses = config.pipe((switchMap((cfg) => {
const ClientConfig = cfg.value as ClientConfig;
return of$(ClientConfig.EnableCustomUserStatuses === 'true' && isMinimumServerVersion(ClientConfig.Version, 5, 36));
})));
const version = config.pipe(
switchMap((cfg) => of$((cfg.value as ClientConfig).Version)),
);
const customStatusExpirySupported = config.pipe(
switchMap((cfg) => of$(isCustomStatusExpirySupported((cfg.value as ClientConfig).Version))),
);
return {
currentUser: database.
get(SYSTEM).
findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).
pipe(
switchMap((id: SystemModel) =>
database.get(USER).findAndObserve(id.value),
),
),
enableCustomUserStatuses,
customStatusExpirySupported,
showFullName,
version,
};
});
export default withDatabase(withUserConfig(AccountScreen));

View File

@@ -76,7 +76,7 @@ function TabBar({state, descriptors, navigation, theme}: BottomTabBarProps & {th
return {
transform: [{translateX}],
};
}, [state.index]);
}, [state.index, tabWidth]);
const animatedStyle = useAnimatedStyle(() => {
if (visible === undefined) {

View File

@@ -54,18 +54,24 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.ABOUT:
screen = withServerDatabase(require('@screens/about').default);
break;
// case 'AddReaction':
// screen = require('@screens/add_reaction').default;
// break;
// case 'AdvancedSettings':
// screen = require('@screens/settings/advanced_settings').default;
// break;
case Screens.BOTTOM_SHEET:
screen = require('@screens/bottom_sheet').default;
screen = withServerDatabase(require('@screens/bottom_sheet').default);
break;
case Screens.CHANNEL:
screen = withServerDatabase(require('@screens/channel').default);
break;
case Screens.CUSTOM_STATUS:
screen = withServerDatabase(require('@screens/custom_status').default);
break;
case Screens.CUSTOM_STATUS_CLEAR_AFTER:
screen = withServerDatabase(require('@screens/custom_status_clear_after').default);
break;
case Screens.EMOJI_PICKER:
screen = withServerDatabase(require('@screens/emoji_picker').default);
break;
// case 'ChannelAddMembers':
// screen = require('@screens/channel_add_members').default;
// break;

View File

@@ -1,15 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text, View} from 'react-native';
const Modal = () => {
return (
<View style={{backgroundColor: '#ffffff', flex: 1}}>
<Text>{'Modal screen'}</Text>
</View>
);
};
export default Modal;

View File

@@ -2,21 +2,29 @@
// See LICENSE.txt for license information.
import merge from 'deepmerge';
import {Appearance, DeviceEventEmitter, Keyboard, Platform} from 'react-native';
import {Appearance, DeviceEventEmitter, NativeModules, Platform} from 'react-native';
import {Navigation, Options, OptionsModalPresentationStyle} from 'react-native-navigation';
import CompassIcon from '@components/compass_icon';
import {Device, Preferences, Screens} from '@constants';
import NavigationConstants from '@constants/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {changeOpacity} from '@utils/theme';
import type {LaunchProps} from '@typings/launch';
const {MattermostManaged} = NativeModules;
const isRunningInSplitView = MattermostManaged.isRunningInSplitView;
Navigation.setDefaultOptions({
layout: {
//@ts-expect-error all not defined in type definition
orientation: [Device.IS_TABLET ? 'all' : 'portrait'],
},
statusBar: {
backgroundColor: 'rgba(20, 33, 62, 0.42)',
},
});
function getThemeFromState() {
@@ -261,7 +269,10 @@ export function showModal(name: string, title: string, passProps = {}, options =
component: {
id: name,
name,
passProps,
passProps: {
...passProps,
isModal: true,
},
options: merge(defaultOptions, options),
},
}],
@@ -353,14 +364,15 @@ export async function dismissModal(options = {}) {
return;
}
const componentId = EphemeralStore.getNavigationTopComponentId();
try {
await Navigation.dismissModal(componentId, options);
EphemeralStore.removeNavigationModal(componentId);
} catch (error) {
// RNN returns a promise rejection if there is no modal to
// dismiss. We'll do nothing in this case.
const componentId = EphemeralStore.getNavigationTopModalId();
if (componentId) {
try {
await Navigation.dismissModal(componentId, options);
EphemeralStore.removeNavigationModal(componentId);
} catch (error) {
// RNN returns a promise rejection if there is no modal to
// dismiss. We'll do nothing in this case.
}
}
}
@@ -421,68 +433,46 @@ export async function dismissOverlay(componentId: string) {
}
}
export function openMainSideMenu() {
if (Platform.OS === 'ios') {
return;
}
const componentId = EphemeralStore.getNavigationTopComponentId();
Keyboard.dismiss();
Navigation.mergeOptions(componentId, {
sideMenu: {
left: {visible: true},
},
});
type BottomSheetArgs = {
closeButtonId: string;
renderContent: () => JSX.Element;
snapPoints: number[];
theme: Theme;
title: string;
}
export function closeMainSideMenu() {
if (Platform.OS === 'ios') {
return;
export async function bottomSheet({title, renderContent, snapPoints, theme, closeButtonId}: BottomSheetArgs) {
const {isSplitView} = await isRunningInSplitView();
const isTablet = Device.IS_TABLET && !isSplitView;
if (isTablet) {
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.centerChannelColor);
showModal(Screens.BOTTOM_SHEET, title, {
closeButtonId,
renderContent,
snapPoints,
}, {
modalPresentationStyle: OptionsModalPresentationStyle.formSheet,
swipeToDismiss: true,
topBar: {
leftButtons: [{
id: closeButtonId,
icon: closeButton,
testID: closeButtonId,
}],
leftButtonColor: changeOpacity(theme.centerChannelColor, 0.56),
background: {
color: theme.centerChannelBg,
},
title: {
color: theme.centerChannelColor,
},
},
});
} else {
showModalOverCurrentContext(Screens.BOTTOM_SHEET, {
renderContent,
snapPoints,
}, {swipeToDismiss: true});
}
Keyboard.dismiss();
Navigation.mergeOptions(Screens.CHANNEL, {
sideMenu: {
left: {visible: false},
},
});
}
export function enableMainSideMenu(enabled: boolean, visible = true) {
if (Platform.OS === 'ios') {
return;
}
Navigation.mergeOptions(Screens.CHANNEL, {
sideMenu: {
left: {enabled, visible},
},
});
}
export function openSettingsSideMenu() {
if (Platform.OS === 'ios') {
return;
}
Keyboard.dismiss();
Navigation.mergeOptions(Screens.CHANNEL, {
sideMenu: {
right: {visible: true},
},
});
}
export function closeSettingsSideMenu() {
if (Platform.OS === 'ios') {
return;
}
Keyboard.dismiss();
Navigation.mergeOptions(Screens.CHANNEL, {
sideMenu: {
right: {visible: false},
},
});
}

View File

@@ -11,6 +11,10 @@ class EphemeralStore {
return this.navigationComponentIdStack[0];
}
getNavigationTopModalId = () => {
return this.navigationModalStack[0];
}
clearNavigationComponents = () => {
this.navigationComponentIdStack = [];
this.navigationModalStack = [];

View File

@@ -3,6 +3,8 @@
import emojiRegex from 'emoji-regex';
import SystemModel from '@database/models/server/system';
import {Emojis, EmojiIndicesByAlias} from './';
const RE_NAMED_EMOJI = /(:([a-zA-Z0-9_+-]+):)/g;
@@ -111,9 +113,9 @@ const defaultComparisonRule = (aName: string, bName: string) => {
return aName.localeCompare(bName);
};
const thumbsDownComparisonRule = (other: string) => (other === 'thumbsup' || other === '+1' ? 1 : 0);
const thumbsDownComparisonRule = (other: string) => (other.startsWith('thumbsup') || other.startsWith('+1') ? 1 : 0);
const thumbsUpComparisonRule = (other: string) => (other === 'thumbsdown' || other === '-1' ? -1 : 0);
const thumbsUpComparisonRule = (other: string) => (other.startsWith('thumbsdown') || other.startsWith('-1') ? -1 : 0);
type Comparators = Record<string, ((other: string) => number)>;
@@ -125,8 +127,9 @@ const customComparisonRules: Comparators = {
};
function doDefaultComparison(aName: string, bName: string) {
if (customComparisonRules[aName]) {
return customComparisonRules[aName](bName) || defaultComparisonRule(aName, bName);
const rule = aName.split('_')[0];
if (customComparisonRules[rule]) {
return customComparisonRules[rule](bName) || defaultComparisonRule(aName, bName);
}
return defaultComparisonRule(aName, bName);
@@ -188,3 +191,29 @@ export function compareEmojis(emojiA: string | Partial<EmojiType>, emojiB: strin
return doDefaultComparison(aName!, bName!);
}
export const isCustomEmojiEnabled = (config: ClientConfig | SystemModel) => {
if (config instanceof SystemModel) {
return config?.value?.EnableCustomEmoji === 'true';
}
return config?.EnableCustomEmoji === 'true';
};
export function fillEmoji(index: number) {
const emoji = Emojis[index];
return {
name: 'short_name' in emoji ? emoji.short_name : emoji.name,
aliases: 'short_names' in emoji ? emoji.short_names : [],
};
}
export function getSkin(emoji: any) {
if ('skin_variations' in emoji) {
return 'default';
}
if ('skins' in emoji) {
return emoji.skins && emoji.skins[0];
}
return null;
}

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment, {Moment} from 'moment-timezone';
import {CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES} from '@constants/custom_status';
// isMinimumServerVersion will return true if currentVersion is equal to higher or than
// the provided minimum version. A non-equal major version will ignore minor and dot
// versions, and a non-equal minor version will ignore dot version.
// currentVersion is a string, e.g '4.6.0'
// minMajorVersion, minMinorVersion, minDotVersion are integers
export const isMinimumServerVersion = (currentVersion: string, minMajorVersion = 0, minMinorVersion = 0, minDotVersion = 0): boolean => {
if (!currentVersion || typeof currentVersion !== 'string') {
return false;
@@ -74,7 +77,7 @@ export function isEmail(email: string): boolean {
return (/^[^ ,@]+@[^ ,@]+$/).test(email);
}
export function safeParseJSON(rawJson: string | Record<string, unknown>) {
export function safeParseJSON(rawJson: string | Record<string, unknown> | unknown[]) {
let data = rawJson;
try {
if (typeof rawJson == 'string') {
@@ -86,3 +89,33 @@ export function safeParseJSON(rawJson: string | Record<string, unknown>) {
return data;
}
export function getCurrentMomentForTimezone(timezone: string) {
return timezone ? moment.tz(timezone) : moment();
}
export function getUtcOffsetForTimeZone(timezone: string) {
return moment.tz(timezone).utcOffset();
}
export function isCustomStatusExpirySupported(version: string) {
return isMinimumServerVersion(version, 5, 37);
}
export function toTitleCase(str: string) {
function doTitleCase(txt: string) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
}
return str.replace(/\w\S*/g, doTitleCase);
}
export function getRoundedTime(value: Moment) {
const roundedTo = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES;
const start = moment(value);
const diff = start.minute() % roundedTo;
if (diff === 0) {
return value;
}
const remainder = roundedTo - diff;
return start.add(remainder, 'm').seconds(0).milliseconds(0);
}

View File

@@ -4,6 +4,7 @@
import {StyleSheet} from 'react-native';
import tinyColor from 'tinycolor2';
import {Preferences, Screens} from '@constants';
import {mergeNavigationOptions} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
@@ -119,7 +120,9 @@ export function setNavigatorStyles(componentId: string, theme: Theme) {
export function setNavigationStackStyles(theme: Theme) {
EphemeralStore.allNavigationComponentIds.forEach((componentId) => {
setNavigatorStyles(componentId, theme);
if (componentId !== Screens.BOTTOM_SHEET) {
setNavigatorStyles(componentId, theme);
}
});
}
@@ -206,3 +209,56 @@ export function blendColors(background: string, foreground: string, opacity: num
return `rgba(${red},${green},${blue},${alpha})`;
}
const themeTypeMap: ThemeTypeMap = {
Mattermost: 'denim',
Organization: 'sapphire',
'Mattermost Dark': 'indigo',
'Windows Dark': 'onyx',
Denim: 'denim',
Sapphire: 'sapphire',
Quartz: 'quartz',
Indigo: 'indigo',
Onyx: 'onyx',
custom: 'custom',
};
// setThemeDefaults will set defaults on the theme for any unset properties.
export function setThemeDefaults(theme: Theme): Theme {
const themes = Preferences.THEMES as Record<ThemeKey, Theme>;
const defaultTheme = themes.denim;
const processedTheme = {...theme};
// If this is a system theme, return the source theme object matching the theme preference type
if (theme.type && theme.type !== 'custom' && Object.keys(themeTypeMap).includes(theme.type)) {
return Preferences.THEMES[themeTypeMap[theme.type]];
}
for (const key of Object.keys(defaultTheme)) {
if (theme[key]) {
// Fix a case where upper case theme colours are rendered as black
processedTheme[key] = theme[key]?.toLowerCase();
}
}
for (const property in defaultTheme) {
if (property === 'type' || (property === 'sidebarTeamBarBg' && theme.sidebarHeaderBg)) {
continue;
}
if (theme[property] == null) {
processedTheme[property] = defaultTheme[property];
}
// Backwards compatability with old name
if (!theme.mentionBg && theme.mentionBj) {
processedTheme.mentionBg = theme.mentionBj;
}
}
if (!theme.sidebarTeamBarBg && theme.sidebarHeaderBg) {
processedTheme.sidebarTeamBarBg = blendColors(theme.sidebarHeaderBg, '#000000', 0.2, true);
}
return processedTheme as Theme;
}

View File

@@ -2,14 +2,18 @@
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import {Alert} from 'react-native';
import {General, Preferences} from '@constants';
import {CustomStatusDuration} from '@constants/custom_status';
import {UserModel} from '@database/models/server';
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
import {toTitleCase} from '@utils/helpers';
import type GroupModel from '@typings/database/models/servers/group';
import type GroupMembershipModel from '@typings/database/models/servers/group_membership';
import type {UserMentionKey} from '@typings/global/markdown';
import type {IntlShape} from 'react-intl';
export function displayUsername(user?: UserProfile | UserModel, locale?: string, teammateDisplayNameSetting?: string, useFallbackUsername = true) {
let name = useFallbackUsername ? getLocalizedMessage(locale || DEFAULT_LOCALE, t('channel_loader.someone'), 'Someone') : '';
@@ -166,12 +170,16 @@ export const getTimezone = (timezone: UserTimezone | null) => {
return timezone.manualTimezone;
};
export const getUserCustomStatus = (user: UserModel) => {
if (user.props?.customStatus) {
return user.props.customStatus as UserCustomStatus;
}
export const getUserCustomStatus = (user: UserModel): UserCustomStatus | undefined => {
try {
if (typeof user.props?.customStatus === 'string') {
return JSON.parse(user.props.customStatus) as UserCustomStatus;
}
return undefined;
return user.props?.customStatus;
} catch {
return undefined;
}
};
export function isCustomStatusExpired(user: UserModel) {
@@ -190,3 +198,43 @@ export function isCustomStatusExpired(user: UserModel) {
const currentTime = timezone ? moment.tz(timezone) : moment();
return currentTime.isSameOrAfter(expiryTime);
}
export function confirmOutOfOfficeDisabled(intl: IntlShape, status: string, updateStatus: (status: string) => void) {
const userStatusId = 'modal.manual_status.auto_responder.message_' + status;
t('modal.manual_status.auto_responder.message_');
t('modal.manual_status.auto_responder.message_away');
t('modal.manual_status.auto_responder.message_dnd');
t('modal.manual_status.auto_responder.message_offline');
t('modal.manual_status.auto_responder.message_online');
let translatedStatus;
if (status === 'dnd') {
translatedStatus = intl.formatMessage({
id: 'mobile.set_status.dnd',
defaultMessage: 'Do Not Disturb',
});
} else {
translatedStatus = intl.formatMessage({
id: `mobile.set_status.${status}`,
defaultMessage: toTitleCase(status),
});
}
Alert.alert(
intl.formatMessage({
id: 'mobile.reset_status.title_ooo',
defaultMessage: 'Disable "Out Of Office"?',
}),
intl.formatMessage({
id: userStatusId,
defaultMessage: 'Would you like to switch your status to "{status}" and disable Automatic Replies?',
}, {status: translatedStatus}),
[{
text: intl.formatMessage({id: 'mobile.reset_status.alert_cancel', defaultMessage: 'Cancel'}),
style: 'cancel',
}, {
text: intl.formatMessage({id: 'mobile.reset_status.alert_ok', defaultMessage: 'OK'}),
onPress: () => updateStatus(status),
}],
);
}

View File

@@ -10,6 +10,12 @@
"about.teamEditiont0": "Team Edition",
"about.teamEditiont1": "Enterprise Edition",
"about.title": "About {appTitle}",
"account.logout": "Log out",
"account.logout_from": "Log out of {serverName}",
"account.saved_messages": "Saved Messages",
"account.settings": "Settings",
"account.user_status.title": "User Presence",
"account.your_profile": "Your Profile",
"api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.",
"api.channel.add_member.added": "{addedUsername} added to the channel by {username}.",
"api.channel.guest_join_channel.post_and_forget": "{username} joined the channel as a guest.",
@@ -58,6 +64,27 @@
"combined_system_message.removed_from_team.one_you": "You were **removed from the team**.",
"combined_system_message.removed_from_team.two": "{firstUser} and {secondUser} were **removed from the team**.",
"combined_system_message.you": "You",
"custom_status.expiry_dropdown.custom": "Custom",
"custom_status.expiry_dropdown.date_and_time": "Date and Time",
"custom_status.expiry_dropdown.dont_clear": "Don't clear",
"custom_status.expiry_dropdown.four_hours": "4 hours",
"custom_status.expiry_dropdown.one_hour": "1 hour",
"custom_status.expiry_dropdown.thirty_minutes": "30 minutes",
"custom_status.expiry_dropdown.this_week": "This week",
"custom_status.expiry_dropdown.today": "Today",
"custom_status.expiry_time.today": "Today",
"custom_status.expiry_time.tomorrow": "Tomorrow",
"custom_status.expiry.at": "at",
"custom_status.expiry.until": "Until",
"custom_status.failure_message": "Failed to update status. Try again",
"custom_status.set_status": "Set a Status",
"custom_status.suggestions.in_a_meeting": "In a meeting",
"custom_status.suggestions.on_a_vacation": "On a vacation",
"custom_status.suggestions.out_for_lunch": "Out for lunch",
"custom_status.suggestions.out_sick": "Out sick",
"custom_status.suggestions.recent_title": "RECENT",
"custom_status.suggestions.title": "SUGGESTIONS",
"custom_status.suggestions.working_from_home": "Working from home",
"date_separator.today": "Today",
"date_separator.yesterday": "Yesterday",
"emoji_picker.activities": "Activities",
@@ -126,12 +153,18 @@
"mobile.components.select_server_view.enterServerUrl": "Enter Server URL",
"mobile.components.select_server_view.proceed": "Proceed",
"mobile.components.select_server_view.siteUrlPlaceholder": "https://mattermost.example.com",
"mobile.custom_status.choose_emoji": "Choose an emoji",
"mobile.custom_status.clear_after": "Clear After",
"mobile.custom_status.clear_after.title": "Clear Custom Status After",
"mobile.custom_status.modal_confirm": "Done",
"mobile.document_preview.failed_description": "An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n",
"mobile.document_preview.failed_title": "Open Document failed",
"mobile.downloader.disabled_description": "File downloads are disabled on this server. Please contact your System Admin for more details.\n",
"mobile.downloader.disabled_title": "Download disabled",
"mobile.downloader.failed_description": "An error occurred while downloading the file. Please check your internet connection and try again.\n",
"mobile.downloader.failed_title": "Download failed",
"mobile.emoji_picker.search.not_found_description": "Check the spelling or try another search.",
"mobile.emoji_picker.search.not_found_title": "No results found for \"{searchTerm}\"",
"mobile.error_handler.button": "Relaunch",
"mobile.error_handler.description": "\nTap relaunch to open the app again. After restart, you can report the problem from the settings menu.\n",
"mobile.error_handler.title": "Unexpected error occurred",
@@ -177,8 +210,12 @@
"mobile.push_notification_reply.title": "Reply",
"mobile.request.invalid_request_method": "Invalid request method",
"mobile.request.invalid_response": "Received invalid response from the server.",
"mobile.reset_status.alert_cancel": "Cancel",
"mobile.reset_status.alert_ok": "OK",
"mobile.reset_status.title_ooo": "Disable \"Out Of Office\"?",
"mobile.routes.code": "{language} Code",
"mobile.routes.code.noLanguage": "Code",
"mobile.routes.custom_status": "Set a Status",
"mobile.routes.login": "Login",
"mobile.routes.loginOptions": "Login Chooser",
"mobile.routes.mfa": "Multi-factor Authentication",
@@ -197,6 +234,10 @@
"mobile.server_url.empty": "Please enter a valid server URL",
"mobile.server_url.invalid_format": "URL must start with http:// or https://",
"mobile.session_expired": "Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.",
"mobile.set_status.away": "Away",
"mobile.set_status.dnd": "Do Not Disturb",
"mobile.set_status.offline": "Offline",
"mobile.set_status.online": "Online",
"mobile.system_message.channel_archived_message": "{username} archived the channel",
"mobile.system_message.channel_unarchived_message": "{username} unarchived the channel",
"mobile.system_message.update_channel_displayname_message_and_forget.updated_from": "{username} updated the channel display name from: {oldDisplayName} to: {newDisplayName}",
@@ -212,6 +253,11 @@
"mobile.unsupported_server.title": "Unsupported server version",
"mobile.youtube_playback_error.description": "An error occurred while trying to play the YouTube video.\nDetails: {details}",
"mobile.youtube_playback_error.title": "YouTube playback error",
"modal.manual_status.auto_responder.message_": "Would you like to switch your status to \"{status}\" and disable Automatic Replies?",
"modal.manual_status.auto_responder.message_away": "Would you like to switch your status to \"Away\" and disable Automatic Replies?",
"modal.manual_status.auto_responder.message_dnd": "Would you like to switch your status to \"Do Not Disturb\" and disable Automatic Replies?",
"modal.manual_status.auto_responder.message_offline": "Would you like to switch your status to \"Offline\" and disable Automatic Replies?",
"modal.manual_status.auto_responder.message_online": "Would you like to switch your status to \"Online\" and disable Automatic Replies?",
"password_form.title": "Password Reset",
"password_send.checkInbox": "Please check your inbox.",
"password_send.description": "To reset your password, enter the email address you used to sign up",
@@ -237,8 +283,14 @@
"post_info.system": "System",
"post_message_view.edited": "(edited)",
"posts_view.newMsg": "New Messages",
"search_bar.search": "Search",
"signup.email": "Email and Password",
"signup.google": "Google Apps",
"signup.office365": "Office 365",
"status_dropdown.set_away": "Away",
"status_dropdown.set_dnd": "Do Not Disturb",
"status_dropdown.set_offline": "Offline",
"status_dropdown.set_online": "Online",
"status_dropdown.set_ooo": "Out Of Office",
"web.root.signup_info": "All team communication in one place, searchable and accessible anywhere"
}

Binary file not shown.

View File

@@ -8,7 +8,7 @@ module.exports = {
],
env: {
production: {
plugins: ['transform-remove-console'],
plugins: ['transform-remove-console', 'react-native-paper/babel'],
},
},
plugins: [

View File

@@ -13,7 +13,7 @@ import {initialLaunch} from './app/init/launch';
import ManagedApp from './app/init/managed_app';
import NetworkManager from './app/init/network_manager';
import PushNotifications from './app/init/push_notifications';
import {registerScreens} from './app/screens/index';
import {registerScreens} from './app/screens';
import EphemeralStore from './app/store/ephemeral_store';
import setFontFamily from './app/utils/font_family';
@@ -81,5 +81,9 @@ function componentDidAppearListener({componentId}: ComponentDidAppearEvent) {
function componentDidDisappearListener({componentId}: ComponentDidDisappearEvent) {
if (componentId !== Screens.HOME) {
EphemeralStore.removeNavigationComponentId(componentId);
if (EphemeralStore.getNavigationTopComponentId() === Screens.HOME) {
DeviceEventEmitter.emit('tabBarVisible', true);
}
}
}

View File

@@ -7,12 +7,9 @@
objects = {
/* Begin PBXBuildFile section */
0111A42B7F264BCF8CBDE3ED /* OpenSans-ExtraBoldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = FBBEC29EE2D3418D9AC33BD5 /* OpenSans-ExtraBoldItalic.ttf */; };
0C0D24F53F254F75869E5951 /* OpenSans-Italic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 41F3AFE83AAF4B74878AB78A /* OpenSans-Italic.ttf */; };
13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
2D5296A8926B4D7FBAF2D6E2 /* OpenSans-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 6561AEAC21CC40B8A72ABB93 /* OpenSans-Light.ttf */; };
4953BF602368AE8600593328 /* SwimeProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4953BF5F2368AE8600593328 /* SwimeProxy.swift */; };
49AE36FF26D4455800EF4E52 /* Gekidou in Frameworks */ = {isa = PBXBuildFile; productRef = 49AE36FE26D4455800EF4E52 /* Gekidou */; };
49AE370126D4455D00EF4E52 /* Gekidou in Frameworks */ = {isa = PBXBuildFile; productRef = 49AE370026D4455D00EF4E52 /* Gekidou */; };
@@ -20,9 +17,6 @@
49B4C050230C981C006E919E /* libUploadAttachments.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FABE04522137F2A00D0F595 /* libUploadAttachments.a */; };
531BEBC72513E93C00BC05B1 /* compass-icons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 531BEBC52513E93C00BC05B1 /* compass-icons.ttf */; };
536CC6C323E79287002C478C /* RNNotificationEventHandler+HandleReplyAction.m in Sources */ = {isa = PBXBuildFile; fileRef = 536CC6C123E79287002C478C /* RNNotificationEventHandler+HandleReplyAction.m */; };
55C6561DDBBA45929D88B6D1 /* OpenSans-BoldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 32AC3D4EA79E44738A6E9766 /* OpenSans-BoldItalic.ttf */; };
62A8448264674B4D95A5A7C2 /* OpenSans-Semibold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C78A387124874496AD2C1466 /* OpenSans-Semibold.ttf */; };
69AC753E496743BABB7A7124 /* OpenSans-SemiboldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0E617BF0F36D4E738F51D169 /* OpenSans-SemiboldItalic.ttf */; };
6C9B1EFD6561083917AF06CF /* libPods-Mattermost.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 8DEEFB3ED6175724A2653247 /* libPods-Mattermost.a */; };
7F0F4B0A24BA173900E14C60 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7F0F4B0924BA173900E14C60 /* LaunchScreen.storyboard */; };
7F151D3E221B062700FAD8F3 /* RuntimeUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F151D3D221B062700FAD8F3 /* RuntimeUtils.swift */; };
@@ -31,9 +25,6 @@
7F240A23220D3A2300637665 /* MattermostShare.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 7F240A19220D3A2300637665 /* MattermostShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
7F240ADB220E089300637665 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F240ADA220E089300637665 /* Item.swift */; };
7F240ADD220E094A00637665 /* TeamsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F240ADC220E094A00637665 /* TeamsViewController.swift */; };
7F25B62D270F717F00F32373 /* Metropolis-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7F25B626270F666D00F32373 /* Metropolis-Light.ttf */; };
7F25B62E270F718300F32373 /* Metropolis-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7F25B628270F666D00F32373 /* Metropolis-Regular.ttf */; };
7F25B632270F825700F32373 /* Metropolis-Semibold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7F25B631270F824E00F32373 /* Metropolis-Semibold.ttf */; };
7F292A711E8AB73400A450A3 /* SplashScreenResource in Resources */ = {isa = PBXBuildFile; fileRef = 7F292A701E8AB73400A450A3 /* SplashScreenResource */; };
7F581D35221ED5C60099E66B /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F581D34221ED5C60099E66B /* NotificationService.swift */; };
7F581D39221ED5C60099E66B /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 7F581D32221ED5C60099E66B /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -44,14 +35,23 @@
7FABDFC22211A39000D0F595 /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FABDFC12211A39000D0F595 /* Section.swift */; };
7FABE00A2212650600D0F595 /* ChannelsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FABE0092212650600D0F595 /* ChannelsViewController.swift */; };
7FABE0562213884700D0F595 /* libUploadAttachments.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FABE04522137F2A00D0F595 /* libUploadAttachments.a */; };
7FB4224026DED2E40063B0EE /* compass-icons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 531BEBC52513E93C00BC05B1 /* compass-icons.ttf */; };
7FB31F812710995B0032E2E5 /* Metropolis-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7F25B626270F666D00F32373 /* Metropolis-Light.ttf */; };
7FB31F822710996D0032E2E5 /* Metropolis-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7F25B628270F666D00F32373 /* Metropolis-Regular.ttf */; };
7FB31F832710996D0032E2E5 /* Metropolis-Semibold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7F25B631270F824E00F32373 /* Metropolis-Semibold.ttf */; };
7FB31F842710996D0032E2E5 /* OpenSans-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D4B1B363C2414DA19C1AC521 /* OpenSans-Bold.ttf */; };
7FB31F852710996D0032E2E5 /* OpenSans-BoldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 32AC3D4EA79E44738A6E9766 /* OpenSans-BoldItalic.ttf */; };
7FB31F862710996D0032E2E5 /* OpenSans-ExtraBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 3647DF63D6764CF093375861 /* OpenSans-ExtraBold.ttf */; };
7FB31F872710996D0032E2E5 /* OpenSans-ExtraBoldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = FBBEC29EE2D3418D9AC33BD5 /* OpenSans-ExtraBoldItalic.ttf */; };
7FB31F882710996D0032E2E5 /* OpenSans-Italic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 41F3AFE83AAF4B74878AB78A /* OpenSans-Italic.ttf */; };
7FB31F892710996D0032E2E5 /* OpenSans-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 6561AEAC21CC40B8A72ABB93 /* OpenSans-Light.ttf */; };
7FB31F8A2710996D0032E2E5 /* OpenSans-LightItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = BE17F630DB5D41FD93F32D22 /* OpenSans-LightItalic.ttf */; };
7FB31F8B2710996D0032E2E5 /* OpenSans-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = BC977883E2624E05975CA65B /* OpenSans-Regular.ttf */; };
7FB31F8C2710996D0032E2E5 /* OpenSans-Semibold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C78A387124874496AD2C1466 /* OpenSans-Semibold.ttf */; };
7FB31F8D2710996D0032E2E5 /* OpenSans-SemiboldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0E617BF0F36D4E738F51D169 /* OpenSans-SemiboldItalic.ttf */; };
7FB31F8E2710996D0032E2E5 /* compass-icons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 531BEBC52513E93C00BC05B1 /* compass-icons.ttf */; };
7FCEFB9326B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m in Sources */ = {isa = PBXBuildFile; fileRef = 7FCEFB9226B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m */; };
7FEB109D1F61019C0039A015 /* MattermostManaged.m in Sources */ = {isa = PBXBuildFile; fileRef = 7FEB109A1F61019C0039A015 /* MattermostManaged.m */; };
84E3264B229834C30055068A /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E325FF229834C30055068A /* Config.swift */; };
9358B95F95184EE0A4DCE629 /* OpenSans-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D4B1B363C2414DA19C1AC521 /* OpenSans-Bold.ttf */; };
A08D512E7ADC40CCAD055A9E /* OpenSans-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = BC977883E2624E05975CA65B /* OpenSans-Regular.ttf */; };
D719A67137964F08BE47A5FC /* OpenSans-ExtraBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 3647DF63D6764CF093375861 /* OpenSans-ExtraBold.ttf */; };
F083DB472349411A8E6E7AAD /* OpenSans-LightItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = BE17F630DB5D41FD93F32D22 /* OpenSans-LightItalic.ttf */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -603,23 +603,23 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7FB4224026DED2E40063B0EE /* compass-icons.ttf in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
7F0F4B0A24BA173900E14C60 /* LaunchScreen.storyboard in Resources */,
7F292A711E8AB73400A450A3 /* SplashScreenResource in Resources */,
7F25B62D270F717F00F32373 /* Metropolis-Light.ttf in Resources */,
7F25B62E270F718300F32373 /* Metropolis-Regular.ttf in Resources */,
7F25B632270F825700F32373 /* Metropolis-Semibold.ttf in Resources */,
9358B95F95184EE0A4DCE629 /* OpenSans-Bold.ttf in Resources */,
55C6561DDBBA45929D88B6D1 /* OpenSans-BoldItalic.ttf in Resources */,
D719A67137964F08BE47A5FC /* OpenSans-ExtraBold.ttf in Resources */,
0111A42B7F264BCF8CBDE3ED /* OpenSans-ExtraBoldItalic.ttf in Resources */,
0C0D24F53F254F75869E5951 /* OpenSans-Italic.ttf in Resources */,
2D5296A8926B4D7FBAF2D6E2 /* OpenSans-Light.ttf in Resources */,
F083DB472349411A8E6E7AAD /* OpenSans-LightItalic.ttf in Resources */,
A08D512E7ADC40CCAD055A9E /* OpenSans-Regular.ttf in Resources */,
62A8448264674B4D95A5A7C2 /* OpenSans-Semibold.ttf in Resources */,
69AC753E496743BABB7A7124 /* OpenSans-SemiboldItalic.ttf in Resources */,
7FB31F812710995B0032E2E5 /* Metropolis-Light.ttf in Resources */,
7FB31F822710996D0032E2E5 /* Metropolis-Regular.ttf in Resources */,
7FB31F832710996D0032E2E5 /* Metropolis-Semibold.ttf in Resources */,
7FB31F842710996D0032E2E5 /* OpenSans-Bold.ttf in Resources */,
7FB31F852710996D0032E2E5 /* OpenSans-BoldItalic.ttf in Resources */,
7FB31F862710996D0032E2E5 /* OpenSans-ExtraBold.ttf in Resources */,
7FB31F872710996D0032E2E5 /* OpenSans-ExtraBoldItalic.ttf in Resources */,
7FB31F882710996D0032E2E5 /* OpenSans-Italic.ttf in Resources */,
7FB31F892710996D0032E2E5 /* OpenSans-Light.ttf in Resources */,
7FB31F8A2710996D0032E2E5 /* OpenSans-LightItalic.ttf in Resources */,
7FB31F8B2710996D0032E2E5 /* OpenSans-Regular.ttf in Resources */,
7FB31F8C2710996D0032E2E5 /* OpenSans-Semibold.ttf in Resources */,
7FB31F8D2710996D0032E2E5 /* OpenSans-SemiboldItalic.ttf in Resources */,
7FB31F8E2710996D0032E2E5 /* compass-icons.ttf in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -362,6 +362,8 @@ PODS:
- React-Core
- RNCMaskedView (0.1.11):
- React
- RNDateTimePicker (3.5.2):
- React-Core
- RNDeviceInfo (8.3.3):
- React-Core
- RNDevMenu (4.0.2):
@@ -516,6 +518,7 @@ DEPENDENCIES:
- "RNCAsyncStorage (from `../node_modules/@react-native-community/async-storage`)"
- "RNCClipboard (from `../node_modules/@react-native-community/clipboard`)"
- "RNCMaskedView (from `../node_modules/@react-native-community/masked-view`)"
- "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)"
- RNDeviceInfo (from `../node_modules/react-native-device-info`)
- RNDevMenu (from `../node_modules/react-native-dev-menu`)
- RNFastImage (from `../node_modules/react-native-fast-image`)
@@ -670,6 +673,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-community/clipboard"
RNCMaskedView:
:path: "../node_modules/@react-native-community/masked-view"
RNDateTimePicker:
:path: "../node_modules/@react-native-community/datetimepicker"
RNDeviceInfo:
:path: "../node_modules/react-native-device-info"
RNDevMenu:
@@ -785,6 +790,7 @@ SPEC CHECKSUMS:
RNCAsyncStorage: b03032fdbdb725bea0bd9e5ec5a7272865ae7398
RNCClipboard: 41d8d918092ae8e676f18adada19104fa3e68495
RNCMaskedView: 0e1bc4bfa8365eba5fbbb71e07fbdc0555249489
RNDateTimePicker: 7658208086d86d09e1627b5c34ba0cf237c60140
RNDeviceInfo: cc7de0772378f85d8f36ae439df20f05c590a651
RNDevMenu: fd325b5554b61fe7f48d9205a3877cf5ee88cd7c
RNFastImage: 1f2cab428712a4baaf78d6169eaec7f622556dd7

103
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"@react-native-community/async-storage": "1.12.1",
"@react-native-community/cameraroll": "4.0.4",
"@react-native-community/clipboard": "1.5.1",
"@react-native-community/datetimepicker": "3.5.2",
"@react-native-community/masked-view": "0.1.11",
"@react-native-community/netinfo": "6.0.2",
"@react-native-cookies/cookies": "6.0.11",
@@ -66,11 +67,13 @@
"react-native-navigation": "7.21.0",
"react-native-neomorph-shadows": "1.1.2",
"react-native-notifications": "4.1.2",
"react-native-paper": "4.9.2",
"react-native-permissions": "3.0.5",
"react-native-reanimated": "2.3.0-beta.2",
"react-native-redash": "16.2.2",
"react-native-safe-area-context": "3.3.2",
"react-native-screens": "3.8.0",
"react-native-section-list-get-item-layout": "2.2.3",
"react-native-share": "7.1.1",
"react-native-slider": "0.11.0",
"react-native-svg": "12.1.1",
@@ -103,6 +106,7 @@
"@types/commonmark-react-renderer": "4.3.1",
"@types/deep-equal": "1.0.1",
"@types/jest": "27.0.2",
"@types/lodash": "4.14.175",
"@types/react": "17.0.26",
"@types/react-intl": "3.0.0",
"@types/react-native": "0.65.3",
@@ -4277,6 +4281,14 @@
"react-native": ">=0.57.0"
}
},
"node_modules/@react-native-community/datetimepicker": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-3.5.2.tgz",
"integrity": "sha512-TWRuAtr/DnrEcRewqvXMLea2oB+YF+SbtuYLHguALLxNJQLl/RFB7aTNZeF+OoH75zKFqtXECXV1/uxQUpA+sg==",
"dependencies": {
"invariant": "^2.2.4"
}
},
"node_modules/@react-native-community/eslint-config": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@react-native-community/eslint-config/-/eslint-config-3.0.1.tgz",
@@ -4998,6 +5010,12 @@
"integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=",
"dev": true
},
"node_modules/@types/lodash": {
"version": "4.14.175",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.175.tgz",
"integrity": "sha512-XmdEOrKQ8a1Y/yxQFOMbC47G/V2VDO1GvMRnl4O75M4GW/abC5tnfzadQYkqEveqRM1dEJGFFegfPNA2vvx2iw==",
"dev": true
},
"node_modules/@types/mime-db": {
"version": "1.43.1",
"resolved": "https://registry.npmjs.org/@types/mime-db/-/mime-db-1.43.1.tgz",
@@ -19503,6 +19521,41 @@
"react-native": ">=0.25.1"
}
},
"node_modules/react-native-paper": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-4.9.2.tgz",
"integrity": "sha512-J7FRsd0YblQawtuj9I46F//apZHadsCKk6jWpc6njFTYdgUeCdkR8KgEto7cp2WxbcGNELx7KGwPQ4zAgX746A==",
"dependencies": {
"@callstack/react-theme-provider": "^3.0.6",
"color": "^3.1.2",
"react-native-iphone-x-helper": "^1.3.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-vector-icons": "*"
}
},
"node_modules/react-native-paper/node_modules/@callstack/react-theme-provider": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@callstack/react-theme-provider/-/react-theme-provider-3.0.6.tgz",
"integrity": "sha512-wwKMXfmklfogpalNZT0W+jh76BIquiYUiQHOaPmt/PCyCEP/E6rP+e7Uie6mBZrfkea9WJYJ+mus6r+45JAEhg==",
"dependencies": {
"deepmerge": "^3.2.0",
"hoist-non-react-statics": "^3.3.0"
},
"peerDependencies": {
"react": "^16.3.0"
}
},
"node_modules/react-native-paper/node_modules/deepmerge": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-3.3.0.tgz",
"integrity": "sha512-GRQOafGHwMHpjPx9iCvTgpu9NojZ49q794EEL94JVEw6VaeA8XTUyBKvAkOOjBX9oJNiV6G3P+T+tihFjo2TqA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-native-permissions": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-3.0.5.tgz",
@@ -19580,6 +19633,11 @@
"react-native": "*"
}
},
"node_modules/react-native-section-list-get-item-layout": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-native-section-list-get-item-layout/-/react-native-section-list-get-item-layout-2.2.3.tgz",
"integrity": "sha512-fzCW5SiYP6qCZyDHebaElHonIFr8NFrZK9JDkxFLnpxMJih4d+HQ4rHyOs0Z4Gb/FjyCVbRH7RtEnjeQ0XffMg=="
},
"node_modules/react-native-share": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-7.1.1.tgz",
@@ -27599,6 +27657,14 @@
"integrity": "sha512-AHAmrkLEH5UtPaDiRqoULERHh3oNv7Dgs0bTC0hO5Z2GdNokAMPT5w8ci8aMcRemcwbtdHjxChgtjbeA38GBdA==",
"requires": {}
},
"@react-native-community/datetimepicker": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-3.5.2.tgz",
"integrity": "sha512-TWRuAtr/DnrEcRewqvXMLea2oB+YF+SbtuYLHguALLxNJQLl/RFB7aTNZeF+OoH75zKFqtXECXV1/uxQUpA+sg==",
"requires": {
"invariant": "^2.2.4"
}
},
"@react-native-community/eslint-config": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@react-native-community/eslint-config/-/eslint-config-3.0.1.tgz",
@@ -28226,6 +28292,12 @@
"integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=",
"dev": true
},
"@types/lodash": {
"version": "4.14.175",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.175.tgz",
"integrity": "sha512-XmdEOrKQ8a1Y/yxQFOMbC47G/V2VDO1GvMRnl4O75M4GW/abC5tnfzadQYkqEveqRM1dEJGFFegfPNA2vvx2iw==",
"dev": true
},
"@types/mime-db": {
"version": "1.43.1",
"resolved": "https://registry.npmjs.org/@types/mime-db/-/mime-db-1.43.1.tgz",
@@ -39670,6 +39742,32 @@
"integrity": "sha512-InYHtQMt47FTvi8siiI9s/2hB0z3Dq+t9mCrCkzLSqbQvZyjMEErKWjK0cT8Lk9sjxQCTNFpuMAhT5AIc0yzfw==",
"requires": {}
},
"react-native-paper": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-4.9.2.tgz",
"integrity": "sha512-J7FRsd0YblQawtuj9I46F//apZHadsCKk6jWpc6njFTYdgUeCdkR8KgEto7cp2WxbcGNELx7KGwPQ4zAgX746A==",
"requires": {
"@callstack/react-theme-provider": "^3.0.6",
"color": "^3.1.2",
"react-native-iphone-x-helper": "^1.3.1"
},
"dependencies": {
"@callstack/react-theme-provider": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@callstack/react-theme-provider/-/react-theme-provider-3.0.6.tgz",
"integrity": "sha512-wwKMXfmklfogpalNZT0W+jh76BIquiYUiQHOaPmt/PCyCEP/E6rP+e7Uie6mBZrfkea9WJYJ+mus6r+45JAEhg==",
"requires": {
"deepmerge": "^3.2.0",
"hoist-non-react-statics": "^3.3.0"
}
},
"deepmerge": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-3.3.0.tgz",
"integrity": "sha512-GRQOafGHwMHpjPx9iCvTgpu9NojZ49q794EEL94JVEw6VaeA8XTUyBKvAkOOjBX9oJNiV6G3P+T+tihFjo2TqA=="
}
}
},
"react-native-permissions": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-3.0.5.tgz",
@@ -39721,6 +39819,11 @@
"warn-once": "^0.1.0"
}
},
"react-native-section-list-get-item-layout": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-native-section-list-get-item-layout/-/react-native-section-list-get-item-layout-2.2.3.tgz",
"integrity": "sha512-fzCW5SiYP6qCZyDHebaElHonIFr8NFrZK9JDkxFLnpxMJih4d+HQ4rHyOs0Z4Gb/FjyCVbRH7RtEnjeQ0XffMg=="
},
"react-native-share": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-7.1.1.tgz",

View File

@@ -23,6 +23,7 @@
"@react-native-community/async-storage": "1.12.1",
"@react-native-community/cameraroll": "4.0.4",
"@react-native-community/clipboard": "1.5.1",
"@react-native-community/datetimepicker": "3.5.2",
"@react-native-community/masked-view": "0.1.11",
"@react-native-community/netinfo": "6.0.2",
"@react-native-cookies/cookies": "6.0.11",
@@ -64,11 +65,13 @@
"react-native-navigation": "7.21.0",
"react-native-neomorph-shadows": "1.1.2",
"react-native-notifications": "4.1.2",
"react-native-paper": "4.9.2",
"react-native-permissions": "3.0.5",
"react-native-reanimated": "2.3.0-beta.2",
"react-native-redash": "16.2.2",
"react-native-safe-area-context": "3.3.2",
"react-native-screens": "3.8.0",
"react-native-section-list-get-item-layout": "2.2.3",
"react-native-share": "7.1.1",
"react-native-slider": "0.11.0",
"react-native-svg": "12.1.1",
@@ -101,6 +104,7 @@
"@types/commonmark-react-renderer": "4.3.1",
"@types/deep-equal": "1.0.1",
"@types/jest": "27.0.2",
"@types/lodash": "4.14.175",
"@types/react": "17.0.26",
"@types/react-intl": "3.0.0",
"@types/react-native": "0.65.3",

View File

@@ -1,13 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
type ThemeKey = 'denim' | 'sapphire' | 'quartz' | 'indigo' | 'onyx';
type LegacyThemeKey = 'default' | 'organization' | 'mattermostDark' | 'windows10';
type LegacyThemeType = 'Mattermost' | 'Organization' | 'Mattermost Dark' | 'Windows Dark';
type ThemeKey = 'denim' | 'sapphire' | 'quartz' | 'indigo' | 'onyx' | 'custom';
type ThemeType = 'Denim' | 'Sapphire' | 'Quartz' | 'Indigo' | 'Onyx' | 'custom';
type Theme = {
[key: string]: string | undefined;
type?: string;
type?: ThemeType | LegacyThemeType;
sidebarBg: string;
sidebarText: string;
sidebarUnreadText: string;
@@ -16,6 +20,7 @@ type Theme = {
sidebarTextActiveColor: string;
sidebarHeaderBg: string;
sidebarHeaderTextColor: string;
sidebarTeamBarBg: string;
onlineIndicator: string;
awayIndicator: string;
dndIndicator: string;
@@ -32,3 +37,5 @@ type Theme = {
mentionHighlightLink: string;
codeTheme: string;
};
type ThemeTypeMap = Record<ThemeType | LegacyThemeType, ThemeKey>;

10
types/api/users.d.ts vendored
View File

@@ -90,13 +90,3 @@ type UserCustomStatus = {
expires_at?: string;
duration?: CustomStatusDuration;
};
enum CustomStatusDuration {
DONT_CLEAR = '',
THIRTY_MINUTES = 'thirty_minutes',
ONE_HOUR = 'one_hour',
FOUR_HOURS = 'four_hours',
TODAY = 'today',
THIS_WEEK = 'this_week',
DATE_AND_TIME = 'date_and_time',
}

View File

@@ -91,6 +91,6 @@ export default class UserModel extends Model {
/** teams : All the team that this user is part of */
teams: Query<TeamMembershipModel>;
/** prepareSatus: Prepare the model to update the user status in a batch operation */
prepareSatus: (status: string) => void;
/** prepareStatus: Prepare the model to update the user status in a batch operation */
prepareStatus: (status: string) => void;
}

View File

@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
declare module 'react-native-keyboard-tracking-view'

Some files were not shown because too many files have changed in this diff Show More