diff --git a/_locales/en/messages.json b/_locales/en/messages.json index fca857e8f..7fc3b92b3 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2189,13 +2189,16 @@ "message": "You have removed your password." }, "publicChatExists": { - "message": "You are already connected to this public channel" + "message": "You are already connected to this open group" }, "connectToServerFail": { "message": "Failed to connect to server. Check URL" }, + "connectingToServer": { + "message": "Connecting to server..." + }, "connectToServerSuccess": { - "message": "Successfully connected to new open group server" + "message": "Successfully connected to open group" }, "setPasswordFail": { "message": "Failed to set password" @@ -2248,6 +2251,10 @@ "message": "Invalid Pubkey Format", "description": "Error string shown when user types an invalid pubkey format" }, + "attemptedConnectionTimeout": { + "message": "Connection to open group timed out", + "description": "Shown in toast when attempted connection to OpenGroup times out" + }, "lnsMappingNotFound": { "message": "There is no LNS mapping associated with this name", "description": "Shown in toast if user enters an unknown LNS name" @@ -2370,6 +2377,10 @@ "displayName": { "message": "Display Name" }, + "anonymous": { + "message": "Anonymous", + "description": "The name of currently unidentified users" + }, "enterDisplayName": { "message": "Enter a display name" }, diff --git a/js/background.js b/js/background.js index 75e67391e..20ec8003f 100644 --- a/js/background.js +++ b/js/background.js @@ -1104,16 +1104,30 @@ window.setMediaPermissions(!mediaPermissions); }; - // attempts a connection to an open group server + // Attempts a connection to an open group server window.attemptConnection = async (serverURL, channelId) => { - let rawserverURL = serverURL + let completeServerURL = serverURL.toLowerCase(); + const valid = window.libsession.Types.OpenGroup.validate( + completeServerURL + ); + if (!valid) { + return new Promise((_resolve, reject) => { + reject(window.i18n('connectToServerFail')); + }); + } + + // Add http or https prefix to server + completeServerURL = window.libsession.Types.OpenGroup.prefixify( + completeServerURL + ); + + const rawServerURL = serverURL .replace(/^https?:\/\//i, '') .replace(/[/\\]+$/i, ''); - rawserverURL = rawserverURL.toLowerCase(); - const sslServerURL = `https://${rawserverURL}`; - const conversationId = `publicChat:${channelId}@${rawserverURL}`; - // quickly peak to make sure we don't already have it + const conversationId = `publicChat:${channelId}@${rawServerURL}`; + + // Quickly peak to make sure we don't already have it const conversationExists = window.ConversationController.get( conversationId ); @@ -1124,9 +1138,9 @@ }); } - // get server + // Get server const serverAPI = await window.lokiPublicChatAPI.findOrCreateServer( - sslServerURL + completeServerURL ); // SSL certificate failure or offline if (!serverAPI) { @@ -1136,14 +1150,14 @@ }); } - // create conversation + // Create conversation const conversation = await window.ConversationController.getOrCreateAndWait( conversationId, 'group' ); - // convert conversation to a public one - await conversation.setPublicSource(sslServerURL, channelId); + // Convert conversation to a public one + await conversation.setPublicSource(completeServerURL, channelId); // and finally activate it conversation.getPublicSendData(); // may want "await" if you want to use the API diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index d567c23ab..a4c4c600c 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -122,6 +122,7 @@ export class LeftPane extends React.Component { return ( { - if (!thisRef.state.connectSuccess) { - thisRef.setState({ loading: false }); - window.pushToast({ - title: window.i18n('connectToServerFail'), - type: 'error', - id: 'connectToServerFail', - }); - - return; - } - }, window.CONSTANTS.MAX_CONNECTION_DURATION); - - connectionResult - .then(() => { - clearTimeout(connectionTimeout); - - if (thisRef.state.loading) { - thisRef.setState({ - connectSuccess: true, - loading: false, - }); - window.pushToast({ - title: window.i18n('connectToServerSuccess'), - id: 'connectToServerSuccess', - type: 'success', - }); - - if (onSuccess) { - onSuccess(); - } - } - }) - .catch((connectionError: string) => { - clearTimeout(connectionTimeout); - thisRef.setState({ - connectSuccess: true, - loading: false, - }); - window.pushToast({ - title: connectionError, - id: 'connectToServerFail', - type: 'error', - }); - - return false; - }); - - return true; -} - async function createClosedGroup( groupName: string, groupMembers: Array, diff --git a/ts/components/session/LeftPaneContactSection.tsx b/ts/components/session/LeftPaneContactSection.tsx index c86537b93..5df607f93 100644 --- a/ts/components/session/LeftPaneContactSection.tsx +++ b/ts/components/session/LeftPaneContactSection.tsx @@ -29,7 +29,6 @@ export interface Props { conversations: Array; contacts: Array; - searchResults?: SearchResultsProps; updateSearchTerm: (searchTerm: string) => void; diff --git a/ts/components/session/LeftPaneMessageSection.tsx b/ts/components/session/LeftPaneMessageSection.tsx index 5bf50a38f..26f92bfd6 100644 --- a/ts/components/session/LeftPaneMessageSection.tsx +++ b/ts/components/session/LeftPaneMessageSection.tsx @@ -7,6 +7,7 @@ import { ConversationListItem, PropsData as ConversationListItemPropsType, } from '../ConversationListItem'; +import { ConversationType } from '../../state/ducks/conversations'; import { PropsData as SearchResultsProps, SearchResults, @@ -28,11 +29,13 @@ import { SessionButtonColor, SessionButtonType, } from './SessionButton'; +import { OpenGroup } from '../../session/types'; export interface Props { searchTerm: string; isSecondaryDevice: boolean; + contacts: Array; conversations?: Array; searchResults?: SearchResultsProps; @@ -58,7 +61,6 @@ interface State { loading: boolean; overlay: false | SessionComposeToType; valuePasted: string; - connectSuccess: boolean; } export class LeftPaneMessageSection extends React.Component { @@ -72,7 +74,6 @@ export class LeftPaneMessageSection extends React.Component { loading: false, overlay: false, valuePasted: '', - connectSuccess: false, }; const conversations = this.getCurrentConversations(); @@ -314,6 +315,7 @@ export class LeftPaneMessageSection extends React.Component { const closedGroupElement = ( { @@ -438,41 +440,64 @@ export class LeftPaneMessageSection extends React.Component { } } - private handleJoinChannelButtonClick(groupUrl: string) { + private async handleJoinChannelButtonClick(serverUrl: string) { const { loading } = this.state; if (loading) { - return false; + return; } - // longest TLD is now (20/02/06) 24 characters per https://jasontucker.blog/8945/what-is-the-longest-tld-you-can-get-for-a-domain-name - const regexURL = /(http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,24}(:[0-9]{1,5})?(\/.*)?/; + // Server URL entered? + if (serverUrl.length === 0) { + return; + } - if (groupUrl.length <= 0) { + // Server URL valid? + if (!OpenGroup.validate(serverUrl)) { window.pushToast({ title: window.i18n('noServerURL'), - type: 'error', id: 'connectToServerFail', + type: 'error', }); - return false; + return; } - if (!regexURL.test(groupUrl)) { + // Already connected? + if (Boolean(await OpenGroup.getConversation(serverUrl))) { window.pushToast({ - title: window.i18n('noServerURL'), + title: window.i18n('publicChatExists'), + id: 'publicChatExists', type: 'error', - id: 'connectToServerFail', }); - return false; + return; } - MainViewController.joinChannelStateManager(this, groupUrl, () => { - this.handleToggleOverlay(undefined); - }); + // Connect to server + try { + await OpenGroup.join(serverUrl, async () => { + if (await OpenGroup.serverExists(serverUrl)) { + window.pushToast({ + title: window.i18n('connectingToServer'), + id: 'connectToServerSuccess', + type: 'success', + }); - return true; + this.setState({ loading: true }); + } + }); + } catch (e) { + window.pushToast({ + title: window.i18n('connectToServerFail'), + id: 'connectToServerFail', + type: 'error', + }); + } finally { + this.setState({ + loading: false, + }); + } } private async onCreateClosedGroup( diff --git a/ts/components/session/SessionClosableOverlay.tsx b/ts/components/session/SessionClosableOverlay.tsx index d9ad94de4..f2085948c 100644 --- a/ts/components/session/SessionClosableOverlay.tsx +++ b/ts/components/session/SessionClosableOverlay.tsx @@ -66,20 +66,10 @@ export class SessionClosableOverlay extends React.Component { } public getContacts() { - const conversations = window.getConversations() || []; + const contactsList = this.props.contacts ?? []; - const conversationList = conversations.filter((conversation: any) => { - return ( - !conversation.isMe() && - conversation.isPrivate() && - !conversation.isSecondaryDevice() && - !conversation.isBlocked() - ); - }); - - return conversationList.map((d: any) => { - const lokiProfile = d.getLokiProfile(); - const name = lokiProfile ? lokiProfile.displayName : 'Anonymous'; + return contactsList.map((d: any) => { + const name = d.name ?? window.i18n('anonymous'); // TODO: should take existing members into account const existingMember = false; @@ -90,7 +80,7 @@ export class SessionClosableOverlay extends React.Component { authorProfileName: name, selected: false, authorName: name, - authorColor: d.getColor(), + authorColor: d.color, checkmarked: false, existingMember, }; diff --git a/ts/session/types/OpenGroup.ts b/ts/session/types/OpenGroup.ts index 01c148515..462a21589 100644 --- a/ts/session/types/OpenGroup.ts +++ b/ts/session/types/OpenGroup.ts @@ -1,4 +1,4 @@ -// This is the Open Group equivalent to the PubKey type. +import { ConversationModel } from '../../../js/models/conversations'; interface OpenGroupParams { server: string; @@ -8,7 +8,7 @@ interface OpenGroupParams { export class OpenGroup { private static readonly serverRegex = new RegExp( - '^([\\w-]{2,}.){1,2}[\\w-]{2,}$' + '^((https?:\\/\\/){0,1})([\\w-]{2,}\\.){1,2}[\\w-]{2,}$' ); private static readonly groupIdRegex = new RegExp( '^publicChat:[0-9]*@([\\w-]{2,}.){1,2}[\\w-]{2,}$' @@ -18,9 +18,17 @@ export class OpenGroup { public readonly groupId?: string; public readonly conversationId: string; + /** + * An OpenGroup object. + * If `params.server` is not valid, this will throw an `Error`. + * + * @param params.server The server URL. `https` will be prepended if `http` or `https` is not explicitly set + * @param params.channel The server channel + * @param params.groupId The string corresponding to the server. Eg. `publicChat:1@chat.getsession.org` + * @param params.conversationId The conversation ID for the backbone model + */ constructor(params: OpenGroupParams) { - const strippedServer = params.server.replace('https://', ''); - this.server = strippedServer; + this.server = OpenGroup.prefixify(params.server.toLowerCase()); // Validate server format const isValid = OpenGroup.serverRegex.test(this.server); @@ -33,14 +41,29 @@ export class OpenGroup { this.groupId = OpenGroup.getGroupId(this.server, this.channel); } + /** + * Validate the URL of an open group server + * + * @param serverUrl The server URL to validate + */ + public static validate(serverUrl: string): boolean { + return this.serverRegex.test(serverUrl); + } + + /** + * Try to make a new instance of `OpenGroup`. + * This does NOT respect `ConversationController` and does not guarentee the conversation's existence. + * + * @param groupId The string corresponding to the server. Eg. `publicChat:1@chat.getsession.org` + * @param conversationId The conversation ID for the backbone model + * @returns `OpenGroup` if valid otherwise returns `undefined`. + */ public static from( groupId: string, - conversationId: string + conversationId: string, + hasSSL: boolean = true ): OpenGroup | undefined { - // Returns a new instance from a groupId if it's valid - // eg. groupId = 'publicChat:1@chat.getsession.org' - - const server = this.getServer(groupId); + const server = this.getServer(groupId, hasSSL); const channel = this.getChannel(groupId); // Was groupId successfully utilized? @@ -55,17 +78,121 @@ export class OpenGroup { conversationId, } as OpenGroupParams; - if (this.serverRegex.test(server)) { + const isValid = OpenGroup.serverRegex.test(server); + if (!isValid) { return; } return new OpenGroup(openGroupParams); } - private static getServer(groupId: string): string | undefined { - const isValid = this.groupIdRegex.test(groupId); + /** + * Join an open group + * + * @param server The server URL + * @param onLoading Callback function to be called once server begins connecting + * @returns `OpenGroup` if connection success or if already connected + */ + public static async join( + server: string, + onLoading?: any + ): Promise { + const prefixedServer = OpenGroup.prefixify(server); + if (!OpenGroup.validate(server)) { + return; + } - return isValid ? groupId.split('@')[1] : undefined; + // Make this not hard coded + const channel = 1; + let conversation; + let conversationId; + + // Return OpenGroup if we're already connected + conversation = await OpenGroup.getConversation(prefixedServer); + if (conversation) { + conversationId = conversation?.cid; + if (conversationId) { + return new OpenGroup({ + server: prefixedServer, + channel: 1, + conversationId, + }); + } + } + + // Try to connect to server + try { + if (onLoading) { + onLoading(); + } + + conversation = await window.attemptConnection(prefixedServer, channel); + conversationId = conversation?.cid; + } catch (e) { + throw new Error(e); + } + + // Do we want to add conversation as a property of OpenGroup? + return new OpenGroup({ + server, + channel, + conversationId, + }); + } + + /** + * Get the conversation model of a server from its URL + * + * @param server The server URL + * @returns BackBone conversation model corresponding to the server if it exists, otherwise `undefined` + */ + public static async getConversation( + server: string + ): Promise { + if (!OpenGroup.validate(server)) { + return; + } + + const prefixedServer = this.prefixify(server); + const serverInfo = (await window.lokiPublicChatAPI.findOrCreateServer( + prefixedServer + )) as any; + + if (!serverInfo?.channels?.length) { + return; + } + + return serverInfo.channels[0].conversation; + } + + /** + * Check if the server exists. + * This does not compare against your conversations with the server. + * + * @param server The server URL + */ + public static async serverExists(server: string): Promise { + if (!OpenGroup.validate(server)) { + return false; + } + + const prefixedServer = this.prefixify(server); + return Boolean( + await window.lokiPublicChatAPI.findOrCreateServer(prefixedServer) + ); + } + + private static getServer( + groupId: string, + hasSSL: boolean + ): string | undefined { + const isValid = this.groupIdRegex.test(groupId); + const strippedServer = isValid ? groupId.split('@')[1] : undefined; + + // We don't know for sure if the server is https or http when taken from the groupId. Preifx accordingly. + return strippedServer + ? this.prefixify(strippedServer.toLowerCase(), hasSSL) + : undefined; } private static getChannel(groupId: string): number | undefined { @@ -76,7 +203,22 @@ export class OpenGroup { } private static getGroupId(server: string, channel: number): string { - // server is already validated in constructor; no need to re-check - return `publicChat:${channel}@${server}`; + // Server is already validated in constructor; no need to re-check + + // Strip server prefix + const prefixRegex = new RegExp('https?:\\/\\/'); + const strippedServer = server.replace(prefixRegex, ''); + + return `publicChat:${channel}@${strippedServer}`; + } + + private static prefixify(server: string, hasSSL: boolean = true): string { + // Prefix server with https:// if it's not already prefixed with http or https. + const hasPrefix = server.match('^https?://'); + if (hasPrefix) { + return server; + } + + return `http${hasSSL ? 's' : ''}://${server}`; } } diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 5b896ad6c..3f28c7851 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -120,8 +120,26 @@ export const _getLeftPaneLists = ( }; } - // Remove all invalid conversations and conversatons of devices associated with cancelled attempted links - if (!conversation.timestamp) { + // Add Open Group to list as soon as the name has been set + if ( + conversation.isPublic && + (!conversation.name || conversation.name === 'Unknown group') + ) { + continue; + } + + // Show loading icon while fetching messages + if (conversation.isPublic && !conversation.timestamp) { + conversation.lastMessage = { + status: 'sending', + text: '', + isRss: false, + }; + } + + // Remove all invalid conversations and conversatons of devices associated + // with cancelled attempted links + if (!conversation.isPublic && !conversation.timestamp) { continue; } @@ -133,7 +151,7 @@ export const _getLeftPaneLists = ( unreadCount += conversation.unreadCount; } - if (!conversation.activeAt) { + if (!conversation.isPublic && !conversation.activeAt) { continue; } diff --git a/ts/window.d.ts b/ts/window.d.ts index bd50f7e5d..5ea680b4c 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -7,12 +7,15 @@ import { LokiPublicChatFactoryInterface } from '../js/modules/loki_public_chat_a import { LokiAppDotNetServerInterface } from '../js/modules/loki_app_dot_net_api'; import { LokiMessageInterface } from '../js/modules/loki_message_api'; import { SwarmPolling } from './session/snode_api/swarmPolling'; + import { LibTextsecure } from '../libtextsecure'; +import { ConversationType } from '../js/modules/data'; /* We declare window stuff here instead of global.d.ts because we are importing other declarations. If you import anything in global.d.ts, the type system won't work correctly. */ + declare global { interface Window { CONSTANTS: any; @@ -33,7 +36,7 @@ declare global { StubMessageAPI: any; WebAPI: any; Whisper: any; - attemptConnection: any; + attemptConnection: ConversationType; clearLocalData: any; clipboard: any; confirmationDialog: any;