From 22fec720b16dad9b576529e4bcdbcc0caa280170 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Mon, 10 May 2021 23:29:08 -0400 Subject: [PATCH] Server & LoginOptions --- app/client/rest/apps.ts | 3 +- app/client/rest/base.ts | 21 +- app/client/rest/bots.ts | 4 +- app/client/rest/channels.ts | 32 +- app/client/rest/emojis.ts | 44 +- app/client/rest/error.ts | 2 +- app/client/rest/general.ts | 9 +- app/client/rest/groups.ts | 3 +- app/client/rest/index.test.js | 2 +- app/client/rest/integrations.ts | 10 +- app/client/rest/posts.ts | 39 +- app/client/rest/preferences.ts | 2 - app/client/rest/teams.ts | 18 +- app/client/rest/users.ts | 48 +- app/components/error_text/error_text.test.tsx | 14 +- app/components/error_text/error_text.tsx | 58 - app/components/error_text/index.ts | 15 - app/components/error_text/index.tsx | 59 ++ app/components/formatted_text/index.tsx | 172 ++- app/constants/deep_linking.ts | 4 +- app/constants/files.ts | 18 + app/constants/general.ts | 72 ++ app/constants/index.ts | 4 + app/constants/screens.ts | 6 +- app/constants/view.ts | 2 + app/i18n/index.ts | 107 +- app/init/analytics.ts | 2 +- app/init/fetch.ts | 185 ---- app/mattermost_bucket/index.ts | 60 -- app/navigation/index.ts | 121 --- app/requests/remote/general.ts | 27 +- app/screens/{index.ts => index.tsx} | 43 +- app/screens/login_options/email.tsx | 63 ++ app/screens/login_options/gitlab.tsx | 73 ++ app/screens/login_options/google.tsx | 72 ++ app/screens/login_options/index.tsx | 142 +++ app/screens/login_options/ldap.tsx | 78 ++ app/screens/login_options/office365.tsx | 66 ++ app/screens/login_options/open_id.tsx | 64 ++ app/screens/login_options/saml.tsx | 71 ++ app/screens/login_options/types.d.ts | 12 + app/screens/navigation.ts | 3 +- app/screens/select_server/index.ts | 50 - .../select_server/select_server.test.ts | 96 -- app/screens/select_server/select_server.tsx | 668 ------------ app/screens/server/index.tsx | 389 +++++-- app/utils/general/index.ts | 8 + app/utils/helpers.ts | 19 + app/utils/markdown/index.ts | 207 ++++ app/utils/theme/index.ts | 87 +- app/utils/url/index.ts | 137 ++- app/utils/url/latin_map.ts | 995 ++++++++++++++++++ app/utils/url/latinise.ts | 13 + assets/base/images/google.png | Bin 0 -> 601 bytes assets/base/images/logo.png | Bin 0 -> 32490 bytes index.ts | 12 + ios/Podfile.lock | 2 +- package-lock.json | 143 +++ package.json | 6 + patches/react-native-button+3.0.1.patch | 28 + types/api/apps.d.ts | 211 ++++ types/api/bots.d.ts | 20 + types/api/channels.d.ts | 106 ++ types/api/client4.d.ts | 14 +- types/api/config.d.ts | 3 + types/api/emojis.d.ts | 41 + types/api/error.d.ts | 9 + types/api/files.d.ts | 33 + types/api/groups.d.ts | 69 ++ types/api/integrations.d.ts | 87 ++ types/api/license.d.ts | 33 + types/api/posts.d.ts | 113 ++ types/api/reactions.d.ts | 9 + types/api/roles.d.ts | 17 + types/api/teams.d.ts | 55 + types/api/users.d.ts | 80 ++ types/global/preferences.d.ts | 29 - types/global/utilities.d.ts | 29 +- types/screens/gallery.d.ts | 3 - 79 files changed, 3987 insertions(+), 1684 deletions(-) delete mode 100644 app/components/error_text/error_text.tsx delete mode 100644 app/components/error_text/index.ts create mode 100644 app/components/error_text/index.tsx create mode 100644 app/constants/files.ts create mode 100644 app/constants/general.ts delete mode 100644 app/init/fetch.ts delete mode 100644 app/mattermost_bucket/index.ts delete mode 100644 app/navigation/index.ts rename app/screens/{index.ts => index.tsx} (86%) create mode 100644 app/screens/login_options/email.tsx create mode 100644 app/screens/login_options/gitlab.tsx create mode 100644 app/screens/login_options/google.tsx create mode 100644 app/screens/login_options/index.tsx create mode 100644 app/screens/login_options/ldap.tsx create mode 100644 app/screens/login_options/office365.tsx create mode 100644 app/screens/login_options/open_id.tsx create mode 100644 app/screens/login_options/saml.tsx create mode 100644 app/screens/login_options/types.d.ts delete mode 100644 app/screens/select_server/index.ts delete mode 100644 app/screens/select_server/select_server.test.ts delete mode 100644 app/screens/select_server/select_server.tsx create mode 100644 app/utils/general/index.ts create mode 100644 app/utils/markdown/index.ts create mode 100644 app/utils/url/latin_map.ts create mode 100644 app/utils/url/latinise.ts create mode 100644 assets/base/images/google.png create mode 100755 assets/base/images/logo.png create mode 100644 patches/react-native-button+3.0.1.patch create mode 100644 types/api/apps.d.ts create mode 100644 types/api/bots.d.ts create mode 100644 types/api/channels.d.ts create mode 100644 types/api/emojis.d.ts create mode 100644 types/api/error.d.ts create mode 100644 types/api/files.d.ts create mode 100644 types/api/groups.d.ts create mode 100644 types/api/integrations.d.ts create mode 100644 types/api/license.d.ts create mode 100644 types/api/posts.d.ts create mode 100644 types/api/reactions.d.ts create mode 100644 types/api/roles.d.ts create mode 100644 types/api/teams.d.ts create mode 100644 types/api/users.d.ts diff --git a/app/client/rest/apps.ts b/app/client/rest/apps.ts index ab11a27744..08eb32d756 100644 --- a/app/client/rest/apps.ts +++ b/app/client/rest/apps.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps'; -import {buildQueryString} from '@mm-redux/utils/helpers'; +import {buildQueryString} from '@utils/helpers'; export interface ClientAppsMix { executeAppCall: (call: AppCallRequest, type: AppCallType) => Promise; diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index acf0d9666d..0b57fcec17 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -4,15 +4,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {RNFetchBlobFetchRepsonse} from 'rn-fetch-blob'; import urlParse from 'url-parse'; -import {Options} from '@mm-redux/types/client4'; +import {Analytics, create} from '@init/analytics'; import * as ClientConstants from './constants'; import ClientError from './error'; export default class ClientBase { + analitics: Analytics|undefined; clusterId = ''; csrf = ''; defaultHeaders: {[x: string]: string} = {}; @@ -38,8 +38,8 @@ export default class ClientBase { return this.getUrl() + baseUrl; } - getOptions(options: Options) { - const newOptions: Options = {...options}; + getOptions(options: ClientOptions) { + const newOptions: ClientOptions = {...options}; const headers: {[x: string]: string} = { [ClientConstants.HEADER_REQUESTED_WITH]: 'XMLHttpRequest', @@ -127,6 +127,7 @@ export default class ClientBase { setUrl(url: string) { this.url = url.replace(/\/+$/, ''); + this.analitics = create(this.url); } // Routes @@ -279,12 +280,12 @@ export default class ClientBase { } // Client Helpers - handleRedirectProtocol = (url: string, response: RNFetchBlobFetchRepsonse) => { + handleRedirectProtocol = (url: string, response: Response) => { const serverUrl = this.getUrl(); const parsed = urlParse(url); - const {redirects} = response.rnfbRespInfo; - if (redirects) { - const redirectUrl = urlParse(redirects[redirects.length - 1]); + + if (response.redirected) { + const redirectUrl = urlParse(response.url); if (serverUrl === parsed.origin && parsed.host === redirectUrl.host && parsed.protocol !== redirectUrl.protocol) { this.setUrl(serverUrl.replace(parsed.protocol, redirectUrl.protocol)); @@ -292,13 +293,13 @@ export default class ClientBase { } }; - doFetch = async (url: string, options: Options) => { + doFetch = async (url: string, options: ClientOptions) => { const {data} = await this.doFetchWithResponse(url, options); return data; }; - doFetchWithResponse = async (url: string, options: Options) => { + doFetchWithResponse = async (url: string, options: ClientOptions) => { const response = await fetch(url, this.getOptions(options)); const headers = parseAndMergeNestedHeaders(response.headers); diff --git a/app/client/rest/bots.ts b/app/client/rest/bots.ts index 6644324015..b0636e10e6 100644 --- a/app/client/rest/bots.ts +++ b/app/client/rest/bots.ts @@ -1,9 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {buildQueryString} from '@mm-redux/utils/helpers'; - -import type {Bot} from '@mm-redux/types/bots'; +import {buildQueryString} from '@utils/helpers'; export interface ClientBotsMix { getBot: (botUserId: string) => Promise; diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index c09a52020b..5c99a771d3 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -1,9 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {analytics} from '@init/analytics'; -import {Channel, ChannelMemberCountByGroup, ChannelMembership, ChannelNotifyProps, ChannelStats} from '@mm-redux/types/channels'; -import {buildQueryString} from '@mm-redux/utils/helpers'; +import {buildQueryString} from '@utils/helpers'; import {PER_PAGE_DEFAULT} from './constants'; @@ -58,7 +56,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; createChannel = async (channel: Channel) => { - analytics.trackAPI('api_channels_create', {team_id: channel.team_id}); + this.analytics.trackAPI('api_channels_create', {team_id: channel.team_id}); return this.doFetch( `${this.getChannelsRoute()}`, @@ -67,7 +65,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; createDirectChannel = async (userIds: string[]) => { - analytics.trackAPI('api_channels_create_direct'); + this.analytics.trackAPI('api_channels_create_direct'); return this.doFetch( `${this.getChannelsRoute()}/direct`, @@ -76,7 +74,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; createGroupChannel = async (userIds: string[]) => { - analytics.trackAPI('api_channels_create_group'); + this.analytics.trackAPI('api_channels_create_group'); return this.doFetch( `${this.getChannelsRoute()}/group`, @@ -85,7 +83,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; deleteChannel = async (channelId: string) => { - analytics.trackAPI('api_channels_delete', {channel_id: channelId}); + this.analytics.trackAPI('api_channels_delete', {channel_id: channelId}); return this.doFetch( `${this.getChannelRoute(channelId)}`, @@ -94,7 +92,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; unarchiveChannel = async (channelId: string) => { - analytics.trackAPI('api_channels_unarchive', {channel_id: channelId}); + this.analytics.trackAPI('api_channels_unarchive', {channel_id: channelId}); return this.doFetch( `${this.getChannelRoute(channelId)}/restore`, @@ -103,7 +101,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; updateChannel = async (channel: Channel) => { - analytics.trackAPI('api_channels_update', {channel_id: channel.id}); + this.analytics.trackAPI('api_channels_update', {channel_id: channel.id}); return this.doFetch( `${this.getChannelRoute(channel.id)}`, @@ -112,7 +110,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; convertChannelToPrivate = async (channelId: string) => { - analytics.trackAPI('api_channels_convert_to_private', {channel_id: channelId}); + this.analytics.trackAPI('api_channels_convert_to_private', {channel_id: channelId}); return this.doFetch( `${this.getChannelRoute(channelId)}/convert`, @@ -121,7 +119,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; updateChannelPrivacy = async (channelId: string, privacy: any) => { - analytics.trackAPI('api_channels_update_privacy', {channel_id: channelId, privacy}); + this.analytics.trackAPI('api_channels_update_privacy', {channel_id: channelId, privacy}); return this.doFetch( `${this.getChannelRoute(channelId)}/privacy`, @@ -130,7 +128,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; patchChannel = async (channelId: string, channelPatch: Partial) => { - analytics.trackAPI('api_channels_patch', {channel_id: channelId}); + this.analytics.trackAPI('api_channels_patch', {channel_id: channelId}); return this.doFetch( `${this.getChannelRoute(channelId)}/patch`, @@ -139,7 +137,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; updateChannelNotifyProps = async (props: ChannelNotifyProps & {channel_id: string, user_id: string}) => { - analytics.trackAPI('api_users_update_channel_notifications', {channel_id: props.channel_id}); + this.analytics.trackAPI('api_users_update_channel_notifications', {channel_id: props.channel_id}); return this.doFetch( `${this.getChannelMemberRoute(props.channel_id, props.user_id)}/notify_props`, @@ -148,7 +146,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; getChannel = async (channelId: string) => { - analytics.trackAPI('api_channel_get', {channel_id: channelId}); + this.analytics.trackAPI('api_channel_get', {channel_id: channelId}); return this.doFetch( `${this.getChannelRoute(channelId)}`, @@ -164,7 +162,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; getChannelByNameAndTeamName = async (teamName: string, channelName: string, includeDeleted = false) => { - analytics.trackAPI('api_channel_get_by_name_and_teamName', {channel_name: channelName, team_name: teamName, include_deleted: includeDeleted}); + this.analytics.trackAPI('api_channel_get_by_name_and_teamName', {channel_name: channelName, team_name: teamName, include_deleted: includeDeleted}); return this.doFetch( `${this.getTeamNameRoute(teamName)}/channels/name/${channelName}?include_deleted=${includeDeleted}`, @@ -239,7 +237,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; addToChannel = async (userId: string, channelId: string, postRootId = '') => { - analytics.trackAPI('api_channels_add_member', {channel_id: channelId}); + this.analytics.trackAPI('api_channels_add_member', {channel_id: channelId}); const member = {user_id: userId, channel_id: channelId, post_root_id: postRootId}; return this.doFetch( @@ -249,7 +247,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; removeFromChannel = async (userId: string, channelId: string) => { - analytics.trackAPI('api_channels_remove_member', {channel_id: channelId}); + this.analytics.trackAPI('api_channels_remove_member', {channel_id: channelId}); return this.doFetch( `${this.getChannelMemberRoute(channelId, userId)}`, diff --git a/app/client/rest/emojis.ts b/app/client/rest/emojis.ts index 7d2e1b239c..170ca63e15 100644 --- a/app/client/rest/emojis.ts +++ b/app/client/rest/emojis.ts @@ -1,11 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import FormData from 'form-data'; - -import {analytics} from '@init/analytics'; -import {CustomEmoji} from '@mm-redux/types/emojis'; -import {buildQueryString} from '@mm-redux/utils/helpers'; +import {buildQueryString} from '@utils/helpers'; import {PER_PAGE_DEFAULT} from './constants'; @@ -22,27 +18,29 @@ export interface ClientEmojisMix { } const ClientEmojis = (superclass: any) => class extends superclass { + // eslint-disable-next-line @typescript-eslint/no-unused-vars createCustomEmoji = async (emoji: CustomEmoji, imageData: any) => { - analytics.trackAPI('api_emoji_custom_add'); + this.analytics.trackAPI('api_emoji_custom_add'); - const formData = new FormData(); - formData.append('image', imageData); - formData.append('emoji', JSON.stringify(emoji)); - const request: any = { - method: 'post', - body: formData, - }; + // FIXME: Multipart upload with client + // const formData = new FormData(); + // formData.append('image', imageData); + // formData.append('emoji', JSON.stringify(emoji)); + // const request: any = { + // method: 'post', + // body: formData, + // }; - if (formData.getBoundary) { - request.headers = { - 'Content-Type': `multipart/form-data; boundary=${formData.getBoundary()}`, - }; - } + // if (formData.getBoundary) { + // request.headers = { + // 'Content-Type': `multipart/form-data; boundary=${formData.getBoundary()}`, + // }; + // } - return this.doFetch( - `${this.getEmojisRoute()}`, - request, - ); + // return this.doFetch( + // `${this.getEmojisRoute()}`, + // request, + // ); }; getCustomEmoji = async (id: string) => { @@ -67,7 +65,7 @@ const ClientEmojis = (superclass: any) => class extends superclass { }; deleteCustomEmoji = async (emojiId: string) => { - analytics.trackAPI('api_emoji_custom_delete'); + this.analytics.trackAPI('api_emoji_custom_delete'); return this.doFetch( `${this.getEmojiRoute(emojiId)}`, diff --git a/app/client/rest/error.ts b/app/client/rest/error.ts index 0bf146ec6d..d367c09705 100644 --- a/app/client/rest/error.ts +++ b/app/client/rest/error.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {cleanUrlForLogging} from '@mm-redux/utils/sentry'; +import {cleanUrlForLogging} from '@utils/url'; export default class ClientError extends Error { url: string; diff --git a/app/client/rest/general.ts b/app/client/rest/general.ts index 19e058b1f3..3077817117 100644 --- a/app/client/rest/general.ts +++ b/app/client/rest/general.ts @@ -1,10 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Config} from '@mm-redux/types/config'; -import {Role} from '@mm-redux/types/roles'; -import {Dictionary} from '@mm-redux/types/utilities'; -import {buildQueryString} from '@mm-redux/utils/helpers'; +import {buildQueryString} from '@utils/helpers'; import ClientError from './error'; @@ -12,12 +9,12 @@ export interface ClientGeneralMix { getOpenGraphMetadata: (url: string) => Promise; ping: () => Promise; logClientError: (message: string, level?: string) => Promise; - getClientConfigOld: () => Promise; + getClientConfigOld: () => Promise; getClientLicenseOld: () => Promise; getTimezones: () => Promise; getDataRetentionPolicy: () => Promise; getRolesByNames: (rolesNames: string[]) => Promise; - getRedirectLocation: (urlParam: string) => Promise>; + getRedirectLocation: (urlParam: string) => Promise>; } const ClientGeneral = (superclass: any) => class extends superclass { diff --git a/app/client/rest/groups.ts b/app/client/rest/groups.ts index 9c22541c5b..324791602b 100644 --- a/app/client/rest/groups.ts +++ b/app/client/rest/groups.ts @@ -1,8 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Group} from '@mm-redux/types/groups'; -import {buildQueryString} from '@mm-redux/utils/helpers'; +import {buildQueryString} from '@utils/helpers'; import {PER_PAGE_DEFAULT} from './constants'; diff --git a/app/client/rest/index.test.js b/app/client/rest/index.test.js index e585f33710..881ada458c 100644 --- a/app/client/rest/index.test.js +++ b/app/client/rest/index.test.js @@ -7,7 +7,7 @@ import nock from 'nock'; import {HEADER_X_VERSION_ID} from '@client/rest/constants'; import ClientError from '@client/rest/error'; import TestHelper from 'test/test_helper'; -import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; +import {isMinimumServerVersion} from '@utils/helpers'; describe('Client4', () => { beforeAll(() => { diff --git a/app/client/rest/integrations.ts b/app/client/rest/integrations.ts index 7d2ad546f9..003c2f5305 100644 --- a/app/client/rest/integrations.ts +++ b/app/client/rest/integrations.ts @@ -1,9 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {analytics} from '@init/analytics'; -import {Command, DialogSubmission} from '@mm-redux/types/integrations'; -import {buildQueryString} from '@mm-redux/utils/helpers'; +import {buildQueryString} from '@utils/helpers'; import {PER_PAGE_DEFAULT} from './constants'; @@ -39,7 +37,7 @@ const ClientIntegrations = (superclass: any) => class extends superclass { }; executeCommand = async (command: Command, commandArgs = {}) => { - analytics.trackAPI('api_integrations_used'); + this.analytics.trackAPI('api_integrations_used'); return this.doFetch( `${this.getCommandsRoute()}/execute`, @@ -48,7 +46,7 @@ const ClientIntegrations = (superclass: any) => class extends superclass { }; addCommand = async (command: Command) => { - analytics.trackAPI('api_integrations_created'); + this.analytics.trackAPI('api_integrations_created'); return this.doFetch( `${this.getCommandsRoute()}`, @@ -57,7 +55,7 @@ const ClientIntegrations = (superclass: any) => class extends superclass { }; submitInteractiveDialog = async (data: DialogSubmission) => { - analytics.trackAPI('api_interactive_messages_dialog_submitted'); + this.analytics.trackAPI('api_interactive_messages_dialog_submitted'); return this.doFetch( `${this.getBaseRoute()}/actions/dialogs/submit`, {method: 'post', body: JSON.stringify(data)}, diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index d6c99a4b5a..bc4026e447 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -1,10 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {analytics} from '@init/analytics'; -import {FileInfo} from '@mm-redux/types/files'; -import {Post} from '@mm-redux/types/posts'; -import {buildQueryString} from '@mm-redux/utils/helpers'; +import {buildQueryString} from '@utils/helpers'; import {PER_PAGE_DEFAULT} from './constants'; @@ -36,10 +33,10 @@ export interface ClientPostsMix { const ClientPosts = (superclass: any) => class extends superclass { createPost = async (post: Post) => { - analytics.trackAPI('api_posts_create', {channel_id: post.channel_id}); + this.analytics.trackAPI('api_posts_create', {channel_id: post.channel_id}); if (post.root_id != null && post.root_id !== '') { - analytics.trackAPI('api_posts_replied', {channel_id: post.channel_id}); + this.analytics.trackAPI('api_posts_replied', {channel_id: post.channel_id}); } return this.doFetch( @@ -49,7 +46,7 @@ const ClientPosts = (superclass: any) => class extends superclass { }; updatePost = async (post: Post) => { - analytics.trackAPI('api_posts_update', {channel_id: post.channel_id}); + this.analytics.trackAPI('api_posts_update', {channel_id: post.channel_id}); return this.doFetch( `${this.getPostRoute(post.id)}`, @@ -65,7 +62,7 @@ const ClientPosts = (superclass: any) => class extends superclass { }; patchPost = async (postPatch: Partial & {id: string}) => { - analytics.trackAPI('api_posts_patch', {channel_id: postPatch.channel_id}); + this.analytics.trackAPI('api_posts_patch', {channel_id: postPatch.channel_id}); return this.doFetch( `${this.getPostRoute(postPatch.id)}/patch`, @@ -74,7 +71,7 @@ const ClientPosts = (superclass: any) => class extends superclass { }; deletePost = async (postId: string) => { - analytics.trackAPI('api_posts_delete'); + this.analytics.trackAPI('api_posts_delete'); return this.doFetch( `${this.getPostRoute(postId)}`, @@ -104,7 +101,7 @@ const ClientPosts = (superclass: any) => class extends superclass { }; getPostsBefore = async (channelId: string, postId: string, page = 0, perPage = PER_PAGE_DEFAULT) => { - analytics.trackAPI('api_posts_get_before', {channel_id: channelId}); + this.analytics.trackAPI('api_posts_get_before', {channel_id: channelId}); return this.doFetch( `${this.getChannelRoute(channelId)}/posts${buildQueryString({before: postId, page, per_page: perPage})}`, @@ -113,7 +110,7 @@ const ClientPosts = (superclass: any) => class extends superclass { }; getPostsAfter = async (channelId: string, postId: string, page = 0, perPage = PER_PAGE_DEFAULT) => { - analytics.trackAPI('api_posts_get_after', {channel_id: channelId}); + this.analytics.trackAPI('api_posts_get_after', {channel_id: channelId}); return this.doFetch( `${this.getChannelRoute(channelId)}/posts${buildQueryString({after: postId, page, per_page: perPage})}`, @@ -129,7 +126,7 @@ const ClientPosts = (superclass: any) => class extends superclass { }; getFlaggedPosts = async (userId: string, channelId = '', teamId = '', page = 0, perPage = PER_PAGE_DEFAULT) => { - analytics.trackAPI('api_posts_get_flagged', {team_id: teamId}); + this.analytics.trackAPI('api_posts_get_flagged', {team_id: teamId}); return this.doFetch( `${this.getUserRoute(userId)}/posts/flagged${buildQueryString({channel_id: channelId, team_id: teamId, page, per_page: perPage})}`, @@ -138,7 +135,7 @@ const ClientPosts = (superclass: any) => class extends superclass { }; getPinnedPosts = async (channelId: string) => { - analytics.trackAPI('api_posts_get_pinned', {channel_id: channelId}); + this.analytics.trackAPI('api_posts_get_pinned', {channel_id: channelId}); return this.doFetch( `${this.getChannelRoute(channelId)}/pinned`, {method: 'get'}, @@ -146,7 +143,7 @@ const ClientPosts = (superclass: any) => class extends superclass { }; markPostAsUnread = async (userId: string, postId: string) => { - analytics.trackAPI('api_post_set_unread_post'); + this.analytics.trackAPI('api_post_set_unread_post'); return this.doFetch( `${this.getUserRoute(userId)}/posts/${postId}/set_unread`, @@ -155,7 +152,7 @@ const ClientPosts = (superclass: any) => class extends superclass { } pinPost = async (postId: string) => { - analytics.trackAPI('api_posts_pin'); + this.analytics.trackAPI('api_posts_pin'); return this.doFetch( `${this.getPostRoute(postId)}/pin`, @@ -164,7 +161,7 @@ const ClientPosts = (superclass: any) => class extends superclass { }; unpinPost = async (postId: string) => { - analytics.trackAPI('api_posts_unpin'); + this.analytics.trackAPI('api_posts_unpin'); return this.doFetch( `${this.getPostRoute(postId)}/unpin`, @@ -173,7 +170,7 @@ const ClientPosts = (superclass: any) => class extends superclass { }; addReaction = async (userId: string, postId: string, emojiName: string) => { - analytics.trackAPI('api_reactions_save', {post_id: postId}); + this.analytics.trackAPI('api_reactions_save', {post_id: postId}); return this.doFetch( `${this.getReactionsRoute()}`, @@ -182,7 +179,7 @@ const ClientPosts = (superclass: any) => class extends superclass { }; removeReaction = async (userId: string, postId: string, emojiName: string) => { - analytics.trackAPI('api_reactions_delete', {post_id: postId}); + this.analytics.trackAPI('api_reactions_delete', {post_id: postId}); return this.doFetch( `${this.getUserRoute(userId)}/posts/${postId}/reactions/${emojiName}`, @@ -198,7 +195,7 @@ const ClientPosts = (superclass: any) => class extends superclass { }; searchPostsWithParams = async (teamId: string, params: any) => { - analytics.trackAPI('api_posts_search', {team_id: teamId}); + this.analytics.trackAPI('api_posts_search', {team_id: teamId}); return this.doFetch( `${this.getTeamRoute(teamId)}/posts/search`, @@ -216,9 +213,9 @@ const ClientPosts = (superclass: any) => class extends superclass { doPostActionWithCookie = async (postId: string, actionId: string, actionCookie: string, selectedOption = '') => { if (selectedOption) { - analytics.trackAPI('api_interactive_messages_menu_selected'); + this.analytics.trackAPI('api_interactive_messages_menu_selected'); } else { - analytics.trackAPI('api_interactive_messages_button_clicked'); + this.analytics.trackAPI('api_interactive_messages_button_clicked'); } const msg: any = { diff --git a/app/client/rest/preferences.ts b/app/client/rest/preferences.ts index 5666f36a0d..450bd94142 100644 --- a/app/client/rest/preferences.ts +++ b/app/client/rest/preferences.ts @@ -1,8 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {PreferenceType} from '@mm-redux/types/preferences'; - export interface ClientPreferencesMix { savePreferences: (userId: string, preferences: PreferenceType[]) => Promise; deletePreferences: (userId: string, preferences: PreferenceType[]) => Promise; diff --git a/app/client/rest/teams.ts b/app/client/rest/teams.ts index 35d7b0e107..4cc4fc79e3 100644 --- a/app/client/rest/teams.ts +++ b/app/client/rest/teams.ts @@ -1,9 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {analytics} from '@init/analytics'; -import {Team, TeamMembership, TeamUnread} from '@mm-redux/types/teams'; -import {buildQueryString} from '@mm-redux/utils/helpers'; +import {buildQueryString} from '@utils/helpers'; import {PER_PAGE_DEFAULT} from './constants'; @@ -30,7 +28,7 @@ export interface ClientTeamsMix { const ClientTeams = (superclass: any) => class extends superclass { createTeam = async (team: Team) => { - analytics.trackAPI('api_teams_create'); + this.analytics.trackAPI('api_teams_create'); return this.doFetch( `${this.getTeamsRoute()}`, @@ -39,7 +37,7 @@ const ClientTeams = (superclass: any) => class extends superclass { }; deleteTeam = async (teamId: string) => { - analytics.trackAPI('api_teams_delete'); + this.analytics.trackAPI('api_teams_delete'); return this.doFetch( `${this.getTeamRoute(teamId)}`, @@ -48,7 +46,7 @@ const ClientTeams = (superclass: any) => class extends superclass { }; updateTeam = async (team: Team) => { - analytics.trackAPI('api_teams_update_name', {team_id: team.id}); + this.analytics.trackAPI('api_teams_update_name', {team_id: team.id}); return this.doFetch( `${this.getTeamRoute(team.id)}`, @@ -57,7 +55,7 @@ const ClientTeams = (superclass: any) => class extends superclass { }; patchTeam = async (team: Partial & {id: string}) => { - analytics.trackAPI('api_teams_patch_name', {team_id: team.id}); + this.analytics.trackAPI('api_teams_patch_name', {team_id: team.id}); return this.doFetch( `${this.getTeamRoute(team.id)}/patch`, @@ -80,7 +78,7 @@ const ClientTeams = (superclass: any) => class extends superclass { }; getTeamByName = async (teamName: string) => { - analytics.trackAPI('api_teams_get_team_by_name'); + this.analytics.trackAPI('api_teams_get_team_by_name'); return this.doFetch( this.getTeamNameRoute(teamName), @@ -131,7 +129,7 @@ const ClientTeams = (superclass: any) => class extends superclass { }; addToTeam = async (teamId: string, userId: string) => { - analytics.trackAPI('api_teams_invite_members', {team_id: teamId}); + this.analytics.trackAPI('api_teams_invite_members', {team_id: teamId}); const member = {user_id: userId, team_id: teamId}; return this.doFetch( @@ -149,7 +147,7 @@ const ClientTeams = (superclass: any) => class extends superclass { }; removeFromTeam = async (teamId: string, userId: string) => { - analytics.trackAPI('api_teams_remove_members', {team_id: teamId}); + this.analytics.trackAPI('api_teams_remove_members', {team_id: teamId}); return this.doFetch( `${this.getTeamMemberRoute(teamId, userId)}`, diff --git a/app/client/rest/users.ts b/app/client/rest/users.ts index bcc59ce5db..bef25f1815 100644 --- a/app/client/rest/users.ts +++ b/app/client/rest/users.ts @@ -1,10 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {analytics} from '@init/analytics'; -import {General} from '@mm-redux/constants'; -import {UserProfile, UserStatus} from '@mm-redux/types/users'; -import {buildQueryString, isMinimumServerVersion} from '@mm-redux/utils/helpers'; +import {General} from '@constants'; +import {buildQueryString, isMinimumServerVersion} from '@utils/helpers'; import {PER_PAGE_DEFAULT} from './constants'; @@ -47,7 +45,7 @@ export interface ClientUsersMix { const ClientUsers = (superclass: any) => class extends superclass { createUser = async (user: UserProfile, token: string, inviteId: string) => { - analytics.trackAPI('api_users_create'); + this.analytics.trackAPI('api_users_create'); const queryParams: any = {}; @@ -73,7 +71,7 @@ const ClientUsers = (superclass: any) => class extends superclass { } patchUser = async (userPatch: Partial & {id: string}) => { - analytics.trackAPI('api_users_patch'); + this.analytics.trackAPI('api_users_patch'); return this.doFetch( `${this.getUserRoute(userPatch.id)}/patch`, @@ -82,7 +80,7 @@ const ClientUsers = (superclass: any) => class extends superclass { } updateUser = async (user: UserProfile) => { - analytics.trackAPI('api_users_update'); + this.analytics.trackAPI('api_users_update'); return this.doFetch( `${this.getUserRoute(user.id)}`, @@ -91,7 +89,7 @@ const ClientUsers = (superclass: any) => class extends superclass { } demoteUserToGuest = async (userId: string) => { - analytics.trackAPI('api_users_demote_user_to_guest'); + this.analytics.trackAPI('api_users_demote_user_to_guest'); return this.doFetch( `${this.getUserRoute(userId)}/demote`, @@ -100,7 +98,7 @@ const ClientUsers = (superclass: any) => class extends superclass { } getKnownUsers = async () => { - analytics.trackAPI('api_get_known_users'); + this.analytics.trackAPI('api_get_known_users'); return this.doFetch( `${this.getUsersRoute()}/known`, @@ -109,7 +107,7 @@ const ClientUsers = (superclass: any) => class extends superclass { } sendPasswordResetEmail = async (email: string) => { - analytics.trackAPI('api_users_send_password_reset'); + this.analytics.trackAPI('api_users_send_password_reset'); return this.doFetch( `${this.getUsersRoute()}/password/reset/send`, @@ -118,7 +116,7 @@ const ClientUsers = (superclass: any) => class extends superclass { } setDefaultProfileImage = async (userId: string) => { - analytics.trackAPI('api_users_set_default_profile_picture'); + this.analytics.trackAPI('api_users_set_default_profile_picture'); return this.doFetch( `${this.getUserRoute(userId)}/image`, @@ -127,10 +125,10 @@ const ClientUsers = (superclass: any) => class extends superclass { }; login = async (loginId: string, password: string, token = '', deviceId = '', ldapOnly = false) => { - analytics.trackAPI('api_users_login'); + this.analytics.trackAPI('api_users_login'); if (ldapOnly) { - analytics.trackAPI('api_users_login_ldap'); + this.analytics.trackAPI('api_users_login_ldap'); } const body: any = { @@ -157,7 +155,7 @@ const ClientUsers = (superclass: any) => class extends superclass { }; loginById = async (id: string, password: string, token = '', deviceId = '') => { - analytics.trackAPI('api_users_login'); + this.analytics.trackAPI('api_users_login'); const body: any = { device_id: deviceId, id, @@ -174,7 +172,7 @@ const ClientUsers = (superclass: any) => class extends superclass { }; logout = async () => { - analytics.trackAPI('api_users_logout'); + this.analytics.trackAPI('api_users_logout'); const {response} = await this.doFetchWithResponse( `${this.getUsersRoute()}/logout`, @@ -191,7 +189,7 @@ const ClientUsers = (superclass: any) => class extends superclass { }; getProfiles = async (page = 0, perPage = PER_PAGE_DEFAULT, options = {}) => { - analytics.trackAPI('api_profiles_get'); + this.analytics.trackAPI('api_profiles_get'); return this.doFetch( `${this.getUsersRoute()}${buildQueryString({page, per_page: perPage, ...options})}`, @@ -200,7 +198,7 @@ const ClientUsers = (superclass: any) => class extends superclass { }; getProfilesByIds = async (userIds: string[], options = {}) => { - analytics.trackAPI('api_profiles_get_by_ids'); + this.analytics.trackAPI('api_profiles_get_by_ids'); return this.doFetch( `${this.getUsersRoute()}/ids${buildQueryString(options)}`, @@ -209,7 +207,7 @@ const ClientUsers = (superclass: any) => class extends superclass { }; getProfilesByUsernames = async (usernames: string[]) => { - analytics.trackAPI('api_profiles_get_by_usernames'); + this.analytics.trackAPI('api_profiles_get_by_usernames'); return this.doFetch( `${this.getUsersRoute()}/usernames`, @@ -218,7 +216,7 @@ const ClientUsers = (superclass: any) => class extends superclass { }; getProfilesInTeam = async (teamId: string, page = 0, perPage = PER_PAGE_DEFAULT, sort = '', options = {}) => { - analytics.trackAPI('api_profiles_get_in_team', {team_id: teamId, sort}); + this.analytics.trackAPI('api_profiles_get_in_team', {team_id: teamId, sort}); return this.doFetch( `${this.getUsersRoute()}${buildQueryString({...options, in_team: teamId, page, per_page: perPage, sort})}`, @@ -227,7 +225,7 @@ const ClientUsers = (superclass: any) => class extends superclass { }; getProfilesNotInTeam = async (teamId: string, groupConstrained: boolean, page = 0, perPage = PER_PAGE_DEFAULT) => { - analytics.trackAPI('api_profiles_get_not_in_team', {team_id: teamId, group_constrained: groupConstrained}); + this.analytics.trackAPI('api_profiles_get_not_in_team', {team_id: teamId, group_constrained: groupConstrained}); const queryStringObj: any = {not_in_team: teamId, page, per_page: perPage}; if (groupConstrained) { @@ -241,7 +239,7 @@ const ClientUsers = (superclass: any) => class extends superclass { }; getProfilesWithoutTeam = async (page = 0, perPage = PER_PAGE_DEFAULT, options = {}) => { - analytics.trackAPI('api_profiles_get_without_team'); + this.analytics.trackAPI('api_profiles_get_without_team'); return this.doFetch( `${this.getUsersRoute()}${buildQueryString({...options, without_team: 1, page, per_page: perPage})}`, @@ -250,7 +248,7 @@ const ClientUsers = (superclass: any) => class extends superclass { }; getProfilesInChannel = async (channelId: string, page = 0, perPage = PER_PAGE_DEFAULT, sort = '') => { - analytics.trackAPI('api_profiles_get_in_channel', {channel_id: channelId}); + this.analytics.trackAPI('api_profiles_get_in_channel', {channel_id: channelId}); const serverVersion = this.getServerVersion(); let queryStringObj; @@ -266,7 +264,7 @@ const ClientUsers = (superclass: any) => class extends superclass { }; getProfilesInGroupChannels = async (channelsIds: string[]) => { - analytics.trackAPI('api_profiles_get_in_group_channels', {channelsIds}); + this.analytics.trackAPI('api_profiles_get_in_group_channels', {channelsIds}); return this.doFetch( `${this.getUsersRoute()}/group_channels`, @@ -275,7 +273,7 @@ const ClientUsers = (superclass: any) => class extends superclass { }; getProfilesNotInChannel = async (teamId: string, channelId: string, groupConstrained: boolean, page = 0, perPage = PER_PAGE_DEFAULT) => { - analytics.trackAPI('api_profiles_get_not_in_channel', {team_id: teamId, channel_id: channelId, group_constrained: groupConstrained}); + this.analytics.trackAPI('api_profiles_get_not_in_channel', {team_id: teamId, channel_id: channelId, group_constrained: groupConstrained}); const queryStringObj: any = {in_team: teamId, not_in_channel: channelId, page, per_page: perPage}; if (groupConstrained) { @@ -365,7 +363,7 @@ const ClientUsers = (superclass: any) => class extends superclass { }; searchUsers = async (term: string, options: any) => { - analytics.trackAPI('api_search_users'); + this.analytics.trackAPI('api_search_users'); return this.doFetch( `${this.getUsersRoute()}/search`, diff --git a/app/components/error_text/error_text.test.tsx b/app/components/error_text/error_text.test.tsx index 9bca857d6e..7c8173ffe4 100644 --- a/app/components/error_text/error_text.test.tsx +++ b/app/components/error_text/error_text.test.tsx @@ -2,10 +2,10 @@ // See LICENSE.txt for license information. import React from 'react'; -import {shallow} from 'enzyme'; -import Preferences from '@mm-redux/constants/preferences'; +import {render} from '@testing-library/react-native'; +import {Preferences} from '@constants'; -import ErrorText from './error_text.tsx'; +import ErrorText from './index'; describe('ErrorText', () => { const baseProps = { @@ -15,16 +15,14 @@ describe('ErrorText', () => { marginHorizontal: 15, }, theme: Preferences.THEMES.default, - error: { - message: 'Username must begin with a letter and contain between 3 and 22 characters including numbers, lowercase letters, and the symbols', - }, + error: 'Username must begin with a letter and contain between 3 and 22 characters including numbers, lowercase letters, and the symbols', }; test('should match snapshot', () => { - const wrapper = shallow( + const wrapper = render( , ); - expect(wrapper.getElement()).toMatchSnapshot(); + expect(wrapper.toJSON()).toMatchSnapshot(); }); }); diff --git a/app/components/error_text/error_text.tsx b/app/components/error_text/error_text.tsx deleted file mode 100644 index 718022219d..0000000000 --- a/app/components/error_text/error_text.tsx +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import {Text} from 'react-native'; - -import FormattedText from 'app/components/formatted_text'; -import {GlobalStyles} from '@app/styles'; -import {makeStyleSheetFromTheme} from 'app/utils/theme'; - -export default class ErrorText extends PureComponent { - static propTypes = { - testID: PropTypes.string, - error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - textStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]), - theme: PropTypes.object.isRequired, - }; - - render() { - const {testID, error, textStyle, theme} = this.props; - if (!error) { - return null; - } - - const style = getStyleSheet(theme); - - const {intl} = error; - if (intl) { - return ( - - ); - } - - return ( - - {error.message || error} - - ); - } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - errorLabel: { - color: (theme.errorTextColor || '#DA4A4A'), - }, - }; -}); diff --git a/app/components/error_text/index.ts b/app/components/error_text/index.ts deleted file mode 100644 index de94120e0b..0000000000 --- a/app/components/error_text/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {connect} from 'react-redux'; -import {getTheme} from '@mm-redux/selectors/entities/preferences'; - -import ErrorText from './error_text.tsx'; - -function mapStateToProps(state) { - return { - theme: getTheme(state), - }; -} - -export default connect(mapStateToProps)(ErrorText); diff --git a/app/components/error_text/index.tsx b/app/components/error_text/index.tsx new file mode 100644 index 0000000000..944987c1d3 --- /dev/null +++ b/app/components/error_text/index.tsx @@ -0,0 +1,59 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, Text, TextStyle, ViewStyle} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {ClientError} from '@utils/client_error'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +export type ClientErrorWithIntl = ClientError & {intl: {values?: Record}} + +type ErrorProps = { + error: ClientErrorWithIntl | string; + testID?: string; + textStyle?: StyleProp | StyleProp + theme: Theme; +} + +const ErrorText = ({error, testID, textStyle, theme}: ErrorProps) => { + const style = getStyleSheet(theme); + const message = typeof (error) === 'string' ? error : error.message; + + if (typeof (error) !== 'string' && error.intl) { + const {intl} = error; + return ( + + ); + } + + return ( + + {message} + + ); +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + errorLabel: { + color: (theme.errorTextColor || '#DA4A4A'), + marginTop: 15, + marginBottom: 15, + fontSize: 12, + textAlign: 'left', + }, + }; +}); + +export default ErrorText; diff --git a/app/components/formatted_text/index.tsx b/app/components/formatted_text/index.tsx index 5876b85e32..053fc985ba 100644 --- a/app/components/formatted_text/index.tsx +++ b/app/components/formatted_text/index.tsx @@ -1,94 +1,88 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {createElement, isValidElement} from 'react'; -import PropTypes from 'prop-types'; -import {Text} from 'react-native'; -import {IntlShape} from 'react-intl'; +import {createElement, isValidElement} from 'react'; +import {StyleProp, Text, TextStyle, ViewStyle} from 'react-native'; +import {useIntl} from 'react-intl'; -export default class FormattedText extends React.PureComponent { - static propTypes = { - id: PropTypes.string.isRequired, - defaultMessage: PropTypes.string, - values: PropTypes.object, - testID: PropTypes.string, - }; - - static defaultProps = { - defaultMessage: '', - }; - - static contextTypes = { - intl: IntlShape.isRequired, - }; - - render() { - const {id, defaultMessage, values, ...props} = this.props; - const {formatMessage} = this.context.intl; - - let tokenDelimiter; - let tokenizedValues; - let elements; - const hasValues = values && Object.keys(values).length > 0; - if (hasValues) { - // Creates a token with a random UID that should not be guessable or - // conflict with other parts of the `message` string. - const uid = Math.floor(Math.random() * 0x10000000000).toString(16); - - const generateToken = (() => { - let counter = 0; - return () => { - const elementId = `ELEMENT-${uid}-${(counter += 1)}`; - return elementId; - }; - })(); - - // Splitting with a delimiter to support IE8. When using a regex - // with a capture group IE8 does not include the capture group in - // the resulting array. - tokenDelimiter = `@__${uid}__@`; - tokenizedValues = {}; - elements = {}; - - // Iterates over the `props` to keep track of any React Element - // values so they can be represented by the `token` as a placeholder - // when the `message` is formatted. This allows the formatted - // message to then be broken-up into parts with references to the - // React Elements inserted back in. - Object.keys(values).forEach((name) => { - const value = values[name]; - - if (isValidElement(value)) { - const token = generateToken(); - tokenizedValues[name] = tokenDelimiter + token + tokenDelimiter; - elements[token] = value; - } else { - tokenizedValues[name] = value; - } - }); - } - - const descriptor = {id, defaultMessage}; - const formattedMessage = formatMessage( - descriptor, - tokenizedValues || values, - ); - const hasElements = elements && Object.keys(elements).length > 0; - - let nodes; - if (hasElements) { - // Split the message into parts so the React Element values captured - // above can be inserted back into the rendered message. This - // approach allows messages to render with React Elements while - // keeping React's virtual diffing working properly. - nodes = formattedMessage. - split(tokenDelimiter). - filter((part) => Boolean(part)). - map((part) => elements[part] || part); - } else { - nodes = [formattedMessage]; - } - - return createElement(Text, props, ...nodes); - } +type FormattedTextProps = { + id: string; + defaultMessage: string; + values?: Record; + testID?: string; + style?: StyleProp | StyleProp } + +const FormattedText = (props: FormattedTextProps) => { + const intl = useIntl(); + const {formatMessage} = intl; + const {id, defaultMessage, values, ...otherProps} = props; + const tokenizedValues: Record = {}; + const elements: Record = {}; + let tokenDelimiter = ''; + + if (values && Object.keys(values).length > 0) { + // Creates a token with a random UID that should not be guessable or + // conflict with other parts of the `message` string. + const uid = Math.floor(Math.random() * 0x10000000000).toString(16); + + const generateToken = (() => { + let counter = 0; + return () => { + const elementId = `ELEMENT-${uid}-${(counter += 1)}`; + return elementId; + }; + })(); + + // Splitting with a delimiter to support IE8. When using a regex + // with a capture group IE8 does not include the capture group in + // the resulting array. + tokenDelimiter = `@__${uid}__@`; + + // Iterates over the `props` to keep track of any React Element + // values so they can be represented by the `token` as a placeholder + // when the `message` is formatted. This allows the formatted + // message to then be broken-up into parts with references to the + // React Elements inserted back in. + Object.keys(values).forEach((name) => { + const value = values[name]; + + if (isValidElement(value)) { + const token = generateToken(); + tokenizedValues[name] = tokenDelimiter + token + tokenDelimiter; + elements[token] = value; + } else { + tokenizedValues[name] = value; + } + }); + } + + const descriptor = {id, defaultMessage}; + const formattedMessage = formatMessage( + descriptor, + tokenizedValues || values, + ); + const hasElements = elements && Object.keys(elements).length > 0; + + let nodes; + if (hasElements) { + // Split the message into parts so the React Element values captured + // above can be inserted back into the rendered message. This + // approach allows messages to render with React Elements while + // keeping React's virtual diffing working properly. + nodes = formattedMessage. + split(tokenDelimiter). + filter((part) => Boolean(part)). + map((part) => elements[part] || part); + } else { + nodes = [formattedMessage]; + } + + return createElement(Text, otherProps, ...nodes); +}; + +FormattedText.defaultProps = { + defaultMessage: '', +}; + +export default FormattedText; diff --git a/app/constants/deep_linking.ts b/app/constants/deep_linking.ts index f3ac632db1..5d7f68e519 100644 --- a/app/constants/deep_linking.ts +++ b/app/constants/deep_linking.ts @@ -3,6 +3,8 @@ export default { CHANNEL: 'channel', - PERMALINK: 'permalink', + DM: 'dmchannel', + GM: 'groupchannel', OTHER: 'other', + PERMALINK: 'permalink', }; diff --git a/app/constants/files.ts b/app/constants/files.ts new file mode 100644 index 0000000000..dae92df3e1 --- /dev/null +++ b/app/constants/files.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +const Files: Record = { + AUDIO_TYPES: ['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac', 'ogg'], + CODE_TYPES: ['as', 'applescript', 'osascript', 'scpt', 'bash', 'sh', 'zsh', 'clj', 'boot', 'cl2', 'cljc', 'cljs', 'cljs.hl', 'cljscm', 'cljx', 'hic', 'coffee', '_coffee', 'cake', 'cjsx', 'cson', 'iced', 'cpp', 'c', 'cc', 'h', 'c++', 'h++', 'hpp', 'cs', 'csharp', 'css', 'd', 'di', 'dart', 'delphi', 'dpr', 'dfm', 'pas', 'pascal', 'freepascal', 'lazarus', 'lpr', 'lfm', 'diff', 'django', 'jinja', 'dockerfile', 'docker', 'erl', 'f90', 'f95', 'fsharp', 'fs', 'gcode', 'nc', 'go', 'groovy', 'handlebars', 'hbs', 'html.hbs', 'html.handlebars', 'hs', 'hx', 'java', 'jsp', 'js', 'jsx', 'json', 'jl', 'kt', 'ktm', 'kts', 'less', 'lisp', 'lua', 'mk', 'mak', 'md', 'mkdown', 'mkd', 'matlab', 'm', 'mm', 'objc', 'obj-c', 'ml', 'perl', 'pl', 'php', 'php3', 'php4', 'php5', 'php6', 'ps', 'ps1', 'pp', 'py', 'gyp', 'r', 'ruby', 'rb', 'gemspec', 'podspec', 'thor', 'irb', 'rs', 'scala', 'scm', 'sld', 'scss', 'st', 'sql', 'swift', 'tex', 'vbnet', 'vb', 'bas', 'vbs', 'v', 'veo', 'xml', 'html', 'xhtml', 'rss', 'atom', 'xsl', 'plist', 'yaml'], + IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg', 'tiff', 'tif'], + PATCH_TYPES: ['patch'], + PDF_TYPES: ['pdf'], + PRESENTATION_TYPES: ['ppt', 'pptx'], + SPREADSHEET_TYPES: ['xlsx', 'csv'], + TEXT_TYPES: ['txt', 'rtf'], + VIDEO_TYPES: ['mp4', 'avi', 'webm', 'mkv', 'wmv', 'mpg', 'mov', 'flv'], + WORD_TYPES: ['doc', 'docx'], + ZIP_TYPES: ['zip'], +}; + +export default Files; diff --git a/app/constants/general.ts b/app/constants/general.ts new file mode 100644 index 0000000000..72082b8091 --- /dev/null +++ b/app/constants/general.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export default { + CONFIG_CHANGED: 'config_changed', + SERVER_VERSION_CHANGED: 'server_version_changed', + PAGE_SIZE_DEFAULT: 60, + PAGE_SIZE_MAXIMUM: 200, + LOGS_PAGE_SIZE_DEFAULT: 10000, + PROFILE_CHUNK_SIZE: 100, + CHANNELS_CHUNK_SIZE: 50, + TEAMS_CHUNK_SIZE: 50, + SEARCH_TIMEOUT_MILLISECONDS: 100, + STATUS_INTERVAL: 60000, + AUTOCOMPLETE_LIMIT_DEFAULT: 25, + AUTOCOMPLETE_SPLIT_CHARACTERS: ['.', '-', '_'], + MENTION: 'mention', + OUT_OF_OFFICE: 'ooo', + OFFLINE: 'offline', + AWAY: 'away', + ONLINE: 'online', + DND: 'dnd', + PERMISSIONS_ALL: 'all', + PERMISSIONS_CHANNEL_ADMIN: 'channel_admin', + PERMISSIONS_TEAM_ADMIN: 'team_admin', + PERMISSIONS_SYSTEM_ADMIN: 'system_admin', + TEAM_GUEST_ROLE: 'team_guest', + TEAM_USER_ROLE: 'team_user', + TEAM_ADMIN_ROLE: 'team_admin', + CHANNEL_GUEST_ROLE: 'channel_guest', + CHANNEL_USER_ROLE: 'channel_user', + CHANNEL_ADMIN_ROLE: 'channel_admin', + SYSTEM_GUEST_ROLE: 'system_guest', + SYSTEM_USER_ROLE: 'system_user', + SYSTEM_ADMIN_ROLE: 'system_admin', + SYSTEM_USER_ACCESS_TOKEN_ROLE: 'system_user_access_token', + SYSTEM_POST_ALL_ROLE: 'system_post_all', + SYSTEM_POST_ALL_PUBLIC_ROLE: 'system_post_all_public', + ALLOW_EDIT_POST_ALWAYS: 'always', + ALLOW_EDIT_POST_NEVER: 'never', + ALLOW_EDIT_POST_TIME_LIMIT: 'time_limit', + DEFAULT_POST_EDIT_TIME_LIMIT: 300, + RESTRICT_DIRECT_MESSAGE_ANY: 'any', + RESTRICT_DIRECT_MESSAGE_TEAM: 'team', + SWITCH_TO_DEFAULT_CHANNEL: 'switch_to_default_channel', + REMOVED_FROM_CHANNEL: 'removed_from_channel', + DEFAULT_CHANNEL: 'town-square', + DM_CHANNEL: 'D', + OPEN_CHANNEL: 'O', + PRIVATE_CHANNEL: 'P', + GM_CHANNEL: 'G', + PUSH_NOTIFY_APPLE_REACT_NATIVE: 'apple_rn', + PUSH_NOTIFY_ANDROID_REACT_NATIVE: 'android_rn', + STORE_REHYDRATION_COMPLETE: 'store_hydration_complete', + OFFLINE_STORE_RESET: 'offline_store_reset', + OFFLINE_STORE_PURGE: 'offline_store_purge', + TEAMMATE_NAME_DISPLAY: { + SHOW_USERNAME: 'username', + SHOW_NICKNAME_FULLNAME: 'nickname_full_name', + SHOW_FULLNAME: 'full_name', + }, + SPECIAL_MENTIONS: ['all', 'channel', 'here'], + MAX_USERS_IN_GM: 8, + MIN_USERS_IN_GM: 3, + MAX_GROUP_CHANNELS_FOR_PROFILES: 50, + DEFAULT_LOCALE: 'en', + DEFAULT_AUTOLINKED_URL_SCHEMES: ['http', 'https', 'ftp', 'mailto', 'tel', 'mattermost'], + DISABLED: 'disabled', + DEFAULT_ON: 'default_on', + DEFAULT_OFF: 'default_off', + REHYDRATED: 'app/REHYDRATED', +}; diff --git a/app/constants/index.ts b/app/constants/index.ts index e784b9ef38..cab4c0ba59 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -5,6 +5,8 @@ import Attachment from './attachment'; import Database from './database'; import DeepLink from './deep_linking'; import Device from './device'; +import Files from './files'; +import General from './general'; import List from './list'; import Navigation from './navigation'; import Preferences from './preferences'; @@ -17,6 +19,8 @@ export { Database, DeepLink, Device, + Files, + General, List, Navigation, Preferences, diff --git a/app/constants/screens.ts b/app/constants/screens.ts index b65dddf074..ff0327d8dd 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -1,14 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +export const SERVER = 'Server'; +export const LOGIN = 'Login'; +export const LOGIN_OPTIONS = 'LoginOptions'; export const CHANNEL = 'Channel'; export const MAIN_SIDEBAR = 'MainSidebar'; -export const SERVER = 'Server'; export const SETTINGS_SIDEBAR = 'SettingsSidebar'; export const THREAD = 'Thread'; export default { CHANNEL, + LOGIN, + LOGIN_OPTIONS, MAIN_SIDEBAR, SERVER, SETTINGS_SIDEBAR, diff --git a/app/constants/view.ts b/app/constants/view.ts index ed4f2af1a2..a5c0ca465f 100644 --- a/app/constants/view.ts +++ b/app/constants/view.ts @@ -77,7 +77,9 @@ const ViewTypes = keyMirror({ REMOVE_LAST_CHANNEL_FOR_TEAM: null, GITLAB: null, + GOOGLE: null, OFFICE365: null, + OPENID: null, SAML: null, SET_INITIAL_POST_VISIBILITY: null, diff --git a/app/i18n/index.ts b/app/i18n/index.ts index 3a1b952a7d..63421b7321 100644 --- a/app/i18n/index.ts +++ b/app/i18n/index.ts @@ -2,82 +2,173 @@ // See LICENSE.txt for license information. import moment from 'moment'; +import {getLocales} from 'react-native-localize'; import en from '@assets/i18n/en.json'; -export const DEFAULT_LOCALE = 'en'; +declare const global: { HermesInternal: null | {} }; -function loadTranslation(locale: string) { +const deviceLocale = getLocales()[0].languageCode; +export const DEFAULT_LOCALE = deviceLocale; + +function loadTranslation(locale?: string) { try { let translations; let momentData; + switch (locale) { case 'de': + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/de'); + require('@formatjs/intl-numberformat/locale-data/de'); + require('@formatjs/intl-datetimeformat/locale-data/de'); + } + translations = require('@assets/i18n/de.json'); momentData = require('moment/locale/de'); break; case 'es': + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/es'); + require('@formatjs/intl-numberformat/locale-data/es'); + require('@formatjs/intl-datetimeformat/locale-data/es'); + } + translations = require('@assets/i18n/es.json'); momentData = require('moment/locale/es'); break; case 'fr': + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/fr'); + require('@formatjs/intl-numberformat/locale-data/fr'); + require('@formatjs/intl-datetimeformat/locale-data/fr'); + } + translations = require('@assets/i18n/fr.json'); momentData = require('moment/locale/fr'); break; case 'it': + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/it'); + require('@formatjs/intl-numberformat/locale-data/it'); + require('@formatjs/intl-datetimeformat/locale-data/it'); + } + translations = require('@assets/i18n/it.json'); momentData = require('moment/locale/it'); break; case 'ja': + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/ja'); + require('@formatjs/intl-numberformat/locale-data/ja'); + require('@formatjs/intl-datetimeformat/locale-data/ja'); + } + translations = require('@assets/i18n/ja.json'); momentData = require('moment/locale/ja'); break; case 'ko': + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/ko'); + require('@formatjs/intl-numberformat/locale-data/ko'); + require('@formatjs/intl-datetimeformat/locale-data/ko'); + } + translations = require('@assets/i18n/ko.json'); momentData = require('moment/locale/ko'); break; case 'nl': + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/nl'); + require('@formatjs/intl-numberformat/locale-data/nl'); + require('@formatjs/intl-datetimeformat/locale-data/nl'); + } + translations = require('@assets/i18n/nl.json'); momentData = require('moment/locale/nl'); break; case 'pl': + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/pl'); + require('@formatjs/intl-numberformat/locale-data/pl'); + require('@formatjs/intl-datetimeformat/locale-data/pl'); + } + translations = require('@assets/i18n/pl.json'); momentData = require('moment/locale/pl'); break; case 'pt-BR': + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/pt'); + require('@formatjs/intl-numberformat/locale-data/pt'); + require('@formatjs/intl-datetimeformat/locale-data/pt'); + } + translations = require('@assets/i18n/pt-BR.json'); momentData = require('moment/locale/pt-br'); break; case 'ro': + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/ro'); + require('@formatjs/intl-numberformat/locale-data/ro'); + require('@formatjs/intl-datetimeformat/locale-data/ro'); + } + translations = require('@assets/i18n/ro.json'); momentData = require('moment/locale/ro'); break; case 'ru': + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/ru'); + require('@formatjs/intl-numberformat/locale-data/ru'); + require('@formatjs/intl-datetimeformat/locale-data/ru'); + } + translations = require('@assets/i18n/ru.json'); momentData = require('moment/locale/ru'); break; case 'tr': + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/tr'); + require('@formatjs/intl-numberformat/locale-data/tr'); + require('@formatjs/intl-datetimeformat/locale-data/tr'); + } + translations = require('@assets/i18n/tr.json'); momentData = require('moment/locale/tr'); break; case 'uk': + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/uk'); + require('@formatjs/intl-numberformat/locale-data/uk'); + require('@formatjs/intl-datetimeformat/locale-data/uk'); + } + translations = require('@assets/i18n/uk.json'); momentData = require('moment/locale/uk'); break; case 'zh-CN': + loadChinesePolyfills(); translations = require('@assets/i18n/zh-CN.json'); momentData = require('moment/locale/zh-cn'); break; case 'zh-TW': + loadChinesePolyfills(); translations = require('@assets/i18n/zh-TW.json'); momentData = require('moment/locale/zh-tw'); break; default: + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/en'); + require('@formatjs/intl-numberformat/locale-data/en'); + require('@formatjs/intl-datetimeformat/locale-data/en'); + } + translations = en; break; } - if (momentData) { + if (momentData && locale) { moment.updateLocale(locale.toLowerCase(), momentData); } else { resetMomentLocale(); @@ -89,11 +180,19 @@ function loadTranslation(locale: string) { } } +function loadChinesePolyfills() { + if (global.HermesInternal) { + require('@formatjs/intl-pluralrules/locale-data/zh'); + require('@formatjs/intl-numberformat/locale-data/zh'); + require('@formatjs/intl-datetimeformat/locale-data/zh'); + } +} + export function resetMomentLocale() { moment.locale(DEFAULT_LOCALE); } -export function getTranslations(locale: string) { +export function getTranslations(locale?: string) { return loadTranslation(locale); } diff --git a/app/init/analytics.ts b/app/init/analytics.ts index 93419b84b2..2289365989 100644 --- a/app/init/analytics.ts +++ b/app/init/analytics.ts @@ -13,7 +13,7 @@ const isSystemAdmin = (roles: string) => { const clientMap: Record = {}; -class Analytics { +export class Analytics { analytics: RudderClient | null = null; context: any; diagnosticId: string | undefined; diff --git a/app/init/fetch.ts b/app/init/fetch.ts deleted file mode 100644 index 822d8daeb8..0000000000 --- a/app/init/fetch.ts +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Platform} from 'react-native'; -import DeviceInfo from 'react-native-device-info'; -import RNFetchBlob from 'rn-fetch-blob'; -import urlParse from 'url-parse'; - -import LocalConfig from '@assets/config'; -import {Client4} from '@client/rest'; -import {HEADER_TOKEN, HEADER_X_CLUSTER_ID, HEADER_X_VERSION_ID} from '@client/rest/constants'; -import ClientError from '@client/rest/error'; -import EventEmitter from '@mm-redux/utils/event_emitter'; -import {General} from '@mm-redux/constants'; -import {t} from '@utils/i18n'; - -import mattermostBucket from 'app/mattermost_bucket'; -import mattermostManaged from 'app/mattermost_managed'; - -/* eslint-disable no-throw-literal */ - -const DEFAULT_TIMEOUT = 10000; - -let managedConfig; - -mattermostManaged.addEventListener('managedConfigDidChange', (config) => { - if (config?.timeout !== managedConfig?.timeout) { - initFetchConfig(); - return; - } - managedConfig = config; -}); - -const handleRedirectProtocol = (url, response) => { - const serverUrl = Client4.getUrl(); - const parsed = urlParse(url); - const {redirects} = response.rnfbRespInfo; - if (redirects) { - const redirectUrl = urlParse(redirects[redirects.length - 1]); - - if (serverUrl === parsed.origin && parsed.host === redirectUrl.host && parsed.protocol !== redirectUrl.protocol) { - Client4.setUrl(serverUrl.replace(parsed.protocol, redirectUrl.protocol)); - } - } -}; - -Client4.doFetchWithResponse = async (url, options) => { - // eslint-disable-next-line no-console - console.log('Request endpoint', url); - const customHeaders = LocalConfig.CustomRequestHeaders; - let waitsForConnectivity = false; - let timeoutIntervalForResource = 30; - - if (managedConfig?.useVPN === 'true') { - waitsForConnectivity = true; - } - - if (managedConfig?.timeoutVPN) { - timeoutIntervalForResource = parseInt(managedConfig.timeoutVPN, 10); - } - - let requestOptions = { - ...Client4.getOptions(options), - waitsForConnectivity, - timeoutIntervalForResource, - }; - - if (customHeaders && Object.keys(customHeaders).length > 0) { - requestOptions = { - ...requestOptions, - headers: { - ...requestOptions.headers, - ...LocalConfig.CustomRequestHeaders, - }, - }; - } - - let response; - let headers; - - let data; - try { - response = await fetch(url, requestOptions); - headers = response.headers; - if (!url.startsWith('https') && response.rnfbRespInfo && response.rnfbRespInfo.redirects && response.rnfbRespInfo.redirects.length > 1) { - handleRedirectProtocol(url, response); - } - - data = await response.json(); - } catch (err) { - if (response && response.resp && response.resp.data && response.resp.data.includes('SSL certificate')) { - throw new ClientError(Client4.getUrl(), { - message: 'You need to use a valid client certificate in order to connect to this Mattermost server', - status_code: 401, - url, - details: err, - }); - } - - throw new ClientError(Client4.getUrl(), { - message: 'Received invalid response from the server.', - intl: { - id: t('mobile.request.invalid_response'), - defaultMessage: 'Received invalid response from the server.', - }, - url, - details: err, - }); - } - - const clusterId = headers[HEADER_X_CLUSTER_ID] || headers[HEADER_X_CLUSTER_ID.toLowerCase()]; - if (clusterId && Client4.clusterId !== clusterId) { - Client4.clusterId = clusterId; /* eslint-disable-line require-atomic-updates */ - } - - const token = headers[HEADER_TOKEN] || headers[HEADER_TOKEN.toLowerCase()]; - if (token) { - Client4.setToken(token); - } - - const serverVersion = headers[HEADER_X_VERSION_ID] || headers[HEADER_X_VERSION_ID.toLowerCase()]; - if (serverVersion && !headers['Cache-Control'] && Client4.serverVersion !== serverVersion) { - Client4.serverVersion = serverVersion; /* eslint-disable-line require-atomic-updates */ - EventEmitter.emit(General.SERVER_VERSION_CHANGED, serverVersion); - } - - if (response.ok) { - const headersMap = new Map(); - Object.keys(headers).forEach((key) => { - headersMap.set(key, headers[key]); - }); - - return { - response, - headers: headersMap, - data, - }; - } - - const msg = data.message || ''; - - if (Client4.logToConsole) { - console.error(msg); // eslint-disable-line no-console - } - - throw new ClientError(Client4.getUrl(), { - message: msg, - server_error_id: data.id, - status_code: data.status_code, - url, - }); -}; - -const initFetchConfig = async () => { - const fetchConfig = { - auto: true, - timeout: DEFAULT_TIMEOUT, // Set the base timeout for every request to 5s - }; - - try { - managedConfig = await mattermostManaged.getConfig(); - - if (managedConfig?.timeout) { - const timeout = parseInt(managedConfig.timeout, 10); - fetchConfig.timeout = timeout || DEFAULT_TIMEOUT; - } - } catch { - // no managed config - } - - const userAgent = await DeviceInfo.getUserAgent(); - Client4.setUserAgent(userAgent); - - if (Platform.OS === 'ios') { - fetchConfig.certificate = await mattermostBucket.getPreference('cert'); - } - - window.fetch = new RNFetchBlob.polyfill.Fetch(fetchConfig).build(); - - return true; -}; - -initFetchConfig(); - -export default initFetchConfig; diff --git a/app/mattermost_bucket/index.ts b/app/mattermost_bucket/index.ts deleted file mode 100644 index 2617865329..0000000000 --- a/app/mattermost_bucket/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {NativeModules, Platform} from 'react-native'; - -//todo: need to remove this file altogether - -// TODO: Remove platform specific once android is implemented -const MattermostBucket = Platform.OS === 'ios' ? NativeModules.MattermostBucketModule : null; - -export default { - setPreference: (key, value) => { - if (MattermostBucket) { - MattermostBucket.setPreference(key, value); - } - }, - getPreference: async (key) => { - if (MattermostBucket) { - const value = await MattermostBucket.getPreference(key); - if (value) { - try { - return JSON.parse(value); - } catch (e) { - return value; - } - } - } - - return null; - }, - removePreference: (key) => { - if (MattermostBucket) { - MattermostBucket.removePreference(key); - } - }, - writeToFile: (fileName, content) => { - if (MattermostBucket) { - MattermostBucket.writeToFile(fileName, content); - } - }, - readFromFile: async (fileName) => { - if (MattermostBucket) { - const value = await MattermostBucket.readFromFile(fileName); - if (value) { - try { - return JSON.parse(value); - } catch (e) { - return value; - } - } - } - - return null; - }, - removeFile: (fileName) => { - if (MattermostBucket) { - MattermostBucket.removeFile(fileName); - } - }, -}; diff --git a/app/navigation/index.ts b/app/navigation/index.ts deleted file mode 100644 index 1e98648394..0000000000 --- a/app/navigation/index.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import merge from 'deepmerge'; -import {Platform} from 'react-native'; -import {Navigation} from 'react-native-navigation'; -import {Store} from 'react-native-navigation/lib/dist/components/Store'; - -//fixme: to needful here for the whole file - -function getThemeFromState() { - const state = Store.redux?.getState() || {}; - - return getTheme(state); -} - -export function goToScreen(name, title, passProps = {}, options = {}) { - const theme = getThemeFromState(); - const componentId = EphemeralStore.getNavigationTopComponentId(); - const defaultOptions = { - layout: { - componentBackgroundColor: theme.centerChannelBg, - }, - popGesture: true, - sideMenu: { - left: {enabled: false}, - right: {enabled: false}, - }, - topBar: { - animate: true, - visible: true, - backButton: { - color: theme.sidebarHeaderTextColor, - enableMenu: false, - title: '', - testID: 'screen.back.button', - }, - background: { - color: theme.sidebarHeaderBg, - }, - title: { - color: theme.sidebarHeaderTextColor, - text: title, - }, - }, - }; - - Navigation.push(componentId, { - component: { - id: name, - name, - passProps, - options: merge(defaultOptions, options), - }, - }); -} - -export function resetToChannel(passProps = {}) { - const theme = getThemeFromState(); - - EphemeralStore.clearNavigationComponents(); - - const stack = { - children: [{ - component: { - id: NavigationTypes.CHANNEL_SCREEN, - name: NavigationTypes.CHANNEL_SCREEN, - passProps, - options: { - layout: { - componentBackgroundColor: theme.centerChannelBg, - }, - statusBar: { - visible: true, - }, - topBar: { - visible: false, - height: 0, - background: { - color: theme.sidebarHeaderBg, - }, - backButton: { - visible: false, - color: theme.sidebarHeaderTextColor, - enableMenu: false, - }, - }, - }, - }, - }], - }; - - let platformStack = {stack}; - if (Platform.OS === 'android') { - platformStack = { - sideMenu: { - left: { - component: { - id: 'MainSidebar', - name: 'MainSidebar', - }, - }, - center: { - stack, - }, - right: { - component: { - id: 'SettingsSidebar', - name: 'SettingsSidebar', - }, - }, - }, - }; - } - - Navigation.setRoot({ - root: { - ...platformStack, - }, - }); -} diff --git a/app/requests/remote/general.ts b/app/requests/remote/general.ts index c845d2899b..ec6304cb22 100644 --- a/app/requests/remote/general.ts +++ b/app/requests/remote/general.ts @@ -4,27 +4,44 @@ //fixme: substitute with network client import {Client4} from '@client/rest'; -export const getPing = async () => { +export const doPing = async (serverUrl?: string) => { let data; - let pingError = { + const pingError = { id: 'mobile.server_ping_failed', defaultMessage: 'Cannot connect to the server. Please check your server URL and internet connection.', }; try { + if (serverUrl) { + Client4.setUrl(serverUrl); + } + data = await Client4.ping(); if (data.status !== 'OK') { // successful ping but not the right return {data} - return {error: pingError}; + return {error: {intl: pingError}}; } } catch (error) { // Client4Error if (error.status_code === 401) { // When the server requires a client certificate to connect. - pingError = error; + return {error}; } - return {error: pingError}; + return {error: {intl: pingError}}; } return {data}; }; + +export const fetchConfigAndLicense = async () => { + try { + const [config, license] = await Promise.all([ + Client4.getClientConfigOld(), + Client4.getClientLicenseOld(), + ]); + + return {config, license}; + } catch (error) { + return {error}; + } +}; diff --git a/app/screens/index.ts b/app/screens/index.tsx similarity index 86% rename from app/screens/index.ts rename to app/screens/index.tsx index 986fd593f9..eb91749b28 100644 --- a/app/screens/index.ts +++ b/app/screens/index.tsx @@ -1,19 +1,21 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {withManagedConfig} from '@mattermost/react-native-emm'; import React from 'react'; import {Platform, StyleProp, ViewStyle} from 'react-native'; -import {withManagedConfig} from '@mattermost/react-native-emm'; +import {IntlProvider} from 'react-intl'; -import {Navigation} from 'react-native-navigation'; +import {Navigation, NavigationComponent, NavigationFunctionComponent} from 'react-native-navigation'; import {gestureHandlerRootHOC} from 'react-native-gesture-handler'; import {Screens} from '@constants'; +import {DEFAULT_LOCALE, getTranslations} from '@i18n'; // TODO: Remove this and uncomment screens as they get added /* eslint-disable */ -const withGestures = (screen: React.ComponentType, styles: StyleProp) => { +const withGestures = (screen: NavigationFunctionComponent, styles: StyleProp) => { if (Platform.OS === 'android') { return gestureHandlerRootHOC(screen, styles); } @@ -21,10 +23,23 @@ const withGestures = (screen: React.ComponentType, styles: StyleProp { + return function IntlEnabledComponent(props: any) { + return ( + + + + ); + } +} + Navigation.setLazyComponentRegistrator((screenName) => { - // let screen: any; - // let extraStyles: StyleProp; - // switch (screenName) { + let screen: any|undefined; + let extraStyles: StyleProp; + switch (screenName) { // case 'About': // screen = require('@screens/about').default; // break; @@ -91,9 +106,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { // case 'Login': // screen = require('@screens/login').default; // break; - // case 'LoginOptions': - // screen = require('@screens/login_options').default; - // break; + case 'LoginOptions': + screen = require('@screens/login_options').default; + break; // case 'LongPost': // screen = require('@screens/long_post').default; // break; @@ -191,11 +206,11 @@ Navigation.setLazyComponentRegistrator((screenName) => { // case 'UserProfile': // screen = require('@screens/user_profile').default; // break; - // } + } - // if (screen) { - // Navigation.registerComponent(screenName, () => withGestures(withManagedConfig(screen), extraStyles)); - // } + if (screen) { + Navigation.registerComponent(screenName, () => withGestures(withIntl(withManagedConfig(screen)), extraStyles)); + } }); export function registerScreens() { @@ -203,5 +218,5 @@ export function registerScreens() { const serverScreen = require('@screens/server').default; Navigation.registerComponent(Screens.CHANNEL, () => withManagedConfig(channelScreen)); - Navigation.registerComponent(Screens.SERVER, () => withManagedConfig(serverScreen)); + Navigation.registerComponent(Screens.SERVER, () => withIntl(withManagedConfig(serverScreen))); } diff --git a/app/screens/login_options/email.tsx b/app/screens/login_options/email.tsx new file mode 100644 index 0000000000..f3f2fa4385 --- /dev/null +++ b/app/screens/login_options/email.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, ViewStyle} from 'react-native'; +import Button from 'react-native-button'; + +import LocalConfig from '@assets/config.json'; +import FormattedText from '@components/formatted_text'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +const EmailOption = ({config, onPress, theme}: LoginOptionWithConfigProps) => { + const styles = getStyleSheet(theme); + const forceHideFromLocal = LocalConfig.HideEmailLoginExperimental; + + if (!forceHideFromLocal && (config.EnableSignInWithEmail === 'true' || config.EnableSignInWithUsername === 'true')) { + const backgroundColor = config.EmailLoginButtonColor || '#2389d7'; + const additionalStyle: StyleProp = { + backgroundColor, + }; + + if (config.EmailLoginButtonBorderColor) { + additionalStyle.borderColor = config.EmailLoginButtonBorderColor; + } + + const textColor = config.EmailLoginButtonTextColor || 'white'; + + return ( + + ); + } + + return null; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + button: { + borderRadius: 3, + borderColor: theme.buttonBg, + alignItems: 'center', + borderWidth: 1, + alignSelf: 'stretch', + marginTop: 10, + padding: 15, + }, + buttonText: { + textAlign: 'center', + color: theme.buttonBg, + fontSize: 17, + }, +})); + +export default EmailOption; diff --git a/app/screens/login_options/gitlab.tsx b/app/screens/login_options/gitlab.tsx new file mode 100644 index 0000000000..34f4ed47e0 --- /dev/null +++ b/app/screens/login_options/gitlab.tsx @@ -0,0 +1,73 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Image, Text} from 'react-native'; +import Button from 'react-native-button'; + +import LocalConfig from '@assets/config.json'; +import {View} from '@constants'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +const GitLabOption = ({config, onPress, theme}: LoginOptionWithConfigProps) => { + const styles = getStyleSheet(theme); + const forceHideFromLocal = LocalConfig.HideGitLabLoginExperimental; + + const handlePress = () => { + onPress(View.GITLAB); + }; + + if (!forceHideFromLocal && config.EnableSignUpWithGitLab === 'true') { + const additionalButtonStyle = { + backgroundColor: '#548', + borderColor: 'transparent', + borderWidth: 0, + }; + + const logoStyle = { + height: 18, + marginRight: 5, + width: 18, + }; + + const textColor = 'white'; + return ( + + ); + } + + return null; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + button: { + borderRadius: 3, + borderColor: theme.buttonBg, + alignItems: 'center', + borderWidth: 1, + alignSelf: 'stretch', + marginTop: 10, + padding: 15, + }, + buttonText: { + textAlign: 'center', + color: theme.buttonBg, + fontSize: 17, + }, +})); + +export default GitLabOption; diff --git a/app/screens/login_options/google.tsx b/app/screens/login_options/google.tsx new file mode 100644 index 0000000000..d965bcee82 --- /dev/null +++ b/app/screens/login_options/google.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Image} from 'react-native'; +import Button from 'react-native-button'; + +import FormattedText from '@components/formatted_text'; +import {View} from '@constants'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +const GoogleOption = ({config, onPress, theme}: LoginOptionWithConfigProps) => { + const styles = getStyleSheet(theme); + + const handlePress = () => { + onPress(View.GOOGLE); + }; + + if (config.EnableSignUpWithGoogle === 'true') { + const additionalButtonStyle = { + backgroundColor: '#c23321', + borderColor: 'transparent', + borderWidth: 0, + }; + + const logoStyle = { + height: 18, + marginRight: 5, + width: 18, + }; + + const textColor = 'white'; + return ( + + ); + } + + return null; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + button: { + borderRadius: 3, + borderColor: theme.buttonBg, + alignItems: 'center', + borderWidth: 1, + alignSelf: 'stretch', + marginTop: 10, + padding: 15, + }, + buttonText: { + textAlign: 'center', + color: theme.buttonBg, + fontSize: 17, + }, +})); + +export default GoogleOption; diff --git a/app/screens/login_options/index.tsx b/app/screens/login_options/index.tsx new file mode 100644 index 0000000000..656d47ad6a --- /dev/null +++ b/app/screens/login_options/index.tsx @@ -0,0 +1,142 @@ +// 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 {Image, ScrollView, StatusBar, StyleSheet, Text} from 'react-native'; +import {NavigationFunctionComponent} from 'react-native-navigation'; +import {SafeAreaView} from 'react-native-safe-area-context'; + +import FormattedText from '@components/formatted_text'; +import {goToScreen} from '@screens/navigation'; +import {preventDoubleTap} from '@utils/tap'; + +import EmailOption from './email'; +import GitLabOption from './gitlab'; +import GoogleOption from './google'; +import LdapOption from './ldap'; +import Office365Option from './office365'; +import OpenIdOption from './open_id'; +import SamlOption from './saml'; + +type LoginOptionsProps = { + componentId: string; + config: ClientConfig; + license: ClientLicense; + theme: Theme; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + textAlign: 'center', + marginTop: 15, + marginBottom: 15, + fontSize: 32, + fontWeight: '600', + }, + subheader: { + textAlign: 'center', + fontSize: 16, + fontWeight: '300', + color: '#777', + marginBottom: 15, + lineHeight: 22, + }, + innerContainer: { + alignItems: 'center', + flexDirection: 'column', + justifyContent: 'center', + paddingHorizontal: 15, + flex: 1, + }, +}); + +const LoginOptions: NavigationFunctionComponent = ({config, license, theme}: LoginOptionsProps) => { + const intl = useIntl(); + + const displayLogin = preventDoubleTap(() => { + const screen = 'Login'; + const title = intl.formatMessage({id: 'mobile.routes.login', defaultMessage: 'Login'}); + + goToScreen(screen, title); + }); + + const displaySSO = preventDoubleTap((ssoType: string) => { + const screen = 'SSO'; + const title = intl.formatMessage({id: 'mobile.routes.sso', defaultMessage: 'Single Sign-On'}); + + goToScreen(screen, title, {ssoType}); + }); + + return ( + + + + + + {config.SiteName} + + + + + + + + + + + + + ); +}; + +export default LoginOptions; diff --git a/app/screens/login_options/ldap.tsx b/app/screens/login_options/ldap.tsx new file mode 100644 index 0000000000..0c2f0a8107 --- /dev/null +++ b/app/screens/login_options/ldap.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text} from 'react-native'; +import Button from 'react-native-button'; + +import LocalConfig from '@assets/config.json'; +import FormattedText from '@components/formatted_text'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +const LdapOption = ({config, license, onPress, theme}: LoginOptionWithConfigAndLicenseProps) => { + const styles = getStyleSheet(theme); + const forceHideFromLocal = LocalConfig.HideLDAPLoginExperimental; + + if (!forceHideFromLocal && license.IsLicensed === 'true' && config.EnableLdap === 'true') { + const backgroundColor = config.LdapLoginButtonColor || '#2389d7'; + const additionalButtonStyle = { + backgroundColor, + borderColor: 'transparent', + borderWidth: 1, + }; + + if (config.LdapLoginButtonBorderColor) { + additionalButtonStyle.borderColor = config.LdapLoginButtonBorderColor; + } + + const textColor = config.LdapLoginButtonTextColor || 'white'; + + let buttonText; + if (config.LdapLoginFieldName) { + buttonText = ( + + {config.LdapLoginFieldName} + + ); + } else { + buttonText = ( + + ); + } + + return ( + + ); + } + + return null; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + button: { + borderRadius: 3, + borderColor: theme.buttonBg, + alignItems: 'center', + borderWidth: 1, + alignSelf: 'stretch', + marginTop: 10, + padding: 15, + }, + buttonText: { + textAlign: 'center', + color: theme.buttonBg, + fontSize: 17, + }, +})); + +export default LdapOption; diff --git a/app/screens/login_options/office365.tsx b/app/screens/login_options/office365.tsx new file mode 100644 index 0000000000..4f5a6330fa --- /dev/null +++ b/app/screens/login_options/office365.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import Button from 'react-native-button'; + +import LocalConfig from '@assets/config.json'; +import FormattedText from '@components/formatted_text'; +import {View} from '@constants'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +const Office365Option = ({config, license, onPress, theme}: LoginOptionWithConfigAndLicenseProps) => { + const styles = getStyleSheet(theme); + const forceHideFromLocal = LocalConfig.HideO365LoginExperimental; + const o365Enabled = config.EnableSignUpWithOffice365 === 'true' && license.IsLicensed === 'true' && + license.Office365OAuth === 'true'; + + const handlePress = () => { + onPress(View.OFFICE365); + }; + + if (!forceHideFromLocal && o365Enabled) { + const additionalButtonStyle = { + backgroundColor: '#2389d7', + borderColor: 'transparent', + borderWidth: 0, + }; + + const textColor = 'white'; + + return ( + + ); + } + + return null; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + button: { + borderRadius: 3, + borderColor: theme.buttonBg, + alignItems: 'center', + borderWidth: 1, + alignSelf: 'stretch', + marginTop: 10, + padding: 15, + }, + buttonText: { + textAlign: 'center', + color: theme.buttonBg, + fontSize: 17, + }, +})); + +export default Office365Option; diff --git a/app/screens/login_options/open_id.tsx b/app/screens/login_options/open_id.tsx new file mode 100644 index 0000000000..c53cb69701 --- /dev/null +++ b/app/screens/login_options/open_id.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import Button from 'react-native-button'; + +import FormattedText from '@components/formatted_text'; +import {View} from '@constants'; +import {isMinimumServerVersion} from '@utils/helpers'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +const OpenIdOption = ({config, license, onPress, theme}: LoginOptionWithConfigAndLicenseProps) => { + const styles = getStyleSheet(theme); + const openIdEnabled = config.EnableSignUpWithOpenId === 'true' && license.IsLicensed === 'true' && isMinimumServerVersion(config.Version, 5, 33, 0); + + const handlePress = () => { + onPress(View.OPENID); + }; + + if (openIdEnabled) { + const additionalButtonStyle = { + backgroundColor: config.OpenIdButtonColor || '#145DBF', + borderColor: 'transparent', + borderWidth: 0, + }; + + const textColor = 'white'; + + return ( + + ); + } + + return null; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + button: { + borderRadius: 3, + borderColor: theme.buttonBg, + alignItems: 'center', + borderWidth: 1, + alignSelf: 'stretch', + marginTop: 10, + padding: 15, + }, + buttonText: { + textAlign: 'center', + color: theme.buttonBg, + fontSize: 17, + }, +})); + +export default OpenIdOption; diff --git a/app/screens/login_options/saml.tsx b/app/screens/login_options/saml.tsx new file mode 100644 index 0000000000..8f711ee999 --- /dev/null +++ b/app/screens/login_options/saml.tsx @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text} from 'react-native'; +import Button from 'react-native-button'; + +import LocalConfig from '@assets/config.json'; +import {View} from '@constants'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +const SamlOption = ({config, license, onPress, theme}: LoginOptionWithConfigAndLicenseProps) => { + const styles = getStyleSheet(theme); + const forceHideFromLocal = LocalConfig.HideSAMLLoginExperimental; + const enabled = config.EnableSaml === 'true' && license.IsLicensed === 'true' && license.SAML === 'true'; + + const handlePress = () => { + onPress(View.SAML); + }; + + if (!forceHideFromLocal && enabled) { + const backgroundColor = config.SamlLoginButtonColor || '#34a28b'; + + const additionalStyle = { + backgroundColor, + borderColor: 'transparent', + borderWidth: 0, + }; + + if (config.SamlLoginButtonBorderColor) { + additionalStyle.borderColor = config.SamlLoginButtonBorderColor; + } + + const textColor = config.SamlLoginButtonTextColor || 'white'; + + return ( + + ); + } + + return null; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + button: { + borderRadius: 3, + borderColor: theme.buttonBg, + alignItems: 'center', + borderWidth: 1, + alignSelf: 'stretch', + marginTop: 10, + padding: 15, + }, + buttonText: { + textAlign: 'center', + color: theme.buttonBg, + fontSize: 17, + }, +})); + +export default SamlOption; diff --git a/app/screens/login_options/types.d.ts b/app/screens/login_options/types.d.ts new file mode 100644 index 0000000000..0e7fb5e809 --- /dev/null +++ b/app/screens/login_options/types.d.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type LoginOptionWithConfigProps = { + config: ClientConfig; + onPress: (type: string|GestureResponderEvent) => void | (() => void); + theme: Theme; +} + +type LoginOptionWithConfigAndLicenseProps = LoginOptionWithConfigProps & { + license: ClientLicense; +}; diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 744c524a05..44d9a27539 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -81,7 +81,7 @@ export function resetToChannel(passProps = {}) { } export function resetToSelectServer(allowOtherServers: boolean) { - const theme = Preferences.THEMES.default; + const theme = getThemeFromState(); EphemeralStore.clearNavigationComponents(); @@ -94,6 +94,7 @@ export function resetToSelectServer(allowOtherServers: boolean) { name: Screens.SERVER, passProps: { allowOtherServers, + theme, }, options: { layout: { diff --git a/app/screens/select_server/index.ts b/app/screens/select_server/index.ts deleted file mode 100644 index c54a28d21c..0000000000 --- a/app/screens/select_server/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {setLastUpgradeCheck} from '@actions/views/client_upgrade'; -import {loadConfigAndLicense} from '@actions/views/root'; -import {handleServerUrlChanged} from '@actions/views/select_server'; -import {scheduleExpiredNotification} from '@actions/views/session'; -import {getPing, resetPing, setServerVersion} from '@mm-redux/actions/general'; -import {login} from '@mm-redux/actions/users'; -import {getConfig, getLicense} from '@mm-redux/selectors/entities/general'; -import getClientUpgrade from '@selectors/client_upgrade'; - -import SelectServer from './select_server'; - -function mapStateToProps(state) { - const config = getConfig(state); - const license = getLicense(state); - const {currentVersion, latestVersion, minVersion} = getClientUpgrade(state); - - return { - ...state.views.selectServer, - config, - currentVersion, - deepLinkURL: state.views.root.deepLinkURL, - hasConfigAndLicense: Object.keys(config).length > 0 && Object.keys(license).length > 0, - latestVersion, - license, - minVersion, - }; -} - -function mapDispatchToProps(dispatch) { - return { - actions: bindActionCreators({ - getPing, - scheduleExpiredNotification, - handleServerUrlChanged, - loadConfigAndLicense, - login, - resetPing, - setLastUpgradeCheck, - setServerVersion, - }, dispatch), - }; -} - -//todo: Create HOC to pass extra props ( previously known as actions ) to the screen/component (mapDispatchToProps) -//todo: wrap screen/component with `withObservable` HOC for it to observe changes in the DB ( mapStateToProps ) - -export default connect(mapStateToProps, mapDispatchToProps)(SelectServer); diff --git a/app/screens/select_server/select_server.test.ts b/app/screens/select_server/select_server.test.ts deleted file mode 100644 index 69cdb9a699..0000000000 --- a/app/screens/select_server/select_server.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -import {fireEvent, waitFor} from '@testing-library/react-native'; -// See LICENSE.txt for license information. -import {renderWithReduxIntl} from 'test/testing_library'; - -import SelectServer from './select_server'; - -describe('SelectServer', () => { - const actions = { - getPing: jest.fn(), - handleServerUrlChanged: jest.fn(), - scheduleExpiredNotification: jest.fn(), - loadConfigAndLicense: jest.fn(), - login: jest.fn(), - resetPing: jest.fn(), - setLastUpgradeCheck: jest.fn(), - setServerVersion: jest.fn(), - }; - - const baseProps = { - actions, - hasConfigAndLicense: true, - serverUrl: '', - }; - - test('should match error when URL is empty string', async () => { - const {getByTestId, getByText} = renderWithReduxIntl( - ; - ) - - - const button = getByText('Connect'); - fireEvent.press(button); - - await waitFor(() => - expect(getByTestId('select_server.error.text')).toBeTruthy(), - ); - expect(getByText('Please enter a valid server URL')).toBeTruthy(); - }); - - test('should match error when URL is only spaces', async () => { - const {getByTestId, getByText} = renderWithReduxIntl( - ; - ) - - - const urlInput = getByTestId('select_server.server_url.input'); - fireEvent.changeText(urlInput, ' '); - - const button = getByText('Connect'); - fireEvent.press(button); - - await waitFor(() => - expect(getByTestId('select_server.error.text')).toBeTruthy(), - ); - expect(getByText('Please enter a valid server URL')).toBeTruthy(); - }); - - test('should match error when URL does not start with http:// or https://', async () => { - const {getByTestId, getByText} = renderWithReduxIntl( - ; - ) - - - const urlInput = getByTestId('select_server.server_url.input'); - fireEvent.changeText(urlInput, 'ht://invalid:8065'); - - const button = getByText('Connect'); - fireEvent.press(button); - - await waitFor(() => - expect(getByTestId('select_server.error.text')).toBeTruthy(), - ); - expect(getByText('URL must start with http:// or https://')).toBeTruthy(); - }); - - test('should not show error when valid URL is entered', async () => { - const {getByTestId, getByText, queryByTestId} = renderWithReduxIntl( - ; - ) - - - const urlInput = getByTestId('select_server.server_url.input'); - fireEvent.changeText(urlInput, 'http://localhost:8065'); - - const button = getByText('Connect'); - fireEvent.press(button); - - expect(queryByTestId('select_server.error.text')).toBeNull(); - await waitFor(() => expect(getByText('Connecting...')).toBeTruthy()); - }); -}); diff --git a/app/screens/select_server/select_server.tsx b/app/screens/select_server/select_server.tsx deleted file mode 100644 index 3cae1c8613..0000000000 --- a/app/screens/select_server/select_server.tsx +++ /dev/null @@ -1,668 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import mattermostBucket from '@app/mattermost_bucket'; -import {goToScreen, resetToChannel} from '@app/navigation'; -import {getClientUpgrade} from '@app/queries/helpers'; -import {handleServerUrlChanged, setLastUpgradeCheck} from '@app/requests/local/systems'; -import {loadConfigAndLicense} from '@app/requests/remote/systems'; -import {GlobalStyles} from '@app/styles'; -import telemetry from '@app/telemetry'; -import LocalConfig from '@assets/config.json'; -import {Client4} from '@client/rest'; -import AppVersion from '@components/app_version'; -import ErrorText from '@components/error_text'; -import FormattedText from '@components/formatted_text'; -import fetchConfig from '@init/fetch'; -import globalEventHandler from '@init/global_event_handler'; -import withObservables from '@nozbe/with-observables'; -import {getSystems} from '@queries/system'; -import {getPing} from '@requests/remote/general'; -import {scheduleExpiredNotification} from '@requests/remote/session'; -import System from '@typings/database/system'; -import {Styles} from '@typings/utils'; -import {checkUpgradeType, isUpgradeAvailable} from '@utils/client_upgrade'; -import {isMinimumServerVersion} from '@utils/helpers'; -import {t} from '@utils/i18n'; -import {preventDoubleTap} from '@utils/tap'; -import {changeOpacity} from '@utils/theme'; -import tracker from '@utils/time_tracker'; -import {isValidUrl, stripTrailingSlashes} from '@utils/url'; -import merge from 'deepmerge'; -import PropTypes from 'prop-types'; -import React, {PureComponent} from 'react'; -import {IntlShape, IntlContext} from 'react-intl'; -import { - ActivityIndicator, - Alert, - DeviceEventEmitter, - EmitterSubscription, - Image, - Keyboard, - KeyboardAvoidingView, - Platform, - StatusBar, - StyleSheet, - Text, - TextInput, - TouchableWithoutFeedback, - View, -} from 'react-native'; -import Button from 'react-native-button'; -import {EventSubscription, Navigation} from 'react-native-navigation'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import RNFetchBlob from 'rn-fetch-blob'; -import urlParse from 'url-parse'; - -//todo: Once you get a URL and a NAME for a server, you have to init a database and then set it as the currenly active server database. All subsequent calls/queries will use it. -//todo: review all substituted actions - -type SelectServerProps = { - systems: System[]; - allowOtherServers: boolean -}; - -type SelectServerState = { - connected: boolean; - connecting: boolean; - error: null | {} | undefined; - url: string; -}; - -const NEXT_SCREEN_TIMEOUT = 350; - -class SelectServer extends PureComponent { - static propTypes = { - actions: PropTypes.shape({ - login: PropTypes.func.isRequired, - scheduleExpiredNotification: PropTypes.func.isRequired, - setServerVersion: PropTypes.func.isRequired, - }).isRequired, - }; - - static defaultProps = { - allowOtherServers: true, - }; - - //todo: make into functional component in typescript => last resort a class component - - //fixme: relook at below definition for INTL context - you need to use modern Context API; not legacy one - static contextType = IntlContext; - - private cancelPing: (() => void | null) | undefined | null; - private navigationEventListener!: EventSubscription; - private certificateListener!: EmitterSubscription; - private sslProblemListener!: EmitterSubscription; - private textInput!: TextInput; - private nextScreenTimer!: NodeJS.Timeout; - - state: SelectServerState = { - connected: false, - connecting: false, - error: null, - url: '', - }; - - //fixme: do we need getDerivedStateFromProps ? - // static getDerivedStateFromProps(props: SelectServerProps, state: SelectServerState) { - // const {systems} = props; - // const rootRecord = systems.find((systemRecord: System) => systemRecord.name === 'root') as System; - // const {deepLinkURL} = rootRecord.value; - // - // if (state.url === undefined && props.allowOtherServers && deepLinkURL) { - // const url = urlParse(deepLinkURL).host; - // return {url}; - // } else if (state.url === undefined && props.serverUrl) { - // return {url: props.serverUrl}; - // } - // return null; - // } - - componentDidMount() { - this.navigationEventListener = Navigation.events().bindComponent(this); - const { - selectServer: {serverUrl}, - } = this.getSystemsValues(); - - const {allowOtherServers} = this.props; - if (!allowOtherServers && serverUrl) { - // If the app is managed or AutoSelectServerUrl is true in the Config, the server url is set and the user can't change it - // we automatically trigger the ping to move to the next screen - this.handleConnect(); - } - - if (Platform.OS === 'android') { - Keyboard.addListener('keyboardDidHide', this.blur); - } - - this.certificateListener = DeviceEventEmitter.addListener( - 'RNFetchBlobCertificate', - this.selectCertificate, - ); - this.sslProblemListener = DeviceEventEmitter.addListener( - 'RNFetchBlobSslProblem', - this.handleSslProblem, - ); - - telemetry.end(['start:select_server_screen']); - telemetry.save(); - } - - getSystemsValues = () => { - const {systems} = this.props; - const configRecord = systems.find((systemRecord: System) => systemRecord.name === 'config') as System; - const licenseRecord = systems.find((systemRecord: System) => systemRecord.name === 'license') as System; - const selectServerRecord = systems.find((systemRecord: System) => systemRecord.name === 'selectServer') as System; - const rootRecord = systems.find((systemRecord: System) => systemRecord.name === 'root') as System; - - return { - config: configRecord.value, - license: licenseRecord.value, - selectServer: selectServerRecord.value, - root: rootRecord.value, - records: { - configRecord, - licenseRecord, - selectServerRecord, - rootRecord, - }, - }; - }; - - //fixme: Is this life-cycle method necessary ? - componentDidUpdate(prevProps: SelectServerProps, prevState: SelectServerState) { - const {config, license, records: {configRecord}} = this.getSystemsValues(); - const hasConfigAndLicense = Object.keys(config).length > 0 && Object.keys(license).length > 0; - - //todo: need to recheck this logic here as we are retrieving hasConfigAndLicense from the database now - if (this.state.connected && hasConfigAndLicense && !(prevState.connected && hasConfigAndLicense)) { - if (LocalConfig.EnableMobileClientUpgrade) { - setLastUpgradeCheck(configRecord); - - const {currentVersion, minVersion = '', latestVersion = ''} = getClientUpgrade(config); - const upgradeType = checkUpgradeType(currentVersion, minVersion, latestVersion); - - if (isUpgradeAvailable(upgradeType)) { - this.handleShowClientUpgrade(upgradeType); - } else { - this.handleLoginOptions(); - } - } else { - this.handleLoginOptions(); - } - } - } - - componentWillUnmount() { - if (Platform.OS === 'android') { - Keyboard.removeListener('keyboardDidHide', this.blur); - } - - this.certificateListener?.remove(); - this.sslProblemListener?.remove(); - this.navigationEventListener?.remove(); - clearTimeout(this.nextScreenTimer); - } - - componentDidDisappear() { - this.setState({ - connected: false, - }); - } - - getUrl = async (serverUrl?: string, useHttp = false) => { - let url = this.sanitizeUrl(serverUrl, useHttp); - - try { - const resp = await fetch(url, {method: 'HEAD'}); - if (resp?.rnfbRespInfo?.redirects?.length) { - url = resp.rnfbRespInfo.redirects[resp.rnfbRespInfo.redirects.length - 1]; - } - } catch { - // do nothing - } - - return this.sanitizeUrl(url, useHttp); - }; - - goToNextScreen = (screen: string, title: string, passProps = {}, navOptions = {}) => { - const {allowOtherServers} = this.props; - let visible = !LocalConfig.AutoSelectServerUrl; - - if (!allowOtherServers) { - visible = false; - } - - const defaultOptions = { - popGesture: visible, - topBar: { - visible, - height: visible ? null : 0, - }, - }; - const options = merge(defaultOptions, navOptions); - - goToScreen(screen, title, passProps, options); - }; - - blur = () => { - if (this.textInput) { - this.textInput.blur(); - } - }; - - handleLoginOptions = async () => { - const {formatMessage} = this.context.intl; - const {config, license, selectServer: {serverUrl = ''}} = this.getSystemsValues(); - - const { - EnableSaml, - EnableSignUpWithGitLab, - EnableSignUpWithGoogle, - EnableSignUpWithOffice365, - EnableSignUpWithOpenId, - Version, - ExperimentalClientSideCertEnable, - ExperimentalClientSideCertCheck, - } = config; - - const {IsLicensed, SAML, Office365OAuth} = license; - - const samlEnabled = EnableSaml === 'true' && IsLicensed === 'true' && SAML === 'true'; - const gitlabEnabled = EnableSignUpWithGitLab === 'true'; - const googleEnabled = EnableSignUpWithGoogle === 'true' && IsLicensed === 'true'; - const o365Enabled = EnableSignUpWithOffice365 === 'true' && IsLicensed === 'true' && Office365OAuth === 'true'; - const openIdEnabled = EnableSignUpWithOpenId === 'true' && IsLicensed === 'true' && isMinimumServerVersion(Version, 5, 33, 0); - - let options = 0; - if (samlEnabled || gitlabEnabled || googleEnabled || o365Enabled || openIdEnabled) { - options += 1; - } - - let screen: string; - let title: string; - if (options) { - screen = 'LoginOptions'; - title = formatMessage({id: 'mobile.routes.loginOptions', defaultMessage: 'Login Chooser'}); - } else { - screen = 'Login'; - title = formatMessage({id: 'mobile.routes.login', defaultMessage: 'Login'}); - } - - //todo: confirm if we should pass in the serverUrl here - await globalEventHandler.configureAnalytics(serverUrl); - - if (Platform.OS === 'ios') { - if (ExperimentalClientSideCertEnable === 'true' && ExperimentalClientSideCertCheck === 'primary') { - // log in automatically and send directly to the channel screen - this.loginWithCertificate(); - return; - } - - this.nextScreenTimer = setTimeout(() => { - this.goToNextScreen(screen, title); - }, NEXT_SCREEN_TIMEOUT); - } else { - this.goToNextScreen(screen, title); - } - }; - - handleShowClientUpgrade = (upgradeType: string) => { - const {formatMessage} = this.context.intl; - const screen = 'ClientUpgrade'; - const title = formatMessage({ - id: 'mobile.client_upgrade', - defaultMessage: 'Client Upgrade', - }); - const passProps = { - closeAction: this.handleLoginOptions, - upgradeType, - }; - const options = { - statusBar: { - visible: false, - }, - }; - - this.goToNextScreen(screen, title, passProps, options); - }; - - handleTextChanged = (url: string) => { - this.setState({url}); - }; - - inputRef = (ref: TextInput) => { - this.textInput = ref; - }; - - loginWithCertificate = async () => { - tracker.initialLoad = Date.now(); - const {intl} = this.context; - await this.props.actions.login('credential', 'password'); - - scheduleExpiredNotification(intl); - - resetToChannel(); - }; - - pingServer = async (url: string, retryWithHttp = true) => { - const { - setServerVersion, - } = this.props.actions; - - this.setState({ - connected: false, - connecting: true, - error: null, - }); - - let cancel = false; - this.cancelPing = () => { - cancel = true; - - this.setState({ - connected: false, - connecting: false, - }); - - this.cancelPing = null; - }; - - const serverUrl = await this.getUrl(url, !retryWithHttp); - Client4.setUrl(serverUrl); - - const {records: {configRecord, licenseRecord, selectServerRecord}} = this.getSystemsValues(); - handleServerUrlChanged({serverUrl, configRecord, licenseRecord, selectServerRecord}); - - try { - const result = await getPing(); - - if (cancel) { - return; - } - - if (result.error && retryWithHttp) { - const nurl = serverUrl.replace('https:', 'http:'); - this.pingServer(nurl, false); - return; - } - - if (!result.error) { - loadConfigAndLicense(); - setServerVersion(Client4.getServerVersion()); - } - - this.setState({ - connected: !result.error, - connecting: false, - error: result.error, - }); - } catch { - if (cancel) { - return; - } - - this.setState({ - connecting: false, - }); - } - }; - - sanitizeUrl = (url: string, useHttp = false) => { - let preUrl = urlParse(url, true); - let protocol = preUrl.protocol; - - if (!preUrl.host || preUrl.protocol === 'file:') { - preUrl = urlParse('https://' + stripTrailingSlashes(url), true); - } - - if (preUrl.protocol === 'http:' && !useHttp) { - protocol = 'https:'; - } - - return stripTrailingSlashes( - `${protocol}//${preUrl.host}${preUrl.pathname}`, - ); - }; - - handleConnect = preventDoubleTap(async () => { - Keyboard.dismiss(); - const {connecting, connected, url} = this.state; - if (connecting || connected) { - this.cancelPing?.(); - return; - } - - if (!url || url.trim() === '') { - this.setState({ - error: { - intl: { - id: t('mobile.server_url.empty'), - defaultMessage: 'Please enter a valid server URL', - }, - }, - }); - - return; - } - - if (!isValidUrl(this.sanitizeUrl(url))) { - this.setState({ - error: { - intl: { - id: t('mobile.server_url.invalid_format'), - defaultMessage: 'URL must start with http:// or https://', - }, - }, - }); - - return; - } - - await globalEventHandler.resetState(); - - //todo: create a ticket for this part - //fixme: ExperimentalClientSideCertEnable does not exist in LocalConfig...do we add it ? - if (LocalConfig.ExperimentalClientSideCertEnable && Platform.OS === 'ios') { - RNFetchBlob.cba.selectCertificate((certificate) => { - if (certificate) { - mattermostBucket.setPreference('cert', certificate); - window.fetch = new RNFetchBlob.polyfill.Fetch({ - auto: true, - certificate, - }).build(); - this.pingServer(url); - } - }); - } else { - this.pingServer(url); - } - }); - - handleSslProblem = () => { - const {connecting, connected, url} = this.state; - - if (!connecting && !connected) { - return null; - } - - this.cancelPing?.(); - - const host = urlParse(url, true).host || url; - - const {formatMessage} = this.context.intl; - Alert.alert( - formatMessage({ - id: 'mobile.server_ssl.error.title', - defaultMessage: 'Untrusted Certificate', - }), - formatMessage( - { - id: 'mobile.server_ssl.error.text', - defaultMessage: 'The certificate from {host} is not trusted.\n\nPlease contact your System Administrator to resolve the certificate issues and allow connections to this server.', - }, - { - host, - }, - ), - [{text: 'OK'}], - {cancelable: false}, - ); - - return null; - }; - - selectCertificate = async () => { - //fixme: how does this work ? - const url = await this.getUrl(); - RNFetchBlob.cba.selectCertificate((certificate) => { - if (certificate) { - mattermostBucket.setPreference('cert', certificate); - fetchConfig().then(() => { - this.pingServer(url, true); - }); - } - }); - }; - - render() { - const {formatMessage} = this.context.intl; - const {allowOtherServers} = this.props; - const {connected, connecting, error, url} = this.state; - - let buttonIcon; - let buttonText; - if (connected || connecting) { - buttonIcon = ( - - ); - buttonText = ( - - ); - } else { - buttonText = ( - - ); - } - - const barStyle = Platform.OS === 'android' ? 'light-content' : 'dark-content'; - - const inputDisabled = !allowOtherServers || connected || connecting; - - const inputStyle: Styles[] = [GlobalStyles.inputBox]; - if (inputDisabled) { - inputStyle.push(style.disabledInput); - } - - return ( - - - - - - - - - - - - - - - - - - - - - ); - } -} - -const style = StyleSheet.create({ - container: { - flex: 1, - }, - disabledInput: { - backgroundColor: '#e3e3e3', - }, - connectButton: { - alignItems: 'center', - }, - connectingIndicator: { - marginRight: 5, - }, -}); - -const withObserver = withObservables([], async () => { - return { - systems: getSystems(), - }; -}); - -export default withObserver(SelectServer); diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index 9d663ac3d2..e018972a7f 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -1,105 +1,334 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Colors, DebugInstructions, LearnMoreLinks, ReloadInstructions} from 'react-native/Libraries/NewAppScreen'; -import {SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, View} from 'react-native'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import { + ActivityIndicator, EventSubscription, Image, Keyboard, KeyboardAvoidingView, + Platform, StatusBar, StyleSheet, TextInput, TouchableWithoutFeedback, View, +} from 'react-native'; +import Button from 'react-native-button'; +import {useManagedConfig} from '@mattermost/react-native-emm'; +import {NavigationFunctionComponent} from 'react-native-navigation'; +import {SafeAreaView} from 'react-native-safe-area-context'; -import React from 'react'; +import LocalConfig from '@assets/config.json'; +import AppVersion from '@components/app_version'; +import ErrorText, {ClientErrorWithIntl} from '@components/error_text'; +import FormattedText from '@components/formatted_text'; import {Screens} from '@constants'; +import {doPing, fetchConfigAndLicense} from '@requests/remote/general'; import {goToScreen} from '@screens/navigation'; +import {isMinimumServerVersion} from '@utils/helpers'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {getServerUrlAfterRedirect, isValidUrl, sanitizeUrl} from '@utils/url'; -declare const global: { HermesInternal: null | {} }; +type ServerProps = { + componentId: string; + theme: Theme; +} + +let cancelPing: undefined | (() => void); + +const Server: NavigationFunctionComponent = ({theme}: ServerProps) => { + const intl = useIntl(); + const managedConfig = useManagedConfig(); + const input = useRef(null); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(); + const [url, setUrl] = useState(''); + const styles = getStyleSheet(theme); + const {formatMessage} = intl; + + const displayLogin = (config: ClientConfig, license: ClientLicense) => { + const samlEnabled = config.EnableSaml === 'true' && license.IsLicensed === 'true' && license.SAML === 'true'; + const gitlabEnabled = config.EnableSignUpWithGitLab === 'true'; + const googleEnabled = config.EnableSignUpWithGoogle === 'true' && license.IsLicensed === 'true'; + const o365Enabled = config.EnableSignUpWithOffice365 === 'true' && license.IsLicensed === 'true' && license.Office365OAuth === 'true'; + const openIdEnabled = config.EnableSignUpWithOpenId === 'true' && license.IsLicensed === 'true' && isMinimumServerVersion(config.Version, 5, 33, 0); + + let screen = Screens.LOGIN; + let title = formatMessage({id: 'mobile.routes.login', defaultMessage: 'Login'}); + if (samlEnabled || gitlabEnabled || googleEnabled || o365Enabled || openIdEnabled) { + screen = Screens.LOGIN_OPTIONS; + title = formatMessage({id: 'mobile.routes.loginOptions', defaultMessage: 'Login Chooser'}); + } + + const {allowOtherServers} = managedConfig; + let visible = !LocalConfig.AutoSelectServerUrl; + + if (!allowOtherServers) { + visible = false; + } + + const defaultOptions = { + popGesture: visible, + topBar: { + visible, + height: visible ? null : 0, + }, + }; + + goToScreen(screen, title, {config, license, theme}, defaultOptions); + setConnecting(false); + }; + + const handleConnect = preventDoubleTap(() => { + if (connecting && cancelPing) { + cancelPing(); + return; + } + + if (!url || url.trim() === '') { + setError(intl.formatMessage({ + id: 'mobile.server_url.empty', + defaultMessage: 'Please enter a valid server URL', + })); + + return; + } + + const serverUrl = sanitizeUrl(url); + if (!isValidUrl(serverUrl)) { + setError(formatMessage({ + id: 'mobile.server_url.invalid_format', + defaultMessage: 'URL must start with http:// or https://', + })); + + return; + } + + pingServer(serverUrl); + }); + + const pingServer = async (pingUrl: string, retryWithHttp = true) => { + let canceled = false; + setConnecting(true); + setError(undefined); + + cancelPing = () => { + // We should not need this once we have the cancelable network-client library + canceled = true; + setConnecting(false); + cancelPing = undefined; + }; + + const serverUrl = await getServerUrlAfterRedirect(pingUrl, !retryWithHttp); + + const result = await doPing(serverUrl); + + if (canceled) { + return; + } + + if (result.error && retryWithHttp) { + const nurl = serverUrl.replace('https:', 'http:'); + pingServer(nurl, false); + return; + } else if (result.error) { + setError(result.error); + setConnecting(false); + return; + } + + const data = await fetchConfigAndLicense(); + if (data.error) { + setError(data.error); + setConnecting(false); + return; + } + + displayLogin(data.config!, data.license!); + }; + + const blur = useCallback(() => { + input.current?.blur(); + }, []); + + const handleTextChanged = useCallback((text) => { + setUrl(text); + }, []); + + useEffect(() => { + let listener: EventSubscription; + if (Platform.OS === 'android') { + listener = Keyboard.addListener('keyboardDidHide', blur); + } + + return () => listener?.remove(); + }, []); + + let buttonIcon; + let buttonText; + + if (connecting) { + buttonIcon = ( + + ); + buttonText = ( + + ); + } else { + buttonText = ( + + ); + } + + const barStyle = Platform.OS === 'android' ? 'light-content' : 'dark-content'; + const inputDisabled = managedConfig.allowOtherServers === 'false' || connecting; + + const inputStyle = [styles.inputBox]; + if (inputDisabled) { + inputStyle.push(styles.disabledInput); + } -const App = () => { return ( - <> - - - + + + - {global.HermesInternal == null ? null : ( - - {'Engine: Hermes'} + + + + + - )} - - - {'Step One'} - - {'Edit '} - {'screens/server/index.tsx'}{' to change this'} - {'XXXXXscreen and then come back to see your edits.'} - + + + {Boolean(error) && + + - - goToScreen(Screens.CHANNEL, 'Channel')} - >{'See Your Changes'} - - - - - - {'Debug'} - - - - - - {'Learn More'} - - {'Read the docs to discover what to do next:'} - - - + } - - - + + + + ); }; -const styles = StyleSheet.create({ - scrollView: { - backgroundColor: Colors.lighter, +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flex: 1, + backgroundColor: theme.centerChannelBg, }, - engine: { - right: 0, + flex: { + flex: 1, }, - body: { - backgroundColor: Colors.white, + formContainer: { + flex: 1, + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + paddingRight: 15, + paddingLeft: 15, }, - sectionContainer: { - marginTop: 32, - paddingHorizontal: 24, + disabledInput: { + backgroundColor: '#e3e3e3', }, - sectionTitle: { - fontSize: 24, - fontWeight: '600', - color: Colors.black, + connectButton: { + borderRadius: 3, + borderColor: theme.buttonBg, + alignItems: 'center', + borderWidth: 1, + alignSelf: 'stretch', + marginTop: 10, + padding: 15, }, - sectionDescription: { - marginTop: 8, - fontSize: 18, + connectingIndicator: { + marginRight: 5, + }, + inputBox: { + fontSize: 16, + height: 45, + borderColor: theme.centerChannelColor, + borderWidth: 1, + marginTop: 5, + marginBottom: 5, + paddingLeft: 10, + alignSelf: 'stretch', + borderRadius: 3, + color: theme.centerChannelColor, + }, + logo: { + height: 72, + resizeMode: 'contain', + }, + header: { + textAlign: 'center', + marginTop: 15, + marginBottom: 15, + fontSize: 20, fontWeight: '400', - color: Colors.dark, }, - highlight: { - fontWeight: '700', + connectText: { + textAlign: 'center', + color: theme.buttonBg, + fontSize: 17, }, - footer: { - color: Colors.dark, - fontSize: 12, - fontWeight: '600', - padding: 4, - paddingRight: 12, - textAlign: 'right', - }, -}); +})); -export default App; +export default Server; diff --git a/app/utils/general/index.ts b/app/utils/general/index.ts new file mode 100644 index 0000000000..e51f6b08d5 --- /dev/null +++ b/app/utils/general/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export function emptyErrorHandlingFunction() { // eslint-disable-line no-empty-function, @typescript-eslint/no-unused-vars +} + +export function emptyFunction() { // eslint-disable-line no-empty-function +} diff --git a/app/utils/helpers.ts b/app/utils/helpers.ts index d8d2885fd7..6f40fc6b81 100644 --- a/app/utils/helpers.ts +++ b/app/utils/helpers.ts @@ -43,3 +43,22 @@ export const isMinimumServerVersion = (currentVersion: string, minMajorVersion = // Dot version is equal return true; }; + +export function buildQueryString(parameters: Dictionary): string { + const keys = Object.keys(parameters); + if (keys.length === 0) { + return ''; + } + + let query = '?'; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + query += key + '=' + encodeURIComponent(parameters[key]); + + if (i < keys.length - 1) { + query += '&'; + } + } + + return query; +} diff --git a/app/utils/markdown/index.ts b/app/utils/markdown/index.ts new file mode 100644 index 0000000000..1cdf2b63ce --- /dev/null +++ b/app/utils/markdown/index.ts @@ -0,0 +1,207 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Platform, StyleSheet} from 'react-native'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +export function getCodeFont() { + return Platform.OS === 'ios' ? 'Menlo' : 'monospace'; +} + +export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme: Theme) => { + const codeFont = getCodeFont(); + + return { + emph: { + fontStyle: 'italic', + }, + strong: { + fontWeight: 'bold', + }, + del: { + textDecorationLine: 'line-through', + }, + link: { + color: theme.linkColor, + }, + heading1: { + fontSize: 17, + fontWeight: '700', + lineHeight: 25, + }, + heading1Text: { + paddingBottom: 8, + }, + heading2: { + fontSize: 17, + fontWeight: '700', + lineHeight: 25, + }, + heading2Text: { + paddingBottom: 8, + }, + heading3: { + fontSize: 17, + fontWeight: '700', + lineHeight: 25, + }, + heading3Text: { + paddingBottom: 8, + }, + heading4: { + fontSize: 17, + fontWeight: '700', + lineHeight: 25, + }, + heading4Text: { + paddingBottom: 8, + }, + heading5: { + fontSize: 17, + fontWeight: '700', + lineHeight: 25, + }, + heading5Text: { + paddingBottom: 8, + }, + heading6: { + fontSize: 17, + fontWeight: '700', + lineHeight: 25, + }, + heading6Text: { + paddingBottom: 8, + }, + code: { + alignSelf: 'center', + backgroundColor: changeOpacity(theme.centerChannelColor, 0.07), + fontFamily: codeFont, + }, + codeBlock: { + fontFamily: codeFont, + }, + mention: { + color: theme.linkColor, + }, + error: { + color: theme.errorTextColor, + }, + table_header_row: { + fontWeight: '700', + }, + mention_highlight: { + backgroundColor: theme.mentionHighlightBg, + color: theme.mentionHighlightLink, + }, + }; +}); + +export const getMarkdownBlockStyles = makeStyleSheetFromTheme((theme: Theme) => { + return { + adjacentParagraph: { + marginTop: 6, + }, + horizontalRule: { + backgroundColor: theme.centerChannelColor, + height: StyleSheet.hairlineWidth, + marginVertical: 10, + }, + quoteBlockIcon: { + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + }; +}); + +const languages: Record = { + actionscript: 'ActionScript', + applescript: 'AppleScript', + bash: 'Bash', + clojure: 'Clojure', + coffeescript: 'CoffeeScript', + cpp: 'C++', + cs: 'C#', + css: 'CSS', + d: 'D', + dart: 'Dart', + delphi: 'Delphi', + diff: 'Diff', + django: 'Django', + dockerfile: 'Dockerfile', + elixir: 'Elixir', + erlang: 'Erlang', + fortran: 'Fortran', + fsharp: 'F#', + gcode: 'G-code', + go: 'Go', + groovy: 'Groovy', + handlebars: 'Handlebars', + haskell: 'Haskell', + haxe: 'Haxe', + html: 'HTML', + java: 'Java', + javascript: 'JavaScript', + json: 'JSON', + julia: 'Julia', + kotlin: 'Kotlin', + latex: 'LaTeX', + less: 'Less', + lisp: 'Lisp', + lua: 'Lua', + makefile: 'Makefile', + markdown: 'Markdown', + matlab: 'Matlab', + objectivec: 'Objective-C', + ocaml: 'OCaml', + perl: 'Perl', + php: 'PHP', + powershell: 'PowerShell', + puppet: 'Puppet', + python: 'Python', + r: 'R', + ruby: 'Ruby', + rust: 'Rust', + scala: 'Scala', + scheme: 'Scheme', + scss: 'SCSS', + smalltalk: 'Smalltalk', + sql: 'SQL', + swift: 'Swift', + tex: 'TeX', + vbnet: 'VB.NET', + vbscript: 'VBScript', + verilog: 'Verilog', + xml: 'XML', + yaml: 'YAML', +}; + +export function getDisplayNameForLanguage(language: string) { + return languages[language.toLowerCase()] || ''; +} + +export function escapeRegex(text: string) { + return text.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); +} + +export function switchKeyboardForCodeBlocks(value: string, cursorPosition: number) { + if (Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 12) { + const regexForCodeBlock = /^```$(.*?)^```$|^```$(.*)/gms; + + const matches = []; + let nextMatch; + while ((nextMatch = regexForCodeBlock.exec(value)) !== null) { + matches.push({ + startOfMatch: regexForCodeBlock.lastIndex - nextMatch[0].length, + endOfMatch: regexForCodeBlock.lastIndex + 1, + }); + } + + const cursorIsInsideCodeBlock = matches.some((match) => cursorPosition >= match.startOfMatch && cursorPosition <= match.endOfMatch); + + // 'email-address' keyboardType prevents iOS emdash autocorrect + if (cursorIsInsideCodeBlock) { + return 'email-address'; + } + } + + return 'default'; +} diff --git a/app/utils/theme/index.ts b/app/utils/theme/index.ts index 08e695dda8..de0af17c7d 100644 --- a/app/utils/theme/index.ts +++ b/app/utils/theme/index.ts @@ -4,9 +4,9 @@ import {StyleSheet} from 'react-native'; import tinyColor from 'tinycolor2'; -import * as ThemeUtils from '@mm-redux/utils/theme_utils'; +import {mergeNavigationOptions} from '@screens/navigation'; -import {mergeNavigationOptions} from 'app/actions/navigation'; +import type {Options} from 'react-native-navigation'; const MODAL_SCREENS_WITHOUT_BACK = [ 'AddReaction', @@ -24,22 +24,74 @@ const MODAL_SCREENS_WITHOUT_BACK = [ 'UserProfile', ]; -export function makeStyleSheetFromTheme(getStyleFromTheme) { - return ThemeUtils.makeStyleFromTheme((theme) => { - return StyleSheet.create(getStyleFromTheme(theme)); - }); +const rgbPattern = /^rgba?\((\d+),(\d+),(\d+)(?:,([\d.]+))?\)$/; + +export function getComponents(inColor: string): {red: number; green: number; blue: number; alpha: number} { + let color = inColor; + + // RGB color + const match = rgbPattern.exec(color); + if (match) { + return { + red: parseInt(match[1], 10), + green: parseInt(match[2], 10), + blue: parseInt(match[3], 10), + alpha: match[4] ? parseFloat(match[4]) : 1, + }; + } + + // Hex color + if (color[0] === '#') { + color = color.slice(1); + } + + if (color.length === 3) { + const tempColor = color; + color = ''; + + color += tempColor[0] + tempColor[0]; + color += tempColor[1] + tempColor[1]; + color += tempColor[2] + tempColor[2]; + } + + return { + red: parseInt(color.substring(0, 2), 16), + green: parseInt(color.substring(2, 4), 16), + blue: parseInt(color.substring(4, 6), 16), + alpha: 1, + }; } -export const changeOpacity = ThemeUtils.changeOpacity; +export function makeStyleSheetFromTheme(getStyleFromTheme: (a: any) => any): (a: any) => any { + let lastTheme: any; + let style: any; + return (theme: any) => { + if (!style || theme !== lastTheme) { + style = StyleSheet.create(getStyleFromTheme(theme)); + lastTheme = theme; + } -export const blendColors = ThemeUtils.blendColors; + return style; + }; +} -export function concatStyles(...styles) { +export function changeOpacity(oldColor: string, opacity: number): string { + const { + red, + green, + blue, + alpha, + } = getComponents(oldColor); + + return `rgba(${red},${green},${blue},${alpha * opacity})`; +} + +export function concatStyles(...styles: any) { return [].concat(styles); } -export function setNavigatorStyles(componentId, theme) { - const options = { +export function setNavigatorStyles(componentId: string, theme: Theme) { + const options: Options = { topBar: { title: { color: theme.sidebarHeaderTextColor, @@ -55,7 +107,7 @@ export function setNavigatorStyles(componentId, theme) { }, }; - if (!MODAL_SCREENS_WITHOUT_BACK.includes(componentId)) { + if (!MODAL_SCREENS_WITHOUT_BACK.includes(componentId) && options.topBar) { options.topBar.backButton = { color: theme.sidebarHeaderTextColor, }; @@ -64,17 +116,12 @@ export function setNavigatorStyles(componentId, theme) { mergeNavigationOptions(componentId, options); } -export function isThemeSwitchingEnabled(state) { - const {config} = state.entities.general; - return config.EnableThemeSelection === 'true'; -} - -export function getKeyboardAppearanceFromTheme(theme) { +export function getKeyboardAppearanceFromTheme(theme: Theme) { return tinyColor(theme.centerChannelBg).isLight() ? 'light' : 'dark'; } -export function hexToHue(hexColor) { - let {red, green, blue} = ThemeUtils.getComponents(hexColor); +export function hexToHue(hexColor: string) { + let {red, green, blue} = getComponents(hexColor); red /= 255; green /= 255; blue /= 255; diff --git a/app/utils/url/index.ts b/app/utils/url/index.ts index 918a1d92ad..247bc3d424 100644 --- a/app/utils/url/index.ts +++ b/app/utils/url/index.ts @@ -2,15 +2,13 @@ // See LICENSE.txt for license information. import {Linking} from 'react-native'; +import urlParse from 'url-parse'; -import {latinise} from './latinise.js'; -import {escapeRegex} from './markdown'; - -import {Files} from '@mm-redux/constants'; -import {getCurrentServerUrl} from '@init/credentials'; - -import {DeepLinkTypes} from '@constants'; +import {DeepLink, Files} from '@constants'; import {emptyErrorHandlingFunction, emptyFunction} from '@utils/general'; +import {escapeRegex} from '@utils/markdown'; + +import {latinise} from './latinise'; const ytRegex = /(?:http|https):\/\/(?:www\.|m\.)?(?:(?:youtube\.com\/(?:(?:v\/)|(?:(?:watch|embed\/watch)(?:\/|.*v=))|(?:embed\/)|(?:user\/[^/]+\/u\/[0-9]\/)))|(?:youtu\.be\/))([^#&?]*)/; @@ -19,6 +17,38 @@ export function isValidUrl(url = '') { return regex.test(url); } +export function sanitizeUrl(url: string, useHttp = false) { + let preUrl = urlParse(url, true); + let protocol = preUrl.protocol; + + if (!preUrl.host || preUrl.protocol === 'file:') { + preUrl = urlParse('https://' + stripTrailingSlashes(url), true); + } + + if (!protocol || (preUrl.protocol === 'http:' && !useHttp)) { + protocol = 'https:'; + } + + return stripTrailingSlashes( + `${protocol}//${preUrl.host}${preUrl.pathname}`, + ); +} + +export async function getServerUrlAfterRedirect(serverUrl: string, useHttp = false) { + let url = sanitizeUrl(serverUrl, useHttp); + + try { + const resp = await fetch(url, {method: 'HEAD'}); + if (resp.redirected) { + url = resp.url; + } + } catch { + // do nothing + } + + return sanitizeUrl(url, useHttp); +} + export function stripTrailingSlashes(url = '') { return url.replace(/ /g, '').replace(/^\/+/, '').replace(/\/+$/, ''); } @@ -27,7 +57,7 @@ export function removeProtocol(url = '') { return url.replace(/(^\w+:|^)\/\//, ''); } -export function extractFirstLink(text) { +export function extractFirstLink(text: string) { const pattern = /(^|[\s\n]|)((?:https?|ftp):\/\/[-A-Z0-9+\u0026\u2019@#/%?=()~_|!:,.;]*[-A-Z0-9+\u0026@#/%=~()_|])/i; let inText = text; @@ -45,11 +75,11 @@ export function extractFirstLink(text) { return ''; } -export function isYoutubeLink(link) { +export function isYoutubeLink(link: string) { return link.trim().match(ytRegex); } -export function isImageLink(link) { +export function isImageLink(link: string) { let linkWithoutQuery = link; if (link.indexOf('?') !== -1) { linkWithoutQuery = linkWithoutQuery.split('?')[0]; @@ -68,7 +98,7 @@ export function isImageLink(link) { // Converts the protocol of a link (eg. http, ftp) to be lower case since // Android doesn't handle uppercase links. -export function normalizeProtocol(url) { +export function normalizeProtocol(url: string) { const index = url.indexOf(':'); if (index === -1) { // There's no protocol on the link to be normalized @@ -87,7 +117,7 @@ export function getShortenedURL(url = '', getLength = 27) { return url + '/'; } -export function cleanUpUrlable(input) { +export function cleanUpUrlable(input: string) { let cleaned = latinise(input); cleaned = cleaned.trim().replace(/-/g, ' ').replace(/[^\w\s]/gi, '').toLowerCase().replace(/\s/g, '-'); cleaned = cleaned.replace(/-{2,}/, '-'); @@ -96,7 +126,7 @@ export function cleanUpUrlable(input) { return cleaned; } -export function getScheme(url) { +export function getScheme(url: string) { const match = (/([a-z0-9+.-]+):/i).exec(url); return match && match[1]; @@ -104,7 +134,7 @@ export function getScheme(url) { export const PERMALINK_GENERIC_TEAM_NAME_REDIRECT = '_redirect'; -export function matchDeepLink(url, serverURL, siteURL) { +export function matchDeepLink(url?: string, serverURL?: string, siteURL?: string) { if (!url || (!serverURL && !siteURL)) { return null; } @@ -126,28 +156,28 @@ export function matchDeepLink(url, serverURL, siteURL) { match = new RegExp(linkRoot + '\\/([^\\/]+)\\/channels\\/(\\S+)').exec(urlToMatch); if (match) { - return {type: DeepLinkTypes.CHANNEL, teamName: match[1], channelName: match[2]}; + return {type: DeepLink.CHANNEL, teamName: match[1], channelName: match[2]}; } match = new RegExp(linkRoot + '\\/([^\\/]+)\\/pl\\/(\\w+)').exec(urlToMatch); if (match) { - return {type: DeepLinkTypes.PERMALINK, teamName: match[1], postId: match[2]}; + return {type: DeepLink.PERMALINK, teamName: match[1], postId: match[2]}; } match = new RegExp(linkRoot + '\\/([^\\/]+)\\/messages\\/@(\\S+)').exec(urlToMatch); if (match) { - return {type: DeepLinkTypes.DMCHANNEL, teamName: match[1], userName: match[2]}; + return {type: DeepLink.DM, teamName: match[1], userName: match[2]}; } match = new RegExp(linkRoot + '\\/([^\\/]+)\\/messages\\/(\\S+)').exec(urlToMatch); if (match) { - return {type: DeepLinkTypes.GROUPCHANNEL, teamName: match[1], id: match[2]}; + return {type: DeepLink.GM, teamName: match[1], id: match[2]}; } return null; } -export function getYouTubeVideoId(link) { +export function getYouTubeVideoId(link: string) { // https://youtube.com/watch?v= let match = (/youtube\.com\/watch\?\S*\bv=([a-zA-Z0-9_-]{6,11})/g).exec(link); if (match) { @@ -169,25 +199,58 @@ export function getYouTubeVideoId(link) { return ''; } -export async function getURLAndMatch(href, serverURL, siteURL) { - const url = normalizeProtocol(href); - - if (!url) { - return {}; - } - - let serverUrl = serverURL; - if (!serverUrl) { - serverUrl = await getCurrentServerUrl(); - } - - const match = matchDeepLink(url, serverURL, siteURL); - - return {url, match}; -} - -export function tryOpenURL(url, onError = emptyErrorHandlingFunction, onSuccess = emptyFunction) { +export function tryOpenURL(url: string, onError = emptyErrorHandlingFunction, onSuccess = emptyFunction) { Linking.openURL(url). then(onSuccess). catch(onError); } + +// Given a URL from an API request, return a URL that has any parts removed that are either sensitive or that would +// prevent properly grouping the messages in Sentry. +export function cleanUrlForLogging(baseUrl: string, apiUrl: string): string { + let url = apiUrl; + + // Trim the host name + url = url.substring(baseUrl.length); + + // Filter the query string + const index = url.indexOf('?'); + if (index !== -1) { + url = url.substring(0, index); + } + + // A non-exhaustive whitelist to exclude parts of the URL that are unimportant (eg IDs) or may be sentsitive + // (eg email addresses). We prefer filtering out fields that aren't recognized because there should generally + // be enough left over for debugging. + // + // Note that new API routes don't need to be added here since this shouldn't be happening for newly added routes. + const whitelist = [ + 'api', 'v4', 'users', 'teams', 'scheme', 'name', 'members', 'channels', 'posts', 'reactions', 'commands', + 'files', 'preferences', 'hooks', 'incoming', 'outgoing', 'oauth', 'apps', 'emoji', 'brand', 'image', + 'data_retention', 'jobs', 'plugins', 'roles', 'system', 'timezones', 'schemes', 'redirect_location', 'patch', + 'mfa', 'password', 'reset', 'send', 'active', 'verify', 'terms_of_service', 'login', 'logout', 'ids', + 'usernames', 'me', 'username', 'email', 'default', 'sessions', 'revoke', 'all', 'device', 'status', + 'search', 'switch', 'authorized', 'authorize', 'deauthorize', 'tokens', 'disable', 'enable', 'exists', 'unread', + 'invite', 'batch', 'stats', 'import', 'schemeRoles', 'direct', 'group', 'convert', 'view', 'search_autocomplete', + 'thread', 'info', 'flagged', 'pinned', 'pin', 'unpin', 'opengraph', 'actions', 'thumbnail', 'preview', 'link', + 'delete', 'logs', 'ping', 'config', 'client', 'license', 'websocket', 'webrtc', 'token', 'regen_token', + 'autocomplete', 'execute', 'regen_secret', 'policy', 'type', 'cancel', 'reload', 'environment', 's3_test', 'file', + 'caches', 'invalidate', 'database', 'recycle', 'compliance', 'reports', 'cluster', 'ldap', 'test', 'sync', 'saml', + 'certificate', 'public', 'private', 'idp', 'elasticsearch', 'purge_indexes', 'analytics', 'old', 'webapp', 'fake', + ]; + + url = url.split('/').map((part) => { + if (part !== '' && whitelist.indexOf(part) === -1) { + return ''; + } + + return part; + }).join('/'); + + if (index !== -1) { + // Add this on afterwards since it wouldn't pass the whitelist + url += '?'; + } + + return url; +} diff --git a/app/utils/url/latin_map.ts b/app/utils/url/latin_map.ts new file mode 100644 index 0000000000..4caf1a13fa --- /dev/null +++ b/app/utils/url/latin_map.ts @@ -0,0 +1,995 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable max-lines */ +export const latinMap: Record = { + Á: 'A', // LATIN CAPITAL LETTER A WITH ACUTE + Ă: 'A', // LATIN CAPITAL LETTER A WITH BREVE + Ắ: 'A', // LATIN CAPITAL LETTER A WITH BREVE AND ACUTE + Ặ: 'A', // LATIN CAPITAL LETTER A WITH BREVE AND DOT BELOW + Ằ: 'A', // LATIN CAPITAL LETTER A WITH BREVE AND GRAVE + Ẳ: 'A', // LATIN CAPITAL LETTER A WITH BREVE AND HOOK ABOVE + Ẵ: 'A', // LATIN CAPITAL LETTER A WITH BREVE AND TILDE + Ǎ: 'A', // LATIN CAPITAL LETTER A WITH CARON + Â: 'A', // LATIN CAPITAL LETTER A WITH CIRCUMFLEX + Ấ: 'A', // LATIN CAPITAL LETTER A WITH CIRCUMFLEX AND ACUTE + Ậ: 'A', // LATIN CAPITAL LETTER A WITH CIRCUMFLEX AND DOT BELOW + Ầ: 'A', // LATIN CAPITAL LETTER A WITH CIRCUMFLEX AND GRAVE + Ẩ: 'A', // LATIN CAPITAL LETTER A WITH CIRCUMFLEX AND HOOK ABOVE + Ẫ: 'A', // LATIN CAPITAL LETTER A WITH CIRCUMFLEX AND TILDE + Ä: 'A', // LATIN CAPITAL LETTER A WITH DIAERESIS + Ǟ: 'A', // LATIN CAPITAL LETTER A WITH DIAERESIS AND MACRON + Ȧ: 'A', // LATIN CAPITAL LETTER A WITH DOT ABOVE + Ǡ: 'A', // LATIN CAPITAL LETTER A WITH DOT ABOVE AND MACRON + Ạ: 'A', // LATIN CAPITAL LETTER A WITH DOT BELOW + Ȁ: 'A', // LATIN CAPITAL LETTER A WITH DOUBLE GRAVE + À: 'A', // LATIN CAPITAL LETTER A WITH GRAVE + Ả: 'A', // LATIN CAPITAL LETTER A WITH HOOK ABOVE + Ȃ: 'A', // LATIN CAPITAL LETTER A WITH INVERTED BREVE + Ā: 'A', // LATIN CAPITAL LETTER A WITH MACRON + Ą: 'A', // LATIN CAPITAL LETTER A WITH OGONEK + Å: 'A', // LATIN CAPITAL LETTER A WITH RING ABOVE + Ǻ: 'A', // LATIN CAPITAL LETTER A WITH RING ABOVE AND ACUTE + Ḁ: 'A', // LATIN CAPITAL LETTER A WITH RING BELOW + Ⱥ: 'A', // LATIN CAPITAL LETTER A WITH STROKE + Ã: 'A', // LATIN CAPITAL LETTER A WITH TILDE + Ꜳ: 'AA', // LATIN CAPITAL LETTER AA + Æ: 'AE', // LATIN CAPITAL LETTER AE + Ǽ: 'AE', // LATIN CAPITAL LETTER AE WITH ACUTE + Ǣ: 'AE', // LATIN CAPITAL LETTER AE WITH MACRON + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER AFRICAN D' (Ɖ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER ALPHA' (Ɑ) + Ꜵ: 'AO', // LATIN CAPITAL LETTER AO + Ꜷ: 'AU', // LATIN CAPITAL LETTER AU + Ꜹ: 'AV', // LATIN CAPITAL LETTER AV + Ꜻ: 'AV', // LATIN CAPITAL LETTER AV WITH HORIZONTAL BAR + Ꜽ: 'AY', // LATIN CAPITAL LETTER AY + Ḃ: 'B', // LATIN CAPITAL LETTER B WITH DOT ABOVE + Ḅ: 'B', // LATIN CAPITAL LETTER B WITH DOT BELOW + Ɓ: 'B', // LATIN CAPITAL LETTER B WITH HOOK + Ḇ: 'B', // LATIN CAPITAL LETTER B WITH LINE BELOW + Ƀ: 'B', // LATIN CAPITAL LETTER B WITH STROKE + Ƃ: 'B', // LATIN CAPITAL LETTER B WITH TOPBAR + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER BROKEN L' (Ꝇ) + Ć: 'C', // LATIN CAPITAL LETTER C WITH ACUTE + Č: 'C', // LATIN CAPITAL LETTER C WITH CARON + Ç: 'C', // LATIN CAPITAL LETTER C WITH CEDILLA + Ḉ: 'C', // LATIN CAPITAL LETTER C WITH CEDILLA AND ACUTE + Ĉ: 'C', // LATIN CAPITAL LETTER C WITH CIRCUMFLEX + Ċ: 'C', // LATIN CAPITAL LETTER C WITH DOT ABOVE + Ƈ: 'C', // LATIN CAPITAL LETTER C WITH HOOK + Ȼ: 'C', // LATIN CAPITAL LETTER C WITH STROKE + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER CON' (Ꝯ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER CUATRILLO' (Ꜭ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER CUATRILLO WITH COMMA' (Ꜯ) + Ď: 'D', // LATIN CAPITAL LETTER D WITH CARON + Ḑ: 'D', // LATIN CAPITAL LETTER D WITH CEDILLA + Ḓ: 'D', // LATIN CAPITAL LETTER D WITH CIRCUMFLEX BELOW + Ḋ: 'D', // LATIN CAPITAL LETTER D WITH DOT ABOVE + Ḍ: 'D', // LATIN CAPITAL LETTER D WITH DOT BELOW + Ɗ: 'D', // LATIN CAPITAL LETTER D WITH HOOK + Ḏ: 'D', // LATIN CAPITAL LETTER D WITH LINE BELOW + Dz: 'D', // LATIN CAPITAL LETTER D WITH SMALL LETTER Z + Dž: 'D', // LATIN CAPITAL LETTER D WITH SMALL LETTER Z WITH CARON + Đ: 'D', // LATIN CAPITAL LETTER D WITH STROKE + Ƌ: 'D', // LATIN CAPITAL LETTER D WITH TOPBAR + DZ: 'DZ', // LATIN CAPITAL LETTER DZ + DŽ: 'DZ', // LATIN CAPITAL LETTER DZ WITH CARON + É: 'E', // LATIN CAPITAL LETTER E WITH ACUTE + Ĕ: 'E', // LATIN CAPITAL LETTER E WITH BREVE + Ě: 'E', // LATIN CAPITAL LETTER E WITH CARON + Ȩ: 'E', // LATIN CAPITAL LETTER E WITH CEDILLA + Ḝ: 'E', // LATIN CAPITAL LETTER E WITH CEDILLA AND BREVE + Ê: 'E', // LATIN CAPITAL LETTER E WITH CIRCUMFLEX + Ế: 'E', // LATIN CAPITAL LETTER E WITH CIRCUMFLEX AND ACUTE + Ệ: 'E', // LATIN CAPITAL LETTER E WITH CIRCUMFLEX AND DOT BELOW + Ề: 'E', // LATIN CAPITAL LETTER E WITH CIRCUMFLEX AND GRAVE + Ể: 'E', // LATIN CAPITAL LETTER E WITH CIRCUMFLEX AND HOOK ABOVE + Ễ: 'E', // LATIN CAPITAL LETTER E WITH CIRCUMFLEX AND TILDE + Ḙ: 'E', // LATIN CAPITAL LETTER E WITH CIRCUMFLEX BELOW + Ë: 'E', // LATIN CAPITAL LETTER E WITH DIAERESIS + Ė: 'E', // LATIN CAPITAL LETTER E WITH DOT ABOVE + Ẹ: 'E', // LATIN CAPITAL LETTER E WITH DOT BELOW + Ȅ: 'E', // LATIN CAPITAL LETTER E WITH DOUBLE GRAVE + È: 'E', // LATIN CAPITAL LETTER E WITH GRAVE + Ẻ: 'E', // LATIN CAPITAL LETTER E WITH HOOK ABOVE + Ȇ: 'E', // LATIN CAPITAL LETTER E WITH INVERTED BREVE + Ē: 'E', // LATIN CAPITAL LETTER E WITH MACRON + Ḗ: 'E', // LATIN CAPITAL LETTER E WITH MACRON AND ACUTE + Ḕ: 'E', // LATIN CAPITAL LETTER E WITH MACRON AND GRAVE + Ę: 'E', // LATIN CAPITAL LETTER E WITH OGONEK + Ɇ: 'E', // LATIN CAPITAL LETTER E WITH STROKE + Ẽ: 'E', // LATIN CAPITAL LETTER E WITH TILDE + Ḛ: 'E', // LATIN CAPITAL LETTER E WITH TILDE BELOW + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER EGYPTOLOGICAL AIN' (Ꜥ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER EGYPTOLOGICAL ALEF' (Ꜣ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER ENG' (Ŋ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER ESH' (Ʃ) + Ꝫ: 'ET', // LATIN CAPITAL LETTER ET + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER ETH' (Ð) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER EZH' (Ʒ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER EZH REVERSED' (Ƹ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER EZH WITH CARON' (Ǯ) + Ḟ: 'F', // LATIN CAPITAL LETTER F WITH DOT ABOVE + Ƒ: 'F', // LATIN CAPITAL LETTER F WITH HOOK + Ǵ: 'G', // LATIN CAPITAL LETTER G WITH ACUTE + Ğ: 'G', // LATIN CAPITAL LETTER G WITH BREVE + Ǧ: 'G', // LATIN CAPITAL LETTER G WITH CARON + Ģ: 'G', // LATIN CAPITAL LETTER G WITH CEDILLA + Ĝ: 'G', // LATIN CAPITAL LETTER G WITH CIRCUMFLEX + Ġ: 'G', // LATIN CAPITAL LETTER G WITH DOT ABOVE + Ɠ: 'G', // LATIN CAPITAL LETTER G WITH HOOK + Ḡ: 'G', // LATIN CAPITAL LETTER G WITH MACRON + Ǥ: 'G', // LATIN CAPITAL LETTER G WITH STROKE + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER GAMMA' (Ɣ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER GLOTTAL STOP' (Ɂ) + Ḫ: 'H', // LATIN CAPITAL LETTER H WITH BREVE BELOW + Ȟ: 'H', // LATIN CAPITAL LETTER H WITH CARON + Ḩ: 'H', // LATIN CAPITAL LETTER H WITH CEDILLA + Ĥ: 'H', // LATIN CAPITAL LETTER H WITH CIRCUMFLEX + Ⱨ: 'H', // LATIN CAPITAL LETTER H WITH DESCENDER + Ḧ: 'H', // LATIN CAPITAL LETTER H WITH DIAERESIS + Ḣ: 'H', // LATIN CAPITAL LETTER H WITH DOT ABOVE + Ḥ: 'H', // LATIN CAPITAL LETTER H WITH DOT BELOW + Ħ: 'H', // LATIN CAPITAL LETTER H WITH STROKE + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER HALF H' (Ⱶ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER HENG' (Ꜧ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER HWAIR' (Ƕ) + Í: 'I', // LATIN CAPITAL LETTER I WITH ACUTE + Ĭ: 'I', // LATIN CAPITAL LETTER I WITH BREVE + Ǐ: 'I', // LATIN CAPITAL LETTER I WITH CARON + Î: 'I', // LATIN CAPITAL LETTER I WITH CIRCUMFLEX + Ï: 'I', // LATIN CAPITAL LETTER I WITH DIAERESIS + Ḯ: 'I', // LATIN CAPITAL LETTER I WITH DIAERESIS AND ACUTE + İ: 'I', // LATIN CAPITAL LETTER I WITH DOT ABOVE + Ị: 'I', // LATIN CAPITAL LETTER I WITH DOT BELOW + Ȉ: 'I', // LATIN CAPITAL LETTER I WITH DOUBLE GRAVE + Ì: 'I', // LATIN CAPITAL LETTER I WITH GRAVE + Ỉ: 'I', // LATIN CAPITAL LETTER I WITH HOOK ABOVE + Ȋ: 'I', // LATIN CAPITAL LETTER I WITH INVERTED BREVE + Ī: 'I', // LATIN CAPITAL LETTER I WITH MACRON + Į: 'I', // LATIN CAPITAL LETTER I WITH OGONEK + Ɨ: 'I', // LATIN CAPITAL LETTER I WITH STROKE + Ĩ: 'I', // LATIN CAPITAL LETTER I WITH TILDE + Ḭ: 'I', // LATIN CAPITAL LETTER I WITH TILDE BELOW + Ꝺ: 'D', // LATIN CAPITAL LETTER INSULAR D + Ꝼ: 'F', // LATIN CAPITAL LETTER INSULAR F + Ᵹ: 'G', // LATIN CAPITAL LETTER INSULAR G + Ꞃ: 'R', // LATIN CAPITAL LETTER INSULAR R + Ꞅ: 'S', // LATIN CAPITAL LETTER INSULAR S + Ꞇ: 'T', // LATIN CAPITAL LETTER INSULAR T + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER IOTA' (Ɩ) + Ꝭ: 'IS', // LATIN CAPITAL LETTER IS + Ĵ: 'J', // LATIN CAPITAL LETTER J WITH CIRCUMFLEX + Ɉ: 'J', // LATIN CAPITAL LETTER J WITH STROKE + Ḱ: 'K', // LATIN CAPITAL LETTER K WITH ACUTE + Ǩ: 'K', // LATIN CAPITAL LETTER K WITH CARON + Ķ: 'K', // LATIN CAPITAL LETTER K WITH CEDILLA + Ⱪ: 'K', // LATIN CAPITAL LETTER K WITH DESCENDER + Ꝃ: 'K', // LATIN CAPITAL LETTER K WITH DIAGONAL STROKE + Ḳ: 'K', // LATIN CAPITAL LETTER K WITH DOT BELOW + Ƙ: 'K', // LATIN CAPITAL LETTER K WITH HOOK + Ḵ: 'K', // LATIN CAPITAL LETTER K WITH LINE BELOW + Ꝁ: 'K', // LATIN CAPITAL LETTER K WITH STROKE + Ꝅ: 'K', // LATIN CAPITAL LETTER K WITH STROKE AND DIAGONAL STROKE + Ĺ: 'L', // LATIN CAPITAL LETTER L WITH ACUTE + Ƚ: 'L', // LATIN CAPITAL LETTER L WITH BAR + Ľ: 'L', // LATIN CAPITAL LETTER L WITH CARON + Ļ: 'L', // LATIN CAPITAL LETTER L WITH CEDILLA + Ḽ: 'L', // LATIN CAPITAL LETTER L WITH CIRCUMFLEX BELOW + Ḷ: 'L', // LATIN CAPITAL LETTER L WITH DOT BELOW + Ḹ: 'L', // LATIN CAPITAL LETTER L WITH DOT BELOW AND MACRON + Ⱡ: 'L', // LATIN CAPITAL LETTER L WITH DOUBLE BAR + Ꝉ: 'L', // LATIN CAPITAL LETTER L WITH HIGH STROKE + Ḻ: 'L', // LATIN CAPITAL LETTER L WITH LINE BELOW + Ŀ: 'L', // LATIN CAPITAL LETTER L WITH MIDDLE DOT + Ɫ: 'L', // LATIN CAPITAL LETTER L WITH MIDDLE TILDE + Lj: 'L', // LATIN CAPITAL LETTER L WITH SMALL LETTER J + Ł: 'L', // LATIN CAPITAL LETTER L WITH STROKE + LJ: 'LJ', // LATIN CAPITAL LETTER LJ + Ḿ: 'M', // LATIN CAPITAL LETTER M WITH ACUTE + Ṁ: 'M', // LATIN CAPITAL LETTER M WITH DOT ABOVE + Ṃ: 'M', // LATIN CAPITAL LETTER M WITH DOT BELOW + Ɱ: 'M', // LATIN CAPITAL LETTER M WITH HOOK + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER MIDDLE-WELSH LL' (Ỻ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER MIDDLE-WELSH V' (Ỽ) + Ń: 'N', // LATIN CAPITAL LETTER N WITH ACUTE + Ň: 'N', // LATIN CAPITAL LETTER N WITH CARON + Ņ: 'N', // LATIN CAPITAL LETTER N WITH CEDILLA + Ṋ: 'N', // LATIN CAPITAL LETTER N WITH CIRCUMFLEX BELOW + Ṅ: 'N', // LATIN CAPITAL LETTER N WITH DOT ABOVE + Ṇ: 'N', // LATIN CAPITAL LETTER N WITH DOT BELOW + Ǹ: 'N', // LATIN CAPITAL LETTER N WITH GRAVE + Ɲ: 'N', // LATIN CAPITAL LETTER N WITH LEFT HOOK + Ṉ: 'N', // LATIN CAPITAL LETTER N WITH LINE BELOW + Ƞ: 'N', // LATIN CAPITAL LETTER N WITH LONG RIGHT LEG + Nj: 'N', // LATIN CAPITAL LETTER N WITH SMALL LETTER J + Ñ: 'N', // LATIN CAPITAL LETTER N WITH TILDE + NJ: 'NJ', // LATIN CAPITAL LETTER NJ + Ó: 'O', // LATIN CAPITAL LETTER O WITH ACUTE + Ŏ: 'O', // LATIN CAPITAL LETTER O WITH BREVE + Ǒ: 'O', // LATIN CAPITAL LETTER O WITH CARON + Ô: 'O', // LATIN CAPITAL LETTER O WITH CIRCUMFLEX + Ố: 'O', // LATIN CAPITAL LETTER O WITH CIRCUMFLEX AND ACUTE + Ộ: 'O', // LATIN CAPITAL LETTER O WITH CIRCUMFLEX AND DOT BELOW + Ồ: 'O', // LATIN CAPITAL LETTER O WITH CIRCUMFLEX AND GRAVE + Ổ: 'O', // LATIN CAPITAL LETTER O WITH CIRCUMFLEX AND HOOK ABOVE + Ỗ: 'O', // LATIN CAPITAL LETTER O WITH CIRCUMFLEX AND TILDE + Ö: 'O', // LATIN CAPITAL LETTER O WITH DIAERESIS + Ȫ: 'O', // LATIN CAPITAL LETTER O WITH DIAERESIS AND MACRON + Ȯ: 'O', // LATIN CAPITAL LETTER O WITH DOT ABOVE + Ȱ: 'O', // LATIN CAPITAL LETTER O WITH DOT ABOVE AND MACRON + Ọ: 'O', // LATIN CAPITAL LETTER O WITH DOT BELOW + Ő: 'O', // LATIN CAPITAL LETTER O WITH DOUBLE ACUTE + Ȍ: 'O', // LATIN CAPITAL LETTER O WITH DOUBLE GRAVE + Ò: 'O', // LATIN CAPITAL LETTER O WITH GRAVE + Ỏ: 'O', // LATIN CAPITAL LETTER O WITH HOOK ABOVE + Ơ: 'O', // LATIN CAPITAL LETTER O WITH HORN + Ớ: 'O', // LATIN CAPITAL LETTER O WITH HORN AND ACUTE + Ợ: 'O', // LATIN CAPITAL LETTER O WITH HORN AND DOT BELOW + Ờ: 'O', // LATIN CAPITAL LETTER O WITH HORN AND GRAVE + Ở: 'O', // LATIN CAPITAL LETTER O WITH HORN AND HOOK ABOVE + Ỡ: 'O', // LATIN CAPITAL LETTER O WITH HORN AND TILDE + Ȏ: 'O', // LATIN CAPITAL LETTER O WITH INVERTED BREVE + Ꝋ: 'O', // LATIN CAPITAL LETTER O WITH LONG STROKE OVERLAY + Ꝍ: 'O', // LATIN CAPITAL LETTER O WITH LOOP + Ō: 'O', // LATIN CAPITAL LETTER O WITH MACRON + Ṓ: 'O', // LATIN CAPITAL LETTER O WITH MACRON AND ACUTE + Ṑ: 'O', // LATIN CAPITAL LETTER O WITH MACRON AND GRAVE + Ɵ: 'O', // LATIN CAPITAL LETTER O WITH MIDDLE TILDE + Ǫ: 'O', // LATIN CAPITAL LETTER O WITH OGONEK + Ǭ: 'O', // LATIN CAPITAL LETTER O WITH OGONEK AND MACRON + Ø: 'O', // LATIN CAPITAL LETTER O WITH STROKE + Ǿ: 'O', // LATIN CAPITAL LETTER O WITH STROKE AND ACUTE + Õ: 'O', // LATIN CAPITAL LETTER O WITH TILDE + Ṍ: 'O', // LATIN CAPITAL LETTER O WITH TILDE AND ACUTE + Ṏ: 'O', // LATIN CAPITAL LETTER O WITH TILDE AND DIAERESIS + Ȭ: 'O', // LATIN CAPITAL LETTER O WITH TILDE AND MACRON + Ƣ: 'OI', // LATIN CAPITAL LETTER OI + Ꝏ: 'OO', // LATIN CAPITAL LETTER OO + Ɛ: 'E', // LATIN CAPITAL LETTER OPEN E + Ɔ: 'O', // LATIN CAPITAL LETTER OPEN O + Ȣ: 'OU', // LATIN CAPITAL LETTER OU + Ṕ: 'P', // LATIN CAPITAL LETTER P WITH ACUTE + Ṗ: 'P', // LATIN CAPITAL LETTER P WITH DOT ABOVE + Ꝓ: 'P', // LATIN CAPITAL LETTER P WITH FLOURISH + Ƥ: 'P', // LATIN CAPITAL LETTER P WITH HOOK + Ꝕ: 'P', // LATIN CAPITAL LETTER P WITH SQUIRREL TAIL + Ᵽ: 'P', // LATIN CAPITAL LETTER P WITH STROKE + Ꝑ: 'P', // LATIN CAPITAL LETTER P WITH STROKE THROUGH DESCENDER + Ꝙ: 'Q', // LATIN CAPITAL LETTER Q WITH DIAGONAL STROKE + Ꝗ: 'Q', // LATIN CAPITAL LETTER Q WITH STROKE THROUGH DESCENDER + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER R ROTUNDA' (Ꝛ) + Ŕ: 'R', // LATIN CAPITAL LETTER R WITH ACUTE + Ř: 'R', // LATIN CAPITAL LETTER R WITH CARON + Ŗ: 'R', // LATIN CAPITAL LETTER R WITH CEDILLA + Ṙ: 'R', // LATIN CAPITAL LETTER R WITH DOT ABOVE + Ṛ: 'R', // LATIN CAPITAL LETTER R WITH DOT BELOW + Ṝ: 'R', // LATIN CAPITAL LETTER R WITH DOT BELOW AND MACRON + Ȑ: 'R', // LATIN CAPITAL LETTER R WITH DOUBLE GRAVE + Ȓ: 'R', // LATIN CAPITAL LETTER R WITH INVERTED BREVE + Ṟ: 'R', // LATIN CAPITAL LETTER R WITH LINE BELOW + Ɍ: 'R', // LATIN CAPITAL LETTER R WITH STROKE + Ɽ: 'R', // LATIN CAPITAL LETTER R WITH TAIL + Ꜿ: 'C', // LATIN CAPITAL LETTER REVERSED C WITH DOT + Ǝ: 'E', // LATIN CAPITAL LETTER REVERSED E + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER RUM ROTUNDA' (Ꝝ) + Ś: 'S', // LATIN CAPITAL LETTER S WITH ACUTE + Ṥ: 'S', // LATIN CAPITAL LETTER S WITH ACUTE AND DOT ABOVE + Š: 'S', // LATIN CAPITAL LETTER S WITH CARON + Ṧ: 'S', // LATIN CAPITAL LETTER S WITH CARON AND DOT ABOVE + Ş: 'S', // LATIN CAPITAL LETTER S WITH CEDILLA + Ŝ: 'S', // LATIN CAPITAL LETTER S WITH CIRCUMFLEX + Ș: 'S', // LATIN CAPITAL LETTER S WITH COMMA BELOW + Ṡ: 'S', // LATIN CAPITAL LETTER S WITH DOT ABOVE + Ṣ: 'S', // LATIN CAPITAL LETTER S WITH DOT BELOW + Ṩ: 'S', // LATIN CAPITAL LETTER S WITH DOT BELOW AND DOT ABOVE + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER SALTILLO' (Ꞌ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER SCHWA' (Ə) + ẞ: 'SS', // LATIN CAPITAL LETTER SHARP S + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER SMALL Q WITH HOOK TAIL' (Ɋ) + Ť: 'T', // LATIN CAPITAL LETTER T WITH CARON + Ţ: 'T', // LATIN CAPITAL LETTER T WITH CEDILLA + Ṱ: 'T', // LATIN CAPITAL LETTER T WITH CIRCUMFLEX BELOW + Ț: 'T', // LATIN CAPITAL LETTER T WITH COMMA BELOW + Ⱦ: 'T', // LATIN CAPITAL LETTER T WITH DIAGONAL STROKE + Ṫ: 'T', // LATIN CAPITAL LETTER T WITH DOT ABOVE + Ṭ: 'T', // LATIN CAPITAL LETTER T WITH DOT BELOW + Ƭ: 'T', // LATIN CAPITAL LETTER T WITH HOOK + Ṯ: 'T', // LATIN CAPITAL LETTER T WITH LINE BELOW + Ʈ: 'T', // LATIN CAPITAL LETTER T WITH RETROFLEX HOOK + Ŧ: 'T', // LATIN CAPITAL LETTER T WITH STROKE + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER THORN' (Þ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER THORN WITH STROKE' (Ꝥ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER THORN WITH STROKE THROUGH DESCENDER' (Ꝧ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER TONE FIVE' (Ƽ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER TONE SIX' (Ƅ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER TONE TWO' (Ƨ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER TRESILLO' (Ꜫ) + Ɐ: 'A', // LATIN CAPITAL LETTER TURNED A + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER TURNED INSULAR G' (Ꝿ) + Ꞁ: 'L', // LATIN CAPITAL LETTER TURNED L + Ɯ: 'M', // LATIN CAPITAL LETTER TURNED M + Ʌ: 'V', // LATIN CAPITAL LETTER TURNED V + Ꜩ: 'TZ', // LATIN CAPITAL LETTER TZ + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER U BAR' (Ʉ) + Ú: 'U', // LATIN CAPITAL LETTER U WITH ACUTE + Ŭ: 'U', // LATIN CAPITAL LETTER U WITH BREVE + Ǔ: 'U', // LATIN CAPITAL LETTER U WITH CARON + Û: 'U', // LATIN CAPITAL LETTER U WITH CIRCUMFLEX + Ṷ: 'U', // LATIN CAPITAL LETTER U WITH CIRCUMFLEX BELOW + Ü: 'U', // LATIN CAPITAL LETTER U WITH DIAERESIS + Ǘ: 'U', // LATIN CAPITAL LETTER U WITH DIAERESIS AND ACUTE + Ǚ: 'U', // LATIN CAPITAL LETTER U WITH DIAERESIS AND CARON + Ǜ: 'U', // LATIN CAPITAL LETTER U WITH DIAERESIS AND GRAVE + Ǖ: 'U', // LATIN CAPITAL LETTER U WITH DIAERESIS AND MACRON + Ṳ: 'U', // LATIN CAPITAL LETTER U WITH DIAERESIS BELOW + Ụ: 'U', // LATIN CAPITAL LETTER U WITH DOT BELOW + Ű: 'U', // LATIN CAPITAL LETTER U WITH DOUBLE ACUTE + Ȕ: 'U', // LATIN CAPITAL LETTER U WITH DOUBLE GRAVE + Ù: 'U', // LATIN CAPITAL LETTER U WITH GRAVE + Ủ: 'U', // LATIN CAPITAL LETTER U WITH HOOK ABOVE + Ư: 'U', // LATIN CAPITAL LETTER U WITH HORN + Ứ: 'U', // LATIN CAPITAL LETTER U WITH HORN AND ACUTE + Ự: 'U', // LATIN CAPITAL LETTER U WITH HORN AND DOT BELOW + Ừ: 'U', // LATIN CAPITAL LETTER U WITH HORN AND GRAVE + Ử: 'U', // LATIN CAPITAL LETTER U WITH HORN AND HOOK ABOVE + Ữ: 'U', // LATIN CAPITAL LETTER U WITH HORN AND TILDE + Ȗ: 'U', // LATIN CAPITAL LETTER U WITH INVERTED BREVE + Ū: 'U', // LATIN CAPITAL LETTER U WITH MACRON + Ṻ: 'U', // LATIN CAPITAL LETTER U WITH MACRON AND DIAERESIS + Ų: 'U', // LATIN CAPITAL LETTER U WITH OGONEK + Ů: 'U', // LATIN CAPITAL LETTER U WITH RING ABOVE + Ũ: 'U', // LATIN CAPITAL LETTER U WITH TILDE + Ṹ: 'U', // LATIN CAPITAL LETTER U WITH TILDE AND ACUTE + Ṵ: 'U', // LATIN CAPITAL LETTER U WITH TILDE BELOW + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER UPSILON' (Ʊ) + Ꝟ: 'V', // LATIN CAPITAL LETTER V WITH DIAGONAL STROKE + Ṿ: 'V', // LATIN CAPITAL LETTER V WITH DOT BELOW + Ʋ: 'V', // LATIN CAPITAL LETTER V WITH HOOK + Ṽ: 'V', // LATIN CAPITAL LETTER V WITH TILDE + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER VEND' (Ꝩ) + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER VISIGOTHIC Z' (Ꝣ) + Ꝡ: 'VY', // LATIN CAPITAL LETTER VY + Ẃ: 'W', // LATIN CAPITAL LETTER W WITH ACUTE + Ŵ: 'W', // LATIN CAPITAL LETTER W WITH CIRCUMFLEX + Ẅ: 'W', // LATIN CAPITAL LETTER W WITH DIAERESIS + Ẇ: 'W', // LATIN CAPITAL LETTER W WITH DOT ABOVE + Ẉ: 'W', // LATIN CAPITAL LETTER W WITH DOT BELOW + Ẁ: 'W', // LATIN CAPITAL LETTER W WITH GRAVE + Ⱳ: 'W', // LATIN CAPITAL LETTER W WITH HOOK + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER WYNN' (Ƿ) + Ẍ: 'X', // LATIN CAPITAL LETTER X WITH DIAERESIS + Ẋ: 'X', // LATIN CAPITAL LETTER X WITH DOT ABOVE + Ý: 'Y', // LATIN CAPITAL LETTER Y WITH ACUTE + Ŷ: 'Y', // LATIN CAPITAL LETTER Y WITH CIRCUMFLEX + Ÿ: 'Y', // LATIN CAPITAL LETTER Y WITH DIAERESIS + Ẏ: 'Y', // LATIN CAPITAL LETTER Y WITH DOT ABOVE + Ỵ: 'Y', // LATIN CAPITAL LETTER Y WITH DOT BELOW + Ỳ: 'Y', // LATIN CAPITAL LETTER Y WITH GRAVE + Ƴ: 'Y', // LATIN CAPITAL LETTER Y WITH HOOK + Ỷ: 'Y', // LATIN CAPITAL LETTER Y WITH HOOK ABOVE + Ỿ: 'Y', // LATIN CAPITAL LETTER Y WITH LOOP + Ȳ: 'Y', // LATIN CAPITAL LETTER Y WITH MACRON + Ɏ: 'Y', // LATIN CAPITAL LETTER Y WITH STROKE + Ỹ: 'Y', // LATIN CAPITAL LETTER Y WITH TILDE + // CANNOT FIND APPROXIMATION FOR 'LATIN CAPITAL LETTER YOGH' (Ȝ) + Ź: 'Z', // LATIN CAPITAL LETTER Z WITH ACUTE + Ž: 'Z', // LATIN CAPITAL LETTER Z WITH CARON + Ẑ: 'Z', // LATIN CAPITAL LETTER Z WITH CIRCUMFLEX + Ⱬ: 'Z', // LATIN CAPITAL LETTER Z WITH DESCENDER + Ż: 'Z', // LATIN CAPITAL LETTER Z WITH DOT ABOVE + Ẓ: 'Z', // LATIN CAPITAL LETTER Z WITH DOT BELOW + Ȥ: 'Z', // LATIN CAPITAL LETTER Z WITH HOOK + Ẕ: 'Z', // LATIN CAPITAL LETTER Z WITH LINE BELOW + Ƶ: 'Z', // LATIN CAPITAL LETTER Z WITH STROKE + IJ: 'IJ', // LATIN CAPITAL LIGATURE IJ + Œ: 'OE', // LATIN CAPITAL LIGATURE OE + // CANNOT FIND APPROXIMATION FOR 'LATIN CROSS' (✝) + // CANNOT FIND APPROXIMATION FOR 'LATIN EPIGRAPHIC LETTER ARCHAIC M' (ꟿ) + // CANNOT FIND APPROXIMATION FOR 'LATIN EPIGRAPHIC LETTER I LONGA' (ꟾ) + // CANNOT FIND APPROXIMATION FOR 'LATIN EPIGRAPHIC LETTER INVERTED M' (ꟽ) + // CANNOT FIND APPROXIMATION FOR 'LATIN EPIGRAPHIC LETTER REVERSED F' (ꟻ) + // CANNOT FIND APPROXIMATION FOR 'LATIN EPIGRAPHIC LETTER REVERSED P' (ꟼ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER AIN' (ᴥ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER ALVEOLAR CLICK' (ǂ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER BIDENTAL PERCUSSIVE' (ʭ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER BILABIAL CLICK' (ʘ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER BILABIAL PERCUSSIVE' (ʬ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER DENTAL CLICK' (ǀ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER GLOTTAL STOP' (ʔ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER GLOTTAL STOP WITH STROKE' (ʡ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER INVERTED GLOTTAL STOP' (ʖ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER INVERTED GLOTTAL STOP WITH STROKE' (ƾ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER LATERAL CLICK' (ǁ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER PHARYNGEAL VOICED FRICATIVE' (ʕ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER RETROFLEX CLICK' (ǃ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER REVERSED ESH LOOP' (ƪ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER REVERSED GLOTTAL STOP WITH STROKE' (ʢ) + ᴀ: 'A', // LATIN LETTER SMALL CAPITAL A + ᴁ: 'AE', // LATIN LETTER SMALL CAPITAL AE + ʙ: 'B', // LATIN LETTER SMALL CAPITAL B + ᴃ: 'B', // LATIN LETTER SMALL CAPITAL BARRED B + ᴄ: 'C', // LATIN LETTER SMALL CAPITAL C + ᴅ: 'D', // LATIN LETTER SMALL CAPITAL D + ᴇ: 'E', // LATIN LETTER SMALL CAPITAL E + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER SMALL CAPITAL ETH' (ᴆ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER SMALL CAPITAL EZH' (ᴣ) + ꜰ: 'F', // LATIN LETTER SMALL CAPITAL F + ɢ: 'G', // LATIN LETTER SMALL CAPITAL G + ʛ: 'G', // LATIN LETTER SMALL CAPITAL G WITH HOOK + ʜ: 'H', // LATIN LETTER SMALL CAPITAL H + ɪ: 'I', // LATIN LETTER SMALL CAPITAL I + ʁ: 'R', // LATIN LETTER SMALL CAPITAL INVERTED R + ᴊ: 'J', // LATIN LETTER SMALL CAPITAL J + ᴋ: 'K', // LATIN LETTER SMALL CAPITAL K + ʟ: 'L', // LATIN LETTER SMALL CAPITAL L + ᴌ: 'L', // LATIN LETTER SMALL CAPITAL L WITH STROKE + ᴍ: 'M', // LATIN LETTER SMALL CAPITAL M + ɴ: 'N', // LATIN LETTER SMALL CAPITAL N + ᴏ: 'O', // LATIN LETTER SMALL CAPITAL O + ɶ: 'OE', // LATIN LETTER SMALL CAPITAL OE + ᴐ: 'O', // LATIN LETTER SMALL CAPITAL OPEN O + ᴕ: 'OU', // LATIN LETTER SMALL CAPITAL OU + ᴘ: 'P', // LATIN LETTER SMALL CAPITAL P + ʀ: 'R', // LATIN LETTER SMALL CAPITAL R + ᴎ: 'N', // LATIN LETTER SMALL CAPITAL REVERSED N + ᴙ: 'R', // LATIN LETTER SMALL CAPITAL REVERSED R + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER SMALL CAPITAL RUM' (ꝶ) + ꜱ: 'S', // LATIN LETTER SMALL CAPITAL S + ᴛ: 'T', // LATIN LETTER SMALL CAPITAL T + ⱻ: 'E', // LATIN LETTER SMALL CAPITAL TURNED E + ᴚ: 'R', // LATIN LETTER SMALL CAPITAL TURNED R + ᴜ: 'U', // LATIN LETTER SMALL CAPITAL U + ᴠ: 'V', // LATIN LETTER SMALL CAPITAL V + ᴡ: 'W', // LATIN LETTER SMALL CAPITAL W + ʏ: 'Y', // LATIN LETTER SMALL CAPITAL Y + ᴢ: 'Z', // LATIN LETTER SMALL CAPITAL Z + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER STRETCHED C' (ʗ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER TWO WITH STROKE' (ƻ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER VOICED LARYNGEAL SPIRANT' (ᴤ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER WYNN' (ƿ) + // CANNOT FIND APPROXIMATION FOR 'LATIN LETTER YR' (Ʀ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL CAPITAL LETTER I WITH STROKE' (ᵻ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL CAPITAL LETTER U WITH STROKE' (ᵾ) + á: 'a', // LATIN SMALL LETTER A WITH ACUTE + ă: 'a', // LATIN SMALL LETTER A WITH BREVE + ắ: 'a', // LATIN SMALL LETTER A WITH BREVE AND ACUTE + ặ: 'a', // LATIN SMALL LETTER A WITH BREVE AND DOT BELOW + ằ: 'a', // LATIN SMALL LETTER A WITH BREVE AND GRAVE + ẳ: 'a', // LATIN SMALL LETTER A WITH BREVE AND HOOK ABOVE + ẵ: 'a', // LATIN SMALL LETTER A WITH BREVE AND TILDE + ǎ: 'a', // LATIN SMALL LETTER A WITH CARON + â: 'a', // LATIN SMALL LETTER A WITH CIRCUMFLEX + ấ: 'a', // LATIN SMALL LETTER A WITH CIRCUMFLEX AND ACUTE + ậ: 'a', // LATIN SMALL LETTER A WITH CIRCUMFLEX AND DOT BELOW + ầ: 'a', // LATIN SMALL LETTER A WITH CIRCUMFLEX AND GRAVE + ẩ: 'a', // LATIN SMALL LETTER A WITH CIRCUMFLEX AND HOOK ABOVE + ẫ: 'a', // LATIN SMALL LETTER A WITH CIRCUMFLEX AND TILDE + ä: 'a', // LATIN SMALL LETTER A WITH DIAERESIS + ǟ: 'a', // LATIN SMALL LETTER A WITH DIAERESIS AND MACRON + ȧ: 'a', // LATIN SMALL LETTER A WITH DOT ABOVE + ǡ: 'a', // LATIN SMALL LETTER A WITH DOT ABOVE AND MACRON + ạ: 'a', // LATIN SMALL LETTER A WITH DOT BELOW + ȁ: 'a', // LATIN SMALL LETTER A WITH DOUBLE GRAVE + à: 'a', // LATIN SMALL LETTER A WITH GRAVE + ả: 'a', // LATIN SMALL LETTER A WITH HOOK ABOVE + ȃ: 'a', // LATIN SMALL LETTER A WITH INVERTED BREVE + ā: 'a', // LATIN SMALL LETTER A WITH MACRON + ą: 'a', // LATIN SMALL LETTER A WITH OGONEK + ᶏ: 'a', // LATIN SMALL LETTER A WITH RETROFLEX HOOK + ẚ: 'a', // LATIN SMALL LETTER A WITH RIGHT HALF RING + å: 'a', // LATIN SMALL LETTER A WITH RING ABOVE + ǻ: 'a', // LATIN SMALL LETTER A WITH RING ABOVE AND ACUTE + ḁ: 'a', // LATIN SMALL LETTER A WITH RING BELOW + ⱥ: 'a', // LATIN SMALL LETTER A WITH STROKE + ã: 'a', // LATIN SMALL LETTER A WITH TILDE + ꜳ: 'aa', // LATIN SMALL LETTER AA + æ: 'ae', // LATIN SMALL LETTER AE + ǽ: 'ae', // LATIN SMALL LETTER AE WITH ACUTE + ǣ: 'ae', // LATIN SMALL LETTER AE WITH MACRON + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER ALPHA' (ɑ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER ALPHA WITH RETROFLEX HOOK' (ᶐ) + ꜵ: 'ao', // LATIN SMALL LETTER AO + ꜷ: 'au', // LATIN SMALL LETTER AU + ꜹ: 'av', // LATIN SMALL LETTER AV + ꜻ: 'av', // LATIN SMALL LETTER AV WITH HORIZONTAL BAR + ꜽ: 'ay', // LATIN SMALL LETTER AY + ḃ: 'b', // LATIN SMALL LETTER B WITH DOT ABOVE + ḅ: 'b', // LATIN SMALL LETTER B WITH DOT BELOW + ɓ: 'b', // LATIN SMALL LETTER B WITH HOOK + ḇ: 'b', // LATIN SMALL LETTER B WITH LINE BELOW + ᵬ: 'b', // LATIN SMALL LETTER B WITH MIDDLE TILDE + ᶀ: 'b', // LATIN SMALL LETTER B WITH PALATAL HOOK + ƀ: 'b', // LATIN SMALL LETTER B WITH STROKE + ƃ: 'b', // LATIN SMALL LETTER B WITH TOPBAR + ɵ: 'o', // LATIN SMALL LETTER BARRED O + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER BOTTOM HALF O' (ᴗ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER BROKEN L' (ꝇ) + ć: 'c', // LATIN SMALL LETTER C WITH ACUTE + č: 'c', // LATIN SMALL LETTER C WITH CARON + ç: 'c', // LATIN SMALL LETTER C WITH CEDILLA + ḉ: 'c', // LATIN SMALL LETTER C WITH CEDILLA AND ACUTE + ĉ: 'c', // LATIN SMALL LETTER C WITH CIRCUMFLEX + ɕ: 'c', // LATIN SMALL LETTER C WITH CURL + ċ: 'c', // LATIN SMALL LETTER C WITH DOT ABOVE + ƈ: 'c', // LATIN SMALL LETTER C WITH HOOK + ȼ: 'c', // LATIN SMALL LETTER C WITH STROKE + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER CLOSED OMEGA' (ɷ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER CLOSED OPEN E' (ʚ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER CLOSED REVERSED OPEN E' (ɞ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER CON' (ꝯ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER CUATRILLO' (ꜭ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER CUATRILLO WITH COMMA' (ꜯ) + ď: 'd', // LATIN SMALL LETTER D WITH CARON + ḑ: 'd', // LATIN SMALL LETTER D WITH CEDILLA + ḓ: 'd', // LATIN SMALL LETTER D WITH CIRCUMFLEX BELOW + ȡ: 'd', // LATIN SMALL LETTER D WITH CURL + ḋ: 'd', // LATIN SMALL LETTER D WITH DOT ABOVE + ḍ: 'd', // LATIN SMALL LETTER D WITH DOT BELOW + ɗ: 'd', // LATIN SMALL LETTER D WITH HOOK + ᶑ: 'd', // LATIN SMALL LETTER D WITH HOOK AND TAIL + ḏ: 'd', // LATIN SMALL LETTER D WITH LINE BELOW + ᵭ: 'd', // LATIN SMALL LETTER D WITH MIDDLE TILDE + ᶁ: 'd', // LATIN SMALL LETTER D WITH PALATAL HOOK + đ: 'd', // LATIN SMALL LETTER D WITH STROKE + ɖ: 'd', // LATIN SMALL LETTER D WITH TAIL + ƌ: 'd', // LATIN SMALL LETTER D WITH TOPBAR + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER DB DIGRAPH' (ȸ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER DELTA' (ẟ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER DEZH DIGRAPH' (ʤ) + ı: 'i', // LATIN SMALL LETTER DOTLESS I + ȷ: 'j', // LATIN SMALL LETTER DOTLESS J + ɟ: 'j', // LATIN SMALL LETTER DOTLESS J WITH STROKE + ʄ: 'j', // LATIN SMALL LETTER DOTLESS J WITH STROKE AND HOOK + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER DUM' (ꝱ) + dz: 'dz', // LATIN SMALL LETTER DZ + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER DZ DIGRAPH' (ʣ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER DZ DIGRAPH WITH CURL' (ʥ) + dž: 'dz', // LATIN SMALL LETTER DZ WITH CARON + é: 'e', // LATIN SMALL LETTER E WITH ACUTE + ĕ: 'e', // LATIN SMALL LETTER E WITH BREVE + ě: 'e', // LATIN SMALL LETTER E WITH CARON + ȩ: 'e', // LATIN SMALL LETTER E WITH CEDILLA + ḝ: 'e', // LATIN SMALL LETTER E WITH CEDILLA AND BREVE + ê: 'e', // LATIN SMALL LETTER E WITH CIRCUMFLEX + ế: 'e', // LATIN SMALL LETTER E WITH CIRCUMFLEX AND ACUTE + ệ: 'e', // LATIN SMALL LETTER E WITH CIRCUMFLEX AND DOT BELOW + ề: 'e', // LATIN SMALL LETTER E WITH CIRCUMFLEX AND GRAVE + ể: 'e', // LATIN SMALL LETTER E WITH CIRCUMFLEX AND HOOK ABOVE + ễ: 'e', // LATIN SMALL LETTER E WITH CIRCUMFLEX AND TILDE + ḙ: 'e', // LATIN SMALL LETTER E WITH CIRCUMFLEX BELOW + ë: 'e', // LATIN SMALL LETTER E WITH DIAERESIS + ė: 'e', // LATIN SMALL LETTER E WITH DOT ABOVE + ẹ: 'e', // LATIN SMALL LETTER E WITH DOT BELOW + ȅ: 'e', // LATIN SMALL LETTER E WITH DOUBLE GRAVE + è: 'e', // LATIN SMALL LETTER E WITH GRAVE + ẻ: 'e', // LATIN SMALL LETTER E WITH HOOK ABOVE + ȇ: 'e', // LATIN SMALL LETTER E WITH INVERTED BREVE + ē: 'e', // LATIN SMALL LETTER E WITH MACRON + ḗ: 'e', // LATIN SMALL LETTER E WITH MACRON AND ACUTE + ḕ: 'e', // LATIN SMALL LETTER E WITH MACRON AND GRAVE + ⱸ: 'e', // LATIN SMALL LETTER E WITH NOTCH + ę: 'e', // LATIN SMALL LETTER E WITH OGONEK + ᶒ: 'e', // LATIN SMALL LETTER E WITH RETROFLEX HOOK + ɇ: 'e', // LATIN SMALL LETTER E WITH STROKE + ẽ: 'e', // LATIN SMALL LETTER E WITH TILDE + ḛ: 'e', // LATIN SMALL LETTER E WITH TILDE BELOW + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER EGYPTOLOGICAL AIN' (ꜥ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER EGYPTOLOGICAL ALEF' (ꜣ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER ENG' (ŋ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER ESH' (ʃ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER ESH WITH CURL' (ʆ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER ESH WITH PALATAL HOOK' (ᶋ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER ESH WITH RETROFLEX HOOK' (ᶘ) + ꝫ: 'et', // LATIN SMALL LETTER ET + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER ETH' (ð) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER EZH' (ʒ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER EZH REVERSED' (ƹ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER EZH WITH CARON' (ǯ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER EZH WITH CURL' (ʓ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER EZH WITH RETROFLEX HOOK' (ᶚ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER EZH WITH TAIL' (ƺ) + ḟ: 'f', // LATIN SMALL LETTER F WITH DOT ABOVE + ƒ: 'f', // LATIN SMALL LETTER F WITH HOOK + ᵮ: 'f', // LATIN SMALL LETTER F WITH MIDDLE TILDE + ᶂ: 'f', // LATIN SMALL LETTER F WITH PALATAL HOOK + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER FENG DIGRAPH' (ʩ) + ǵ: 'g', // LATIN SMALL LETTER G WITH ACUTE + ğ: 'g', // LATIN SMALL LETTER G WITH BREVE + ǧ: 'g', // LATIN SMALL LETTER G WITH CARON + ģ: 'g', // LATIN SMALL LETTER G WITH CEDILLA + ĝ: 'g', // LATIN SMALL LETTER G WITH CIRCUMFLEX + ġ: 'g', // LATIN SMALL LETTER G WITH DOT ABOVE + ɠ: 'g', // LATIN SMALL LETTER G WITH HOOK + ḡ: 'g', // LATIN SMALL LETTER G WITH MACRON + ᶃ: 'g', // LATIN SMALL LETTER G WITH PALATAL HOOK + ǥ: 'g', // LATIN SMALL LETTER G WITH STROKE + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER GAMMA' (ɣ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER GLOTTAL STOP' (ɂ) + ḫ: 'h', // LATIN SMALL LETTER H WITH BREVE BELOW + ȟ: 'h', // LATIN SMALL LETTER H WITH CARON + ḩ: 'h', // LATIN SMALL LETTER H WITH CEDILLA + ĥ: 'h', // LATIN SMALL LETTER H WITH CIRCUMFLEX + ⱨ: 'h', // LATIN SMALL LETTER H WITH DESCENDER + ḧ: 'h', // LATIN SMALL LETTER H WITH DIAERESIS + ḣ: 'h', // LATIN SMALL LETTER H WITH DOT ABOVE + ḥ: 'h', // LATIN SMALL LETTER H WITH DOT BELOW + ɦ: 'h', // LATIN SMALL LETTER H WITH HOOK + ẖ: 'h', // LATIN SMALL LETTER H WITH LINE BELOW + ħ: 'h', // LATIN SMALL LETTER H WITH STROKE + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER HALF H' (ⱶ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER HENG' (ꜧ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER HENG WITH HOOK' (ɧ) + ƕ: 'hv', // LATIN SMALL LETTER HV + í: 'i', // LATIN SMALL LETTER I WITH ACUTE + ĭ: 'i', // LATIN SMALL LETTER I WITH BREVE + ǐ: 'i', // LATIN SMALL LETTER I WITH CARON + î: 'i', // LATIN SMALL LETTER I WITH CIRCUMFLEX + ï: 'i', // LATIN SMALL LETTER I WITH DIAERESIS + ḯ: 'i', // LATIN SMALL LETTER I WITH DIAERESIS AND ACUTE + ị: 'i', // LATIN SMALL LETTER I WITH DOT BELOW + ȉ: 'i', // LATIN SMALL LETTER I WITH DOUBLE GRAVE + ì: 'i', // LATIN SMALL LETTER I WITH GRAVE + ỉ: 'i', // LATIN SMALL LETTER I WITH HOOK ABOVE + ȋ: 'i', // LATIN SMALL LETTER I WITH INVERTED BREVE + ī: 'i', // LATIN SMALL LETTER I WITH MACRON + į: 'i', // LATIN SMALL LETTER I WITH OGONEK + ᶖ: 'i', // LATIN SMALL LETTER I WITH RETROFLEX HOOK + ɨ: 'i', // LATIN SMALL LETTER I WITH STROKE + ĩ: 'i', // LATIN SMALL LETTER I WITH TILDE + ḭ: 'i', // LATIN SMALL LETTER I WITH TILDE BELOW + ꝺ: 'd', // LATIN SMALL LETTER INSULAR D + ꝼ: 'f', // LATIN SMALL LETTER INSULAR F + ᵹ: 'g', // LATIN SMALL LETTER INSULAR G + ꞃ: 'r', // LATIN SMALL LETTER INSULAR R + ꞅ: 's', // LATIN SMALL LETTER INSULAR S + ꞇ: 't', // LATIN SMALL LETTER INSULAR T + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER IOTA' (ɩ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER IOTA WITH STROKE' (ᵼ) + ꝭ: 'is', // LATIN SMALL LETTER IS + ǰ: 'j', // LATIN SMALL LETTER J WITH CARON + ĵ: 'j', // LATIN SMALL LETTER J WITH CIRCUMFLEX + ʝ: 'j', // LATIN SMALL LETTER J WITH CROSSED-TAIL + ɉ: 'j', // LATIN SMALL LETTER J WITH STROKE + ḱ: 'k', // LATIN SMALL LETTER K WITH ACUTE + ǩ: 'k', // LATIN SMALL LETTER K WITH CARON + ķ: 'k', // LATIN SMALL LETTER K WITH CEDILLA + ⱪ: 'k', // LATIN SMALL LETTER K WITH DESCENDER + ꝃ: 'k', // LATIN SMALL LETTER K WITH DIAGONAL STROKE + ḳ: 'k', // LATIN SMALL LETTER K WITH DOT BELOW + ƙ: 'k', // LATIN SMALL LETTER K WITH HOOK + ḵ: 'k', // LATIN SMALL LETTER K WITH LINE BELOW + ᶄ: 'k', // LATIN SMALL LETTER K WITH PALATAL HOOK + ꝁ: 'k', // LATIN SMALL LETTER K WITH STROKE + ꝅ: 'k', // LATIN SMALL LETTER K WITH STROKE AND DIAGONAL STROKE + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER KRA' (ĸ) + ĺ: 'l', // LATIN SMALL LETTER L WITH ACUTE + ƚ: 'l', // LATIN SMALL LETTER L WITH BAR + ɬ: 'l', // LATIN SMALL LETTER L WITH BELT + ľ: 'l', // LATIN SMALL LETTER L WITH CARON + ļ: 'l', // LATIN SMALL LETTER L WITH CEDILLA + ḽ: 'l', // LATIN SMALL LETTER L WITH CIRCUMFLEX BELOW + ȴ: 'l', // LATIN SMALL LETTER L WITH CURL + ḷ: 'l', // LATIN SMALL LETTER L WITH DOT BELOW + ḹ: 'l', // LATIN SMALL LETTER L WITH DOT BELOW AND MACRON + ⱡ: 'l', // LATIN SMALL LETTER L WITH DOUBLE BAR + ꝉ: 'l', // LATIN SMALL LETTER L WITH HIGH STROKE + ḻ: 'l', // LATIN SMALL LETTER L WITH LINE BELOW + ŀ: 'l', // LATIN SMALL LETTER L WITH MIDDLE DOT + ɫ: 'l', // LATIN SMALL LETTER L WITH MIDDLE TILDE + ᶅ: 'l', // LATIN SMALL LETTER L WITH PALATAL HOOK + ɭ: 'l', // LATIN SMALL LETTER L WITH RETROFLEX HOOK + ł: 'l', // LATIN SMALL LETTER L WITH STROKE + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER LAMBDA WITH STROKE' (ƛ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER LEZH' (ɮ) + lj: 'lj', // LATIN SMALL LETTER LJ + ſ: 's', // LATIN SMALL LETTER LONG S + ẜ: 's', // LATIN SMALL LETTER LONG S WITH DIAGONAL STROKE + ẛ: 's', // LATIN SMALL LETTER LONG S WITH DOT ABOVE + ẝ: 's', // LATIN SMALL LETTER LONG S WITH HIGH STROKE + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER LS DIGRAPH' (ʪ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER LUM' (ꝲ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER LZ DIGRAPH' (ʫ) + ḿ: 'm', // LATIN SMALL LETTER M WITH ACUTE + ṁ: 'm', // LATIN SMALL LETTER M WITH DOT ABOVE + ṃ: 'm', // LATIN SMALL LETTER M WITH DOT BELOW + ɱ: 'm', // LATIN SMALL LETTER M WITH HOOK + ᵯ: 'm', // LATIN SMALL LETTER M WITH MIDDLE TILDE + ᶆ: 'm', // LATIN SMALL LETTER M WITH PALATAL HOOK + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER MIDDLE-WELSH LL' (ỻ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER MIDDLE-WELSH V' (ỽ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER MUM' (ꝳ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER N PRECEDED BY APOSTROPHE' (ʼn) + ń: 'n', // LATIN SMALL LETTER N WITH ACUTE + ň: 'n', // LATIN SMALL LETTER N WITH CARON + ņ: 'n', // LATIN SMALL LETTER N WITH CEDILLA + ṋ: 'n', // LATIN SMALL LETTER N WITH CIRCUMFLEX BELOW + ȵ: 'n', // LATIN SMALL LETTER N WITH CURL + ṅ: 'n', // LATIN SMALL LETTER N WITH DOT ABOVE + ṇ: 'n', // LATIN SMALL LETTER N WITH DOT BELOW + ǹ: 'n', // LATIN SMALL LETTER N WITH GRAVE + ɲ: 'n', // LATIN SMALL LETTER N WITH LEFT HOOK + ṉ: 'n', // LATIN SMALL LETTER N WITH LINE BELOW + ƞ: 'n', // LATIN SMALL LETTER N WITH LONG RIGHT LEG + ᵰ: 'n', // LATIN SMALL LETTER N WITH MIDDLE TILDE + ᶇ: 'n', // LATIN SMALL LETTER N WITH PALATAL HOOK + ɳ: 'n', // LATIN SMALL LETTER N WITH RETROFLEX HOOK + ñ: 'n', // LATIN SMALL LETTER N WITH TILDE + nj: 'nj', // LATIN SMALL LETTER NJ + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER NUM' (ꝴ) + ó: 'o', // LATIN SMALL LETTER O WITH ACUTE + ŏ: 'o', // LATIN SMALL LETTER O WITH BREVE + ǒ: 'o', // LATIN SMALL LETTER O WITH CARON + ô: 'o', // LATIN SMALL LETTER O WITH CIRCUMFLEX + ố: 'o', // LATIN SMALL LETTER O WITH CIRCUMFLEX AND ACUTE + ộ: 'o', // LATIN SMALL LETTER O WITH CIRCUMFLEX AND DOT BELOW + ồ: 'o', // LATIN SMALL LETTER O WITH CIRCUMFLEX AND GRAVE + ổ: 'o', // LATIN SMALL LETTER O WITH CIRCUMFLEX AND HOOK ABOVE + ỗ: 'o', // LATIN SMALL LETTER O WITH CIRCUMFLEX AND TILDE + ö: 'o', // LATIN SMALL LETTER O WITH DIAERESIS + ȫ: 'o', // LATIN SMALL LETTER O WITH DIAERESIS AND MACRON + ȯ: 'o', // LATIN SMALL LETTER O WITH DOT ABOVE + ȱ: 'o', // LATIN SMALL LETTER O WITH DOT ABOVE AND MACRON + ọ: 'o', // LATIN SMALL LETTER O WITH DOT BELOW + ő: 'o', // LATIN SMALL LETTER O WITH DOUBLE ACUTE + ȍ: 'o', // LATIN SMALL LETTER O WITH DOUBLE GRAVE + ò: 'o', // LATIN SMALL LETTER O WITH GRAVE + ỏ: 'o', // LATIN SMALL LETTER O WITH HOOK ABOVE + ơ: 'o', // LATIN SMALL LETTER O WITH HORN + ớ: 'o', // LATIN SMALL LETTER O WITH HORN AND ACUTE + ợ: 'o', // LATIN SMALL LETTER O WITH HORN AND DOT BELOW + ờ: 'o', // LATIN SMALL LETTER O WITH HORN AND GRAVE + ở: 'o', // LATIN SMALL LETTER O WITH HORN AND HOOK ABOVE + ỡ: 'o', // LATIN SMALL LETTER O WITH HORN AND TILDE + ȏ: 'o', // LATIN SMALL LETTER O WITH INVERTED BREVE + ꝋ: 'o', // LATIN SMALL LETTER O WITH LONG STROKE OVERLAY + ꝍ: 'o', // LATIN SMALL LETTER O WITH LOOP + ⱺ: 'o', // LATIN SMALL LETTER O WITH LOW RING INSIDE + ō: 'o', // LATIN SMALL LETTER O WITH MACRON + ṓ: 'o', // LATIN SMALL LETTER O WITH MACRON AND ACUTE + ṑ: 'o', // LATIN SMALL LETTER O WITH MACRON AND GRAVE + ǫ: 'o', // LATIN SMALL LETTER O WITH OGONEK + ǭ: 'o', // LATIN SMALL LETTER O WITH OGONEK AND MACRON + ø: 'o', // LATIN SMALL LETTER O WITH STROKE + ǿ: 'o', // LATIN SMALL LETTER O WITH STROKE AND ACUTE + õ: 'o', // LATIN SMALL LETTER O WITH TILDE + ṍ: 'o', // LATIN SMALL LETTER O WITH TILDE AND ACUTE + ṏ: 'o', // LATIN SMALL LETTER O WITH TILDE AND DIAERESIS + ȭ: 'o', // LATIN SMALL LETTER O WITH TILDE AND MACRON + ƣ: 'oi', // LATIN SMALL LETTER OI + ꝏ: 'oo', // LATIN SMALL LETTER OO + ɛ: 'e', // LATIN SMALL LETTER OPEN E + ᶓ: 'e', // LATIN SMALL LETTER OPEN E WITH RETROFLEX HOOK + ɔ: 'o', // LATIN SMALL LETTER OPEN O + ᶗ: 'o', // LATIN SMALL LETTER OPEN O WITH RETROFLEX HOOK + ȣ: 'ou', // LATIN SMALL LETTER OU + ṕ: 'p', // LATIN SMALL LETTER P WITH ACUTE + ṗ: 'p', // LATIN SMALL LETTER P WITH DOT ABOVE + ꝓ: 'p', // LATIN SMALL LETTER P WITH FLOURISH + ƥ: 'p', // LATIN SMALL LETTER P WITH HOOK + ᵱ: 'p', // LATIN SMALL LETTER P WITH MIDDLE TILDE + ᶈ: 'p', // LATIN SMALL LETTER P WITH PALATAL HOOK + ꝕ: 'p', // LATIN SMALL LETTER P WITH SQUIRREL TAIL + ᵽ: 'p', // LATIN SMALL LETTER P WITH STROKE + ꝑ: 'p', // LATIN SMALL LETTER P WITH STROKE THROUGH DESCENDER + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER PHI' (ɸ) + ꝙ: 'q', // LATIN SMALL LETTER Q WITH DIAGONAL STROKE + ʠ: 'q', // LATIN SMALL LETTER Q WITH HOOK + ɋ: 'q', // LATIN SMALL LETTER Q WITH HOOK TAIL + ꝗ: 'q', // LATIN SMALL LETTER Q WITH STROKE THROUGH DESCENDER + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER QP DIGRAPH' (ȹ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER R ROTUNDA' (ꝛ) + ŕ: 'r', // LATIN SMALL LETTER R WITH ACUTE + ř: 'r', // LATIN SMALL LETTER R WITH CARON + ŗ: 'r', // LATIN SMALL LETTER R WITH CEDILLA + ṙ: 'r', // LATIN SMALL LETTER R WITH DOT ABOVE + ṛ: 'r', // LATIN SMALL LETTER R WITH DOT BELOW + ṝ: 'r', // LATIN SMALL LETTER R WITH DOT BELOW AND MACRON + ȑ: 'r', // LATIN SMALL LETTER R WITH DOUBLE GRAVE + ɾ: 'r', // LATIN SMALL LETTER R WITH FISHHOOK + ᵳ: 'r', // LATIN SMALL LETTER R WITH FISHHOOK AND MIDDLE TILDE + ȓ: 'r', // LATIN SMALL LETTER R WITH INVERTED BREVE + ṟ: 'r', // LATIN SMALL LETTER R WITH LINE BELOW + ɼ: 'r', // LATIN SMALL LETTER R WITH LONG LEG + ᵲ: 'r', // LATIN SMALL LETTER R WITH MIDDLE TILDE + ᶉ: 'r', // LATIN SMALL LETTER R WITH PALATAL HOOK + ɍ: 'r', // LATIN SMALL LETTER R WITH STROKE + ɽ: 'r', // LATIN SMALL LETTER R WITH TAIL + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER RAMS HORN' (ɤ) + ↄ: 'c', // LATIN SMALL LETTER REVERSED C + ꜿ: 'c', // LATIN SMALL LETTER REVERSED C WITH DOT + ɘ: 'e', // LATIN SMALL LETTER REVERSED E + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER REVERSED OPEN E' (ɜ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER REVERSED OPEN E WITH HOOK' (ɝ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER REVERSED OPEN E WITH RETROFLEX HOOK' (ᶔ) + ɿ: 'r', // LATIN SMALL LETTER REVERSED R WITH FISHHOOK + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER RUM' (ꝵ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER RUM ROTUNDA' (ꝝ) + ś: 's', // LATIN SMALL LETTER S WITH ACUTE + ṥ: 's', // LATIN SMALL LETTER S WITH ACUTE AND DOT ABOVE + š: 's', // LATIN SMALL LETTER S WITH CARON + ṧ: 's', // LATIN SMALL LETTER S WITH CARON AND DOT ABOVE + ş: 's', // LATIN SMALL LETTER S WITH CEDILLA + ŝ: 's', // LATIN SMALL LETTER S WITH CIRCUMFLEX + ș: 's', // LATIN SMALL LETTER S WITH COMMA BELOW + ṡ: 's', // LATIN SMALL LETTER S WITH DOT ABOVE + ṣ: 's', // LATIN SMALL LETTER S WITH DOT BELOW + ṩ: 's', // LATIN SMALL LETTER S WITH DOT BELOW AND DOT ABOVE + ʂ: 's', // LATIN SMALL LETTER S WITH HOOK + ᵴ: 's', // LATIN SMALL LETTER S WITH MIDDLE TILDE + ᶊ: 's', // LATIN SMALL LETTER S WITH PALATAL HOOK + ȿ: 's', // LATIN SMALL LETTER S WITH SWASH TAIL + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER SALTILLO' (ꞌ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER SCHWA' (ə) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER SCHWA WITH HOOK' (ɚ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER SCHWA WITH RETROFLEX HOOK' (ᶕ) + ɡ: 'g', // LATIN SMALL LETTER SCRIPT G + ß: 'ss', // LATIN SMALL LETTER SHARP S + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER SIDEWAYS DIAERESIZED U' (ᴞ) + ᴑ: 'o', // LATIN SMALL LETTER SIDEWAYS O + ᴓ: 'o', // LATIN SMALL LETTER SIDEWAYS O WITH STROKE + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER SIDEWAYS OPEN O' (ᴒ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER SIDEWAYS TURNED M' (ᴟ) + ᴝ: 'u', // LATIN SMALL LETTER SIDEWAYS U + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER SQUAT REVERSED ESH' (ʅ) + ť: 't', // LATIN SMALL LETTER T WITH CARON + ţ: 't', // LATIN SMALL LETTER T WITH CEDILLA + ṱ: 't', // LATIN SMALL LETTER T WITH CIRCUMFLEX BELOW + ț: 't', // LATIN SMALL LETTER T WITH COMMA BELOW + ȶ: 't', // LATIN SMALL LETTER T WITH CURL + ẗ: 't', // LATIN SMALL LETTER T WITH DIAERESIS + ⱦ: 't', // LATIN SMALL LETTER T WITH DIAGONAL STROKE + ṫ: 't', // LATIN SMALL LETTER T WITH DOT ABOVE + ṭ: 't', // LATIN SMALL LETTER T WITH DOT BELOW + ƭ: 't', // LATIN SMALL LETTER T WITH HOOK + ṯ: 't', // LATIN SMALL LETTER T WITH LINE BELOW + ᵵ: 't', // LATIN SMALL LETTER T WITH MIDDLE TILDE + ƫ: 't', // LATIN SMALL LETTER T WITH PALATAL HOOK + ʈ: 't', // LATIN SMALL LETTER T WITH RETROFLEX HOOK + ŧ: 't', // LATIN SMALL LETTER T WITH STROKE + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TAILLESS PHI' (ⱷ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TC DIGRAPH WITH CURL' (ʨ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TESH DIGRAPH' (ʧ) + ᵺ: 'th', // LATIN SMALL LETTER TH WITH STRIKETHROUGH + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER THORN' (þ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER THORN WITH STROKE' (ꝥ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER THORN WITH STROKE THROUGH DESCENDER' (ꝧ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TONE FIVE' (ƽ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TONE SIX' (ƅ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TONE TWO' (ƨ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TOP HALF O' (ᴖ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TRESILLO' (ꜫ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TS DIGRAPH' (ʦ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TUM' (ꝷ) + ɐ: 'a', // LATIN SMALL LETTER TURNED A + ᴂ: 'ae', // LATIN SMALL LETTER TURNED AE + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TURNED ALPHA' (ɒ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TURNED DELTA' (ƍ) + ǝ: 'e', // LATIN SMALL LETTER TURNED E + ᵷ: 'g', // LATIN SMALL LETTER TURNED G + ɥ: 'h', // LATIN SMALL LETTER TURNED H + ʮ: 'h', // LATIN SMALL LETTER TURNED H WITH FISHHOOK + ʯ: 'h', // LATIN SMALL LETTER TURNED H WITH FISHHOOK AND TAIL + ᴉ: 'i', // LATIN SMALL LETTER TURNED I + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TURNED INSULAR G' (ꝿ) + ʞ: 'k', // LATIN SMALL LETTER TURNED K + ꞁ: 'l', // LATIN SMALL LETTER TURNED L + ɯ: 'm', // LATIN SMALL LETTER TURNED M + ɰ: 'm', // LATIN SMALL LETTER TURNED M WITH LONG LEG + ᴔ: 'oe', // LATIN SMALL LETTER TURNED OE + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER TURNED OPEN E' (ᴈ) + ɹ: 'r', // LATIN SMALL LETTER TURNED R + ɻ: 'r', // LATIN SMALL LETTER TURNED R WITH HOOK + ɺ: 'r', // LATIN SMALL LETTER TURNED R WITH LONG LEG + ⱹ: 'r', // LATIN SMALL LETTER TURNED R WITH TAIL + ʇ: 't', // LATIN SMALL LETTER TURNED T + ʌ: 'v', // LATIN SMALL LETTER TURNED V + ʍ: 'w', // LATIN SMALL LETTER TURNED W + ʎ: 'y', // LATIN SMALL LETTER TURNED Y + ꜩ: 'tz', // LATIN SMALL LETTER TZ + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER U BAR' (ʉ) + ú: 'u', // LATIN SMALL LETTER U WITH ACUTE + ŭ: 'u', // LATIN SMALL LETTER U WITH BREVE + ǔ: 'u', // LATIN SMALL LETTER U WITH CARON + û: 'u', // LATIN SMALL LETTER U WITH CIRCUMFLEX + ṷ: 'u', // LATIN SMALL LETTER U WITH CIRCUMFLEX BELOW + ü: 'u', // LATIN SMALL LETTER U WITH DIAERESIS + ǘ: 'u', // LATIN SMALL LETTER U WITH DIAERESIS AND ACUTE + ǚ: 'u', // LATIN SMALL LETTER U WITH DIAERESIS AND CARON + ǜ: 'u', // LATIN SMALL LETTER U WITH DIAERESIS AND GRAVE + ǖ: 'u', // LATIN SMALL LETTER U WITH DIAERESIS AND MACRON + ṳ: 'u', // LATIN SMALL LETTER U WITH DIAERESIS BELOW + ụ: 'u', // LATIN SMALL LETTER U WITH DOT BELOW + ű: 'u', // LATIN SMALL LETTER U WITH DOUBLE ACUTE + ȕ: 'u', // LATIN SMALL LETTER U WITH DOUBLE GRAVE + ù: 'u', // LATIN SMALL LETTER U WITH GRAVE + ủ: 'u', // LATIN SMALL LETTER U WITH HOOK ABOVE + ư: 'u', // LATIN SMALL LETTER U WITH HORN + ứ: 'u', // LATIN SMALL LETTER U WITH HORN AND ACUTE + ự: 'u', // LATIN SMALL LETTER U WITH HORN AND DOT BELOW + ừ: 'u', // LATIN SMALL LETTER U WITH HORN AND GRAVE + ử: 'u', // LATIN SMALL LETTER U WITH HORN AND HOOK ABOVE + ữ: 'u', // LATIN SMALL LETTER U WITH HORN AND TILDE + ȗ: 'u', // LATIN SMALL LETTER U WITH INVERTED BREVE + ū: 'u', // LATIN SMALL LETTER U WITH MACRON + ṻ: 'u', // LATIN SMALL LETTER U WITH MACRON AND DIAERESIS + ų: 'u', // LATIN SMALL LETTER U WITH OGONEK + ᶙ: 'u', // LATIN SMALL LETTER U WITH RETROFLEX HOOK + ů: 'u', // LATIN SMALL LETTER U WITH RING ABOVE + ũ: 'u', // LATIN SMALL LETTER U WITH TILDE + ṹ: 'u', // LATIN SMALL LETTER U WITH TILDE AND ACUTE + ṵ: 'u', // LATIN SMALL LETTER U WITH TILDE BELOW + ᵫ: 'ue', // LATIN SMALL LETTER UE + ꝸ: 'um', // LATIN SMALL LETTER UM + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER UPSILON' (ʊ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER UPSILON WITH STROKE' (ᵿ) + ⱴ: 'v', // LATIN SMALL LETTER V WITH CURL + ꝟ: 'v', // LATIN SMALL LETTER V WITH DIAGONAL STROKE + ṿ: 'v', // LATIN SMALL LETTER V WITH DOT BELOW + ʋ: 'v', // LATIN SMALL LETTER V WITH HOOK + ᶌ: 'v', // LATIN SMALL LETTER V WITH PALATAL HOOK + ⱱ: 'v', // LATIN SMALL LETTER V WITH RIGHT HOOK + ṽ: 'v', // LATIN SMALL LETTER V WITH TILDE + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER VEND' (ꝩ) + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER VISIGOTHIC Z' (ꝣ) + ꝡ: 'vy', // LATIN SMALL LETTER VY + ẃ: 'w', // LATIN SMALL LETTER W WITH ACUTE + ŵ: 'w', // LATIN SMALL LETTER W WITH CIRCUMFLEX + ẅ: 'w', // LATIN SMALL LETTER W WITH DIAERESIS + ẇ: 'w', // LATIN SMALL LETTER W WITH DOT ABOVE + ẉ: 'w', // LATIN SMALL LETTER W WITH DOT BELOW + ẁ: 'w', // LATIN SMALL LETTER W WITH GRAVE + ⱳ: 'w', // LATIN SMALL LETTER W WITH HOOK + ẘ: 'w', // LATIN SMALL LETTER W WITH RING ABOVE + ẍ: 'x', // LATIN SMALL LETTER X WITH DIAERESIS + ẋ: 'x', // LATIN SMALL LETTER X WITH DOT ABOVE + ᶍ: 'x', // LATIN SMALL LETTER X WITH PALATAL HOOK + ý: 'y', // LATIN SMALL LETTER Y WITH ACUTE + ŷ: 'y', // LATIN SMALL LETTER Y WITH CIRCUMFLEX + ÿ: 'y', // LATIN SMALL LETTER Y WITH DIAERESIS + ẏ: 'y', // LATIN SMALL LETTER Y WITH DOT ABOVE + ỵ: 'y', // LATIN SMALL LETTER Y WITH DOT BELOW + ỳ: 'y', // LATIN SMALL LETTER Y WITH GRAVE + ƴ: 'y', // LATIN SMALL LETTER Y WITH HOOK + ỷ: 'y', // LATIN SMALL LETTER Y WITH HOOK ABOVE + ỿ: 'y', // LATIN SMALL LETTER Y WITH LOOP + ȳ: 'y', // LATIN SMALL LETTER Y WITH MACRON + ẙ: 'y', // LATIN SMALL LETTER Y WITH RING ABOVE + ɏ: 'y', // LATIN SMALL LETTER Y WITH STROKE + ỹ: 'y', // LATIN SMALL LETTER Y WITH TILDE + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LETTER YOGH' (ȝ) + ź: 'z', // LATIN SMALL LETTER Z WITH ACUTE + ž: 'z', // LATIN SMALL LETTER Z WITH CARON + ẑ: 'z', // LATIN SMALL LETTER Z WITH CIRCUMFLEX + ʑ: 'z', // LATIN SMALL LETTER Z WITH CURL + ⱬ: 'z', // LATIN SMALL LETTER Z WITH DESCENDER + ż: 'z', // LATIN SMALL LETTER Z WITH DOT ABOVE + ẓ: 'z', // LATIN SMALL LETTER Z WITH DOT BELOW + ȥ: 'z', // LATIN SMALL LETTER Z WITH HOOK + ẕ: 'z', // LATIN SMALL LETTER Z WITH LINE BELOW + ᵶ: 'z', // LATIN SMALL LETTER Z WITH MIDDLE TILDE + ᶎ: 'z', // LATIN SMALL LETTER Z WITH PALATAL HOOK + ʐ: 'z', // LATIN SMALL LETTER Z WITH RETROFLEX HOOK + ƶ: 'z', // LATIN SMALL LETTER Z WITH STROKE + ɀ: 'z', // LATIN SMALL LETTER Z WITH SWASH TAIL + ff: 'ff', // LATIN SMALL LIGATURE FF + ffi: 'ffi', // LATIN SMALL LIGATURE FFI + ffl: 'ffl', // LATIN SMALL LIGATURE FFL + fi: 'fi', // LATIN SMALL LIGATURE FI + fl: 'fl', // LATIN SMALL LIGATURE FL + ij: 'ij', // LATIN SMALL LIGATURE IJ + // CANNOT FIND APPROXIMATION FOR 'LATIN SMALL LIGATURE LONG S T' (ſt) + œ: 'oe', // LATIN SMALL LIGATURE OE + st: 'st', // LATIN SMALL LIGATURE ST + ₐ: 'a', // LATIN SUBSCRIPT SMALL LETTER A + ₑ: 'e', // LATIN SUBSCRIPT SMALL LETTER E + ᵢ: 'i', // LATIN SUBSCRIPT SMALL LETTER I + ⱼ: 'j', // LATIN SUBSCRIPT SMALL LETTER J + ₒ: 'o', // LATIN SUBSCRIPT SMALL LETTER O + ᵣ: 'r', // LATIN SUBSCRIPT SMALL LETTER R + // CANNOT FIND APPROXIMATION FOR 'LATIN SUBSCRIPT SMALL LETTER SCHWA' (ₔ) + ᵤ: 'u', // LATIN SUBSCRIPT SMALL LETTER U + ᵥ: 'v', // LATIN SUBSCRIPT SMALL LETTER V + ₓ: 'x', // LATIN SUBSCRIPT SMALL LETTER X +}; diff --git a/app/utils/url/latinise.ts b/app/utils/url/latinise.ts new file mode 100644 index 0000000000..f2526ef304 --- /dev/null +++ b/app/utils/url/latinise.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +// Credit to http://semplicewebsites.com/removing-accents-javascript + +import {latinMap} from './latin_map'; + +export function map(x: string) { + return latinMap[x] || x; +} + +export function latinise(input: string) { + return input.replace(/[^A-Za-z0-9]/g, map); +} diff --git a/assets/base/images/google.png b/assets/base/images/google.png new file mode 100644 index 0000000000000000000000000000000000000000..92718843233010c461376226729a4bb5350c8f85 GIT binary patch literal 601 zcmV-f0;c_mP)+S}-;Kl3dXs&9!pZMJ?DuZtn(Z=EDd{3(G_eI74o;XaLX19Tqjbkg@G@S*5610REE=_7aXce=729Ek7y}l>10eSarmA(?4+IRvPSF7N z!62DQ+_|v{MgWFkgXjXRhQ5+Fq7>#v56M|k0Gmn%7jFePS#I$tgg+#+LVS0Qn_bXOx{#)W; zv@jBTM5esEpOzbnOR++X17^hzcpWeVyG2dd0UvG+)l%TY|(1eqf00000NkvXXu0mjffE@!I literal 0 HcmV?d00001 diff --git a/assets/base/images/logo.png b/assets/base/images/logo.png new file mode 100755 index 0000000000000000000000000000000000000000..2ed25e06d50f9e96028c1602e694f200a4c7e392 GIT binary patch literal 32490 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 + +- +- {this._renderGroupedChildren()} ++ ++ ++ {this._renderGroupedChildren()} ++ + + +- + ); + } + } diff --git a/types/api/apps.d.ts b/types/api/apps.d.ts new file mode 100644 index 0000000000..f46ff82cdf --- /dev/null +++ b/types/api/apps.d.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type AppManifest = { + app_id: string; + display_name: string; + description?: string; + homepage_url?: string; + root_url: string; +}; + +type AppModalState = { + form: AppForm; + call: AppCallRequest; +}; + +type AppsState = { + bindings: AppBinding[]; +}; + +type AppBinding = { + app_id: string; + location?: string; + icon?: string; + + // Label is the (usually short) primary text to display at the location. + // - For LocationPostMenu is the menu item text. + // - For LocationChannelHeader is the dropdown text. + // - For LocationCommand is the name of the command + label: string; + + // Hint is the secondary text to display + // - LocationPostMenu: not used + // - LocationChannelHeader: tooltip + // - LocationCommand: the "Hint" line + hint?: string; + + // Description is the (optional) extended help text, used in modals and autocomplete + description?: string; + + role_id?: string; + depends_on_team?: boolean; + depends_on_channel?: boolean; + depends_on_user?: boolean; + depends_on_post?: boolean; + + // A Binding is either to a Call, or is a "container" for other locations - + // i.e. menu sub-items or subcommands. + call?: AppCall; + bindings?: AppBinding[]; + form?: AppForm; +}; + +type AppCallValues = { + [name: string]: any; +}; + +type AppCallType = string; + +type AppCall = { + path: string; + expand?: AppExpand; + state?: any; +}; + +type AppCallRequest = AppCall & { + context: AppContext; + values?: AppCallValues; + raw_command?: string; + selected_field?: string; + query?: string; +}; + +type AppCallResponseType = string; + +type AppCallResponse = { + type: AppCallResponseType; + markdown?: string; + data?: Res; + error?: string; + navigate_to_url?: string; + use_external_browser?: boolean; + call?: AppCall; + form?: AppForm; + app_metadata?: AppMetadataForClient; +}; + +type AppMetadataForClient = { + bot_user_id: string; + bot_username: string; +}; + +type AppContext = { + app_id: string; + location?: string; + acting_user_id?: string; + user_id?: string; + channel_id?: string; + team_id?: string; + post_id?: string; + root_id?: string; + props?: AppContextProps; + user_agent?: string; +}; + +type AppContextProps = { + [name: string]: string; +}; + +type AppExpandLevel = string; + +type AppExpand = { + app?: AppExpandLevel; + acting_user?: AppExpandLevel; + channel?: AppExpandLevel; + config?: AppExpandLevel; + mentioned?: AppExpandLevel; + parent_post?: AppExpandLevel; + post?: AppExpandLevel; + root_post?: AppExpandLevel; + team?: AppExpandLevel; + user?: AppExpandLevel; +}; + +type AppForm = { + title?: string; + header?: string; + footer?: string; + icon?: string; + submit_buttons?: string; + cancel_button?: boolean; + submit_on_cancel?: boolean; + fields: AppField[]; + call?: AppCall; + depends_on?: string[]; +}; + +type AppFormValue = string | AppSelectOption | boolean | null; +type AppFormValues = {[name: string]: AppFormValue}; + +type AppSelectOption = { + label: string; + value: string; + icon_data?: string; +}; + +type AppFieldType = string; + +// This should go in mattermost-redux +type AppField = { + + // Name is the name of the JSON field to use. + name: string; + type: AppFieldType; + is_required?: boolean; + readonly?: boolean; + + // Present (default) value of the field + value?: AppFormValue; + + description?: string; + + label?: string; + hint?: string; + position?: number; + + modal_label?: string; + + // Select props + refresh?: boolean; + options?: AppSelectOption[]; + multiselect?: boolean; + + // Text props + subtype?: string; + min_length?: number; + max_length?: number; +}; + +type AutocompleteSuggestion = { + suggestion: string; + complete?: string; + description?: string; + hint?: string; + iconData?: string; +}; + +type AutocompleteSuggestionWithComplete = AutocompleteSuggestion & { + complete: string; +}; + +type AutocompleteElement = AppField; +type AutocompleteStaticSelect = AutocompleteElement & { + options: AppSelectOption[]; +}; + +type AutocompleteDynamicSelect = AutocompleteElement; + +type AutocompleteUserSelect = AutocompleteElement; + +type AutocompleteChannelSelect = AutocompleteElement; + +type FormResponseData = { + errors?: { + [field: string]: string; + }; +}; + +type AppLookupResponse = { + items: AppSelectOption[]; +}; diff --git a/types/api/bots.d.ts b/types/api/bots.d.ts new file mode 100644 index 0000000000..e96cc2647c --- /dev/null +++ b/types/api/bots.d.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type Bot = { + user_id: string ; + username: string ; + display_name?: string ; + description?: string ; + owner_id: string ; + create_at: number ; + update_at: number ; + delete_at: number ; +} + +// BotPatch is a description of what fields to update on an existing bot. +type BotPatch = { + username: string; + display_name: string; + description: string; +} diff --git a/types/api/channels.d.ts b/types/api/channels.d.ts new file mode 100644 index 0000000000..7909d3d975 --- /dev/null +++ b/types/api/channels.d.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +type ChannelType = 'O' | 'P' | 'D' | 'G'; +type ChannelStats = { + channel_id: string; + member_count: number; + pinnedpost_count: number; +}; +type ChannelNotifyProps = { + desktop: 'default' | 'all' | 'mention' | 'none'; + email: 'default' | 'all' | 'mention' | 'none'; + mark_unread: 'all' | 'mention'; + push: 'default' | 'all' | 'mention' | 'none'; + ignore_channel_mentions: 'default' | 'off' | 'on'; +}; +type Channel = { + id: string; + create_at: number; + update_at: number; + delete_at: number; + team_id: string; + type: ChannelType; + display_name: string; + name: string; + header: string; + purpose: string; + last_post_at: number; + total_msg_count: number; + extra_update_at: number; + creator_id: string; + scheme_id: string; + isCurrent?: boolean; + teammate_id?: string; + status?: string; + fake?: boolean; + group_constrained: boolean; +}; +type ChannelWithTeamData = Channel & { + team_display_name: string; + team_name: string; + team_update_at: number; +} +type ChannelMembership = { + channel_id: string; + user_id: string; + roles: string; + last_viewed_at: number; + msg_count: number; + mention_count: number; + notify_props: Partial; + last_update_at: number; + scheme_user: boolean; + scheme_admin: boolean; + post_root_id?: string; +}; +type ChannelUnread = { + channel_id: string; + user_id: string; + team_id: string; + msg_count: number; + mention_count: number; + last_viewed_at: number; + deltaMsgs: number; +}; +type ChannelsState = { + currentChannelId: string; + channels: IDMappedObjects; + channelsInTeam: RelationOneToMany; + myMembers: RelationOneToOne; + membersInChannel: RelationOneToOne>; + stats: RelationOneToOne; + groupsAssociatedToChannel: any; + totalCount: number; + manuallyUnread: RelationOneToOne; + channelMemberCountsByGroup: RelationOneToOne; +}; + +type ChannelModeration = { + name: string; + roles: { + guests?: { + value: boolean; + enabled: boolean; + }; + members: { + value: boolean; + enabled: boolean; + }; + }; +}; + +type ChannelModerationPatch = { + name: string; + roles: { + guests?: boolean; + members?: boolean; + }; +}; + +type ChannelMemberCountByGroup = { + group_id: string; + channel_member_count: number; + channel_member_timezones_count: number; +}; + +type ChannelMemberCountsByGroup = Record; diff --git a/types/api/client4.d.ts b/types/api/client4.d.ts index a1683f5b0a..4bb3f85e68 100644 --- a/types/api/client4.d.ts +++ b/types/api/client4.d.ts @@ -1,5 +1,8 @@ -export declare type logLevel = 'ERROR' | 'WARNING' | 'INFO'; -export declare type GenericClientResponse = { +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +declare type logLevel = 'ERROR' | 'WARNING' | 'INFO'; +declare type GenericClientResponse = { response: any; headers: Map; data: any; @@ -14,14 +17,14 @@ declare type ErrorInvalidResponse = { defaultMessage: string; }; }; -export declare type ErrorApi = { +declare type ErrorApi = { message: string; server_error_id: string; status_code: number; url: string; }; -export declare type Client4Error = ErrorOffline | ErrorInvalidResponse | ErrorApi; -export declare type Options = { +declare type Client4Error = ErrorOffline | ErrorInvalidResponse | ErrorApi; +declare type ClientOptions = { headers?: { [x: string]: string; }; @@ -30,4 +33,3 @@ export declare type Options = { credentials?: 'omit' | 'same-origin' | 'include'; body?: any; }; -export {}; diff --git a/types/api/config.d.ts b/types/api/config.d.ts index 4ef645d0ae..1b17cf93bc 100644 --- a/types/api/config.d.ts +++ b/types/api/config.d.ts @@ -82,6 +82,7 @@ interface ClientConfig { EnableSignUpWithGitLab: string; EnableSignUpWithGoogle: string; EnableSignUpWithOffice365: string; + EnableSignUpWithOpenId: string; EnableSVGs: string; EnableTesting: string; EnableThemeSelection: string; @@ -129,6 +130,8 @@ interface ClientConfig { MaxFileSize: string; MaxNotificationsPerChannel: string; MinimumHashtagLength: string; + OpenIdButtonColor: string; + OpenIdButtonText: string; PasswordMinimumLength: string; PasswordRequireLowercase: string; PasswordRequireNumber: string; diff --git a/types/api/emojis.d.ts b/types/api/emojis.d.ts new file mode 100644 index 0000000000..7947d3ac43 --- /dev/null +++ b/types/api/emojis.d.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type EmojiCategory = ( + | 'recent' + | 'people' + | 'nature' + | 'foods' + | 'activity' + | 'places' + | 'objects' + | 'symbols' + | 'flags' + | 'custom' +); + +type CustomEmoji = { + id: string; + create_at: number; + update_at: number; + delete_at: number; + creator_id: string; + name: string; + category: 'custom'; +}; + +type SystemEmoji = { + filename: string; + aliases: Array; + category: EmojiCategory; + batch: number; +}; + +type Emoji = SystemEmoji | CustomEmoji; + +type EmojisState = { + customEmoji: { + [x: string]: CustomEmoji; + }; + nonExistentEmoji: Set; +}; diff --git a/types/api/error.d.ts b/types/api/error.d.ts new file mode 100644 index 0000000000..e8adf47e29 --- /dev/null +++ b/types/api/error.d.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type ApiError = { + server_error_id?: string; + stack?: string; + message: string; + status_code?: number; +}; diff --git a/types/api/files.d.ts b/types/api/files.d.ts new file mode 100644 index 0000000000..ff094856b7 --- /dev/null +++ b/types/api/files.d.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type FileInfo = { + id: string; + user_id: string; + post_id: string; + create_at: number; + update_at: number; + delete_at: number; + name: string; + extension: string; + size: number; + mime_type: string; + width: number; + height: number; + has_preview_image: boolean; + clientId: string; + localPath?: string; + uri?: string; + loading?: boolean; +}; + +type FilesState = { + files: Dictionary; + fileIdsByPostId: Dictionary>; + filePublicLink?: string; +}; + +type FileUploadResponse = { + file_infos: FileInfo[]; + client_ids: string[]; +}; diff --git a/types/api/groups.d.ts b/types/api/groups.d.ts new file mode 100644 index 0000000000..c0fbecbc30 --- /dev/null +++ b/types/api/groups.d.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type SyncableType = 'team' | 'channel'; +type SyncablePatch = { + scheme_admin: boolean; + auto_add: boolean; +}; +type Group = { + id: string; + name: string; + display_name: string; + description: string; + type: string; + remote_id: string; + create_at: number; + update_at: number; + delete_at: number; + has_syncables: boolean; + member_count: number; + scheme_admin: boolean; + allow_reference: boolean; +}; +type GroupTeam = { + team_id: string; + team_display_name: string; + team_type: string; + group_id: string; + auto_add: boolean; + scheme_admin: boolean; + create_at: number; + delete_at: number; + update_at: number; +}; +type GroupChannel = { + channel_id: string; + channel_display_name: string; + channel_type: string; + team_id: string; + team_display_name: string; + team_type: string; + group_id: string; + auto_add: boolean; + scheme_admin: boolean; + create_at: number; + delete_at: number; + update_at: number; +}; +type GroupSyncables = { + teams: Array; + channels: Array; +}; +type GroupsState = { + syncables: { + [x: string]: GroupSyncables; + }; + members: any; + groups: { + [x: string]: Group; + }; + myGroups: { + [x: string]: Group; + }; +}; +type GroupSearchOpts = { + q: string; + is_linked?: boolean; + is_configured?: boolean; +}; diff --git a/types/api/integrations.d.ts b/types/api/integrations.d.ts new file mode 100644 index 0000000000..a15e3b0cf0 --- /dev/null +++ b/types/api/integrations.d.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type Command = { + 'id': string; + 'token': string; + 'create_at': number; + 'update_at': number; + 'delete_at': number; + 'creator_id': string; + 'team_id': string; + 'trigger': string; + 'method': 'P' | 'G' | ''; + 'username': string; + 'icon_url': string; + 'auto_complete': boolean; + 'auto_complete_desc': string; + 'auto_complete_hint': string; + 'display_name': string; + 'description': string; + 'url': string; +}; + +type CommandArgs = { + channel_id: string; + team_id: string; + root_id?: string; + parent_id?: string; +}; + +// AutocompleteSuggestion represents a single suggestion downloaded from the server. +type AutocompleteSuggestion = { + Complete: string; + Suggestion: string; + Hint: string; + Description: string; + IconData: string; +}; + +type DialogSubmission = { + url: string; + callback_id: string; + state: string; + user_id: string; + channel_id: string; + team_id: string; + submission: { + [x: string]: string; + }; + cancelled: boolean; +}; + +type DialogOption = { + text: string; + value: string; +}; + +type DialogElement = { + display_name: string; + name: string; + type: string; + subtype: string; + default: string; + placeholder: string; + help_text: string; + optional: boolean; + min_length: number; + max_length: number; + data_source: string; + options: Array; +}; + +type InteractiveDialogConfig = { + app_id: string; + trigger_id: string; + url: string; + dialog: { + callback_id: string; + title: string; + introduction_text: string; + icon_url?: string; + elements: DialogElement[]; + submit_label: string; + notify_on_cancel: boolean; + state: string; + }; +}; diff --git a/types/api/license.d.ts b/types/api/license.d.ts new file mode 100644 index 0000000000..72ad20f0cc --- /dev/null +++ b/types/api/license.d.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +interface ClientLicense { + Announcement: string; + Cloud: string; + Cluster: string; + Company: string; + Compliance: string; + CustomPermissionsSchemes: string; + CustomTermsOfService: string; + DataRetention: string; + Elasticsearch: string; + EmailNotificationContents: string; + GoogleOAuth: string; + GuestAccounts: string; + GuestAccountsPermissions: string; + IDLoadedPushNotifications: string; + IsLicensed: string; + LDAP: string; + LDAPGroups: string; + LockTeammateNameDisplay: string; + MFA: string; + MHPNS: string; + MessageExport: string; + Metrics: string; + Office365OAuth: string; + OpenId: string; + RemoteClusterService: string; + SAML: string; + SharedChannels: string; + Users: string; + } diff --git a/types/api/posts.d.ts b/types/api/posts.d.ts new file mode 100644 index 0000000000..31a6ae2fe8 --- /dev/null +++ b/types/api/posts.d.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type PostType = 'system_add_remove' | + 'system_add_to_channel' | + 'system_add_to_team' | + 'system_channel_deleted' | + 'system_channel_restored' | + 'system_displayname_change' | + 'system_convert_channel' | + 'system_ephemeral' | + 'system_header_change' | + 'system_join_channel' | + 'system_join_leave' | + 'system_leave_channel' | + 'system_purpose_change' | + 'system_remove_from_channel'; + +type PostEmbedType = 'image' | 'message_attachment' | 'opengraph'; + +type PostEmbed = { + type: PostEmbedType; + url: string; + data: Record; +}; + +type PostImage = { + height: number; + width: number; + format?: string; + frame_count?: number; +}; + +type PostMetadata = { + embeds: Array; + emojis: Array; + files: Array; + images: Dictionary; + reactions: Array; +}; + +type Post = { + id: string; + create_at: number; + update_at: number; + edit_at: number; + delete_at: number; + is_pinned: boolean; + user_id: string; + channel_id: string; + root_id: string; + parent_id: string; + original_id: string; + message: string; + type: PostType; + props: Record; + hashtags: string; + pending_post_id: string; + reply_count: number; + file_ids?: any[]; + metadata: PostMetadata; + failed?: boolean; + user_activity_posts?: Array; + state?: 'DELETED'; + ownPost?: boolean; +}; + +type PostWithFormatData = Post & { + isFirstReply: boolean; + isLastReply: boolean; + previousPostIsComment: boolean; + commentedOnPost?: Post; + consecutivePostByUser: boolean; + replyCount: number; + isCommentMention: boolean; + highlight: boolean; +}; + +type PostOrderBlock = { + order: Array; + recent?: boolean; + oldest?: boolean; +}; + +type MessageHistory = { + messages: Array; + index: { + post: number; + comment: number; + }; +}; + +type PostsState = { + posts: IDMappedObjects; + postsInChannel: Dictionary>; + postsInThread: RelationOneToMany; + reactions: RelationOneToOne>; + openGraph: RelationOneToOne; + pendingPostIds: Array; + selectedPostId: string; + currentFocusedPostId: string; + messagesHistory: MessageHistory; + expandedURLs: Dictionary; +}; + +type PostProps = { + disable_group_highlight?: boolean; + mentionHighlightDisabled: boolean; +}; + +type PostResponse = PostOrderBlock & { + posts: IDMappedObjects; +}; diff --git a/types/api/reactions.d.ts b/types/api/reactions.d.ts new file mode 100644 index 0000000000..614b6ffd06 --- /dev/null +++ b/types/api/reactions.d.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type Reaction = { + user_id: string; + post_id: string; + emoji_name: string; + create_at: number; +}; diff --git a/types/api/roles.d.ts b/types/api/roles.d.ts new file mode 100644 index 0000000000..a9d6e92c24 --- /dev/null +++ b/types/api/roles.d.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type ChannelModerationRoles = 'members' | 'guests'; + +type Role = { + id: string; + name: string; + display_name: string; + description: string; + create_at: number; + update_at: number; + delete_at: number; + permissions: Array; + scheme_managed: boolean; + built_in: boolean; +}; diff --git a/types/api/teams.d.ts b/types/api/teams.d.ts new file mode 100644 index 0000000000..9e342dab8c --- /dev/null +++ b/types/api/teams.d.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type TeamMembership = { + mention_count: number; + msg_count: number; + team_id: string; + user_id: string; + roles: string; + delete_at: number; + scheme_user: boolean; + scheme_admin: boolean; +}; + +type TeamMemberWithError = { + member: TeamMembership; + user_id: string; + error: ApiError; +} + +type TeamType = 'O' | 'I'; + +type Team = { + id: string; + create_at: number; + update_at: number; + delete_at: number; + display_name: string; + name: string; + description: string; + email: string; + type: TeamType; + company_name: string; + allowed_domains: string; + invite_id: string; + allow_open_invite: boolean; + scheme_id: string; + group_constrained: boolean; +}; + +type TeamsState = { + currentTeamId: string; + teams: Dictionary; + myMembers: Dictionary; + membersInTeam: any; + stats: any; + groupsAssociatedToTeam: any; + totalCount: number; +}; + +type TeamUnread = { + team_id: string; + mention_count: number; + msg_count: number; +}; diff --git a/types/api/users.d.ts b/types/api/users.d.ts new file mode 100644 index 0000000000..00084441f4 --- /dev/null +++ b/types/api/users.d.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type UserNotifyProps = { + auto_responder_active?: 'true' | 'false'; + auto_responder_message?: string; + desktop: 'default' | 'all' | 'mention' | 'none'; + desktop_notification_sound?: string; + desktop_sound: 'true' | 'false'; + email: 'true' | 'false'; + mark_unread: 'all' | 'mention'; + push: 'default' | 'all' | 'mention' | 'none'; + push_status: 'ooo' | 'offline' | 'away' | 'dnd' | 'online'; + comments: 'never' | 'root' | 'any'; + first_name: 'true' | 'false'; + channel: 'true' | 'false'; + mention_keys: string; + user_id?: string; +}; + +type UserProfile = { + id: string; + create_at: number; + update_at: number; + delete_at: number; + username: string; + auth_data: string; + auth_service: string; + email: string; + email_verified: boolean; + nickname: string; + first_name: string; + last_name: string; + position: string; + roles: string; + locale: string; + notify_props: UserNotifyProps; + terms_of_service_id: string; + terms_of_service_create_at: number; + timezone?: UserTimezone; + is_bot: boolean; + last_picture_update: number; +}; + +type UsersState = { + currentUserId: string; + isManualStatus: RelationOneToOne; + mySessions: Array; + profiles: IDMappedObjects; + profilesInTeam: RelationOneToMany; + profilesNotInTeam: RelationOneToMany; + profilesWithoutTeam: Set; + profilesInChannel: RelationOneToMany; + profilesNotInChannel: RelationOneToMany; + statuses: RelationOneToOne; + stats: any; +}; + +type UserTimezone = { + useAutomaticTimezone: boolean | string; + automaticTimezone: string; + manualTimezone: string; +}; + +type UserActivity = { + [x in PostType]: { + [y in $ID]: | { + ids: Array<$ID>; + usernames: Array; + } | Array<$ID>; + }; +}; + +type UserStatus = { + user_id: string; + status: string; + manual: boolean; + last_activity_at: number; + active_channel?: string; +}; diff --git a/types/global/preferences.d.ts b/types/global/preferences.d.ts index 543730569d..bffd2aeb50 100644 --- a/types/global/preferences.d.ts +++ b/types/global/preferences.d.ts @@ -11,32 +11,3 @@ interface PreferenceType { interface PreferencesType { [x: string]: PreferenceType; } - -interface Theme { - [key: string]: string | undefined; - type?: string; - sidebarBg: string; - sidebarText: string; - sidebarUnreadText: string; - sidebarTextHoverBg: string; - sidebarTextActiveBorder: string; - sidebarTextActiveColor: string; - sidebarHeaderBg: string; - sidebarHeaderTextColor: string; - onlineIndicator: string; - awayIndicator: string; - dndIndicator: string; - mentionBg: string; - mentionBj: string; - mentionColor: string; - centerChannelBg: string; - centerChannelColor: string; - newMessageSeparator: string; - linkColor: string; - buttonBg: string; - buttonColor: string; - errorTextColor: string; - mentionHighlightBg: string; - mentionHighlightLink: string; - codeTheme: string; -} diff --git a/types/global/utilities.d.ts b/types/global/utilities.d.ts index cd86e6d0ae..d09586477a 100644 --- a/types/global/utilities.d.ts +++ b/types/global/utilities.d.ts @@ -1,6 +1,31 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -interface Dictionary { +type $ID = E['id']; +type $UserID = E['user_id']; +type $Name = E['name']; +type $Username = E['username']; +type $Email = E['email']; +type RelationOneToOne = { + [x in $ID]: T; +}; +type RelationOneToMany = { + [x in $ID]: Array<$ID>; +}; +type IDMappedObjects = RelationOneToOne; +type UserIDMappedObjects = { + [x in $UserID]: E; +}; +type NameMappedObjects = { + [x in $Name]: E; +}; +type UsernameMappedObjects = { + [x in $Username]: E; +}; +type EmailMappedObjects = { + [x in $Email]: E; +}; + +type Dictionary = { [key: string]: T; -} +}; diff --git a/types/screens/gallery.d.ts b/types/screens/gallery.d.ts index 94e7378728..92f7fba551 100644 --- a/types/screens/gallery.d.ts +++ b/types/screens/gallery.d.ts @@ -6,9 +6,6 @@ import {intlShape} from 'react-intl'; import {StyleProp, ViewStyle} from 'react-native'; import Animated from 'react-native-reanimated'; -import type {FileInfo} from '@mm-redux/types/files'; -import type {Theme} from '@mm-redux/types/preferences'; - export interface CallbackFunctionWithoutArguments { (): void; }