diff --git a/keyserver/src/responders/website-responders.js b/keyserver/src/responders/website-responders.js index a09b85b2b..4f8b6d1f7 100644 --- a/keyserver/src/responders/website-responders.js +++ b/keyserver/src/responders/website-responders.js @@ -1,825 +1,825 @@ // @flow import html from 'common-tags/lib/html/index.js'; import { detect as detectBrowser } from 'detect-browser'; import type { $Response, $Request } from 'express'; import fs from 'fs'; import _isEqual from 'lodash/fp/isEqual.js'; import _keyBy from 'lodash/fp/keyBy.js'; import * as React from 'react'; // eslint-disable-next-line import/extensions import ReactDOMServer from 'react-dom/server'; import t from 'tcomb'; import { promisify } from 'util'; -import { inviteLinkUrl } from 'lib/facts/links.js'; +import { inviteLinkURL } from 'lib/facts/links.js'; import { baseLegalPolicies } from 'lib/facts/policies.js'; import stores from 'lib/facts/stores.js'; import { daysToEntriesFromEntryInfos } from 'lib/reducers/entry-reducer.js'; import { freshMessageStore } from 'lib/reducers/message-reducer.js'; import { mostRecentlyReadThread } from 'lib/selectors/thread-selectors.js'; import { mostRecentMessageTimestamp } from 'lib/shared/message-utils.js'; import { threadHasPermission, threadIsPending, parsePendingThreadID, createPendingThread, } from 'lib/shared/thread-utils.js'; import { defaultWebEnabledApps } from 'lib/types/enabled-apps.js'; import { entryStoreValidator } from 'lib/types/entry-types.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import { keyserverStoreValidator } from 'lib/types/keyserver-types.js'; import { inviteLinksStoreValidator } from 'lib/types/link-types.js'; import { defaultNumberPerThread, messageStoreValidator, } from 'lib/types/message-types.js'; import { defaultEnabledReports } from 'lib/types/report-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { threadStoreValidator } from 'lib/types/thread-types.js'; import { currentUserInfoValidator, userInfosValidator, } from 'lib/types/user-types.js'; import { currentDateInTimeZone } from 'lib/utils/date-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { infoFromURL, urlInfoValidator } from 'lib/utils/url-utils.js'; import { tBool, tNumber, tShape, tString, ashoatKeyserverID, } from 'lib/utils/validation-utils.js'; import getTitle from 'web/title/getTitle.js'; import { navInfoValidator } from 'web/types/nav-types.js'; import { navInfoFromURL } from 'web/url-utils.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { fetchPrimaryInviteLinks } from '../fetchers/link-fetchers.js'; import { fetchMessageInfos } from '../fetchers/message-fetchers.js'; import { hasAnyNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchCurrentUserInfo, fetchKnownUserInfos, fetchUserInfos, } from '../fetchers/user-fetchers.js'; import { getWebPushConfig } from '../push/providers.js'; import { setNewSession } from '../session/cookies.js'; import { Viewer } from '../session/viewer.js'; import { streamJSON, waitForStream } from '../utils/json-stream.js'; import { getAppURLFactsFromRequestURL, getCommAppURLFacts, } from '../utils/urls.js'; import { validateOutput, validateInput } from '../utils/validation-utils.js'; const { renderToNodeStream } = ReactDOMServer; const access = promisify(fs.access); const readFile = promisify(fs.readFile); const googleFontsURL = 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600&display=swap'; const localFontsURL = 'fonts/local-fonts.css'; async function getFontsURL() { try { await access(localFontsURL); return localFontsURL; } catch { return googleFontsURL; } } type AssetInfo = { +jsURL: string, +fontsURL: string, +cssInclude: string, +olmFilename: string, +commQueryExecutorFilename: string, +opaqueURL: string, }; let assetInfo: ?AssetInfo = null; async function getAssetInfo() { if (assetInfo) { return assetInfo; } if (process.env.NODE_ENV === 'development') { const fontsURL = await getFontsURL(); assetInfo = { jsURL: 'http://localhost:8080/dev.build.js', fontsURL, cssInclude: '', olmFilename: '', commQueryExecutorFilename: '', opaqueURL: 'http://localhost:8080/opaque-ke.wasm', }; return assetInfo; } try { const manifestString = await readFile('../web/dist/manifest.json', 'utf8'); const manifest = JSON.parse(manifestString); const webworkersManifestString = await readFile( '../web/dist/webworkers/manifest.json', 'utf8', ); const webworkersManifest = JSON.parse(webworkersManifestString); assetInfo = { jsURL: `compiled/${manifest['browser.js']}`, fontsURL: googleFontsURL, cssInclude: html` `, olmFilename: manifest['olm.wasm'], commQueryExecutorFilename: webworkersManifest['comm_query_executor.wasm'], opaqueURL: `compiled/${manifest['comm_opaque2_wasm_bg.wasm']}`, }; return assetInfo; } catch { throw new Error( 'Could not load manifest.json for web build. ' + 'Did you forget to run `yarn dev` in the web folder?', ); } } let webpackCompiledRootComponent: ?React.ComponentType<{}> = null; async function getWebpackCompiledRootComponentForSSR() { if (webpackCompiledRootComponent) { return webpackCompiledRootComponent; } try { // $FlowFixMe web/dist doesn't always exist const webpackBuild = await import('web/dist/app.build.cjs'); webpackCompiledRootComponent = webpackBuild.app.default; return webpackCompiledRootComponent; } catch { throw new Error( 'Could not load app.build.cjs. ' + 'Did you forget to run `yarn dev` in the web folder?', ); } } const initialReduxStateValidator = tShape({ navInfo: navInfoValidator, deviceID: t.Nil, currentUserInfo: currentUserInfoValidator, draftStore: t.irreducible('default draftStore', _isEqual({ drafts: {} })), entryStore: entryStoreValidator, threadStore: threadStoreValidator, userStore: tShape({ userInfos: userInfosValidator, inconsistencyReports: t.irreducible( 'default inconsistencyReports', _isEqual([]), ), }), messageStore: messageStoreValidator, loadingStatuses: t.irreducible('default loadingStatuses', _isEqual({})), calendarFilters: t.irreducible( 'defaultCalendarFilters', _isEqual(defaultCalendarFilters), ), communityPickerStore: t.irreducible( 'default communityPickerStore', _isEqual({ chat: null, calendar: null }), ), windowDimensions: t.irreducible( 'default windowDimensions', _isEqual({ width: 0, height: 0 }), ), notifPermissionAlertInfo: t.irreducible( 'default notifPermissionAlertInfo', _isEqual(defaultNotifPermissionAlertInfo), ), actualizedCalendarQuery: tShape({ startDate: t.String, endDate: t.String, filters: t.irreducible('default filters', _isEqual(defaultCalendarFilters)), }), watchedThreadIDs: t.irreducible('default watchedThreadIDs', _isEqual([])), lifecycleState: tString('active'), enabledApps: t.irreducible( 'defaultWebEnabledApps', _isEqual(defaultWebEnabledApps), ), reportStore: t.irreducible( 'default reportStore', _isEqual({ enabledReports: defaultEnabledReports, queuedReports: [], }), ), nextLocalID: tNumber(0), deviceToken: t.Nil, dataLoaded: t.Boolean, windowActive: tBool(true), userPolicies: t.irreducible('default userPolicies', _isEqual({})), cryptoStore: t.irreducible( 'default cryptoStore', _isEqual({ primaryIdentityKeys: null, notificationIdentityKeys: null, }), ), pushApiPublicKey: t.maybe(t.String), _persist: t.Nil, commServicesAccessToken: t.Nil, inviteLinksStore: inviteLinksStoreValidator, keyserverStore: keyserverStoreValidator, }); async function websiteResponder( viewer: Viewer, req: $Request, res: $Response, ): Promise { const appURLFacts = getAppURLFactsFromRequestURL(req.originalUrl); const { basePath, baseDomain } = appURLFacts; const baseURL = basePath.replace(/\/$/, ''); const urlPrefix = baseDomain + baseURL; const loadingPromise = getWebpackCompiledRootComponentForSSR(); const hasNotAcknowledgedPoliciesPromise = hasAnyNotAcknowledgedPolicies( viewer.id, baseLegalPolicies, ); const initialNavInfoPromise = (async () => { try { let urlInfo = infoFromURL(decodeURI(req.url)); try { urlInfo = await validateInput(viewer, urlInfoValidator, urlInfo, true); } catch (exc) { // We should still be able to handle older links if (exc.message !== 'invalid_client_id_prefix') { throw exc; } } let backupInfo = { now: currentDateInTimeZone(viewer.timeZone), }; // Some user ids in selectedUserList might not exist in the userStore // (e.g. they were included in the results of the user search endpoint) // Because of that we keep their userInfos inside the navInfo. if (urlInfo.selectedUserList) { const fetchedUserInfos = await fetchUserInfos(urlInfo.selectedUserList); const userInfos = {}; for (const userID in fetchedUserInfos) { const userInfo = fetchedUserInfos[userID]; if (userInfo.username) { userInfos[userID] = userInfo; } } backupInfo = { userInfos, ...backupInfo }; } return navInfoFromURL(urlInfo, backupInfo); } catch (e) { throw new ServerError(e.message); } })(); const calendarQueryPromise = (async () => { const initialNavInfo = await initialNavInfoPromise; return { startDate: initialNavInfo.startDate, endDate: initialNavInfo.endDate, filters: defaultCalendarFilters, }; })(); const messageSelectionCriteria = { joinedThreads: true }; const initialTime = Date.now(); const assetInfoPromise = getAssetInfo(); const threadInfoPromise = fetchThreadInfos(viewer); const messageInfoPromise = fetchMessageInfos( viewer, messageSelectionCriteria, defaultNumberPerThread, ); const entryInfoPromise = (async () => { const calendarQuery = await calendarQueryPromise; return await fetchEntryInfos(viewer, [calendarQuery]); })(); const currentUserInfoPromise = fetchCurrentUserInfo(viewer); const userInfoPromise = fetchKnownUserInfos(viewer); const sessionIDPromise = (async () => { const calendarQuery = await calendarQueryPromise; if (viewer.loggedIn) { await setNewSession(viewer, calendarQuery, initialTime); } return viewer.sessionID; })(); const threadStorePromise = (async () => { const [{ threadInfos }, hasNotAcknowledgedPolicies] = await Promise.all([ threadInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); return { threadInfos: hasNotAcknowledgedPolicies ? {} : threadInfos }; })(); const messageStorePromise = (async () => { const [ { threadInfos }, { rawMessageInfos, truncationStatuses }, hasNotAcknowledgedPolicies, ] = await Promise.all([ threadInfoPromise, messageInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); if (hasNotAcknowledgedPolicies) { return { messages: {}, threads: {}, local: {}, currentAsOf: { [ashoatKeyserverID]: 0 }, }; } const { messageStore: freshStore } = freshMessageStore( rawMessageInfos, truncationStatuses, mostRecentMessageTimestamp(rawMessageInfos, initialTime), threadInfos, ); return freshStore; })(); const entryStorePromise = (async () => { const [{ rawEntryInfos }, hasNotAcknowledgedPolicies] = await Promise.all([ entryInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); if (hasNotAcknowledgedPolicies) { return { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, }; } return { entryInfos: _keyBy('id')(rawEntryInfos), daysToEntries: daysToEntriesFromEntryInfos(rawEntryInfos), lastUserInteractionCalendar: initialTime, }; })(); const userStorePromise = (async () => { const [userInfos, hasNotAcknowledgedPolicies] = await Promise.all([ userInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); return { userInfos: hasNotAcknowledgedPolicies ? {} : userInfos, inconsistencyReports: [], }; })(); const navInfoPromise = (async () => { const [ { threadInfos }, messageStore, currentUserInfo, userStore, finalNavInfo, ] = await Promise.all([ threadInfoPromise, messageStorePromise, currentUserInfoPromise, userStorePromise, initialNavInfoPromise, ]); const requestedActiveChatThreadID = finalNavInfo.activeChatThreadID; if ( requestedActiveChatThreadID && !threadIsPending(requestedActiveChatThreadID) && !threadHasPermission( threadInfos[requestedActiveChatThreadID], threadPermissions.VISIBLE, ) ) { finalNavInfo.activeChatThreadID = null; } if (!finalNavInfo.activeChatThreadID) { const mostRecentThread = mostRecentlyReadThread( messageStore, threadInfos, ); if (mostRecentThread) { finalNavInfo.activeChatThreadID = mostRecentThread; } } if ( finalNavInfo.activeChatThreadID && threadIsPending(finalNavInfo.activeChatThreadID) && finalNavInfo.pendingThread?.id !== finalNavInfo.activeChatThreadID ) { const pendingThreadData = parsePendingThreadID( finalNavInfo.activeChatThreadID, ); if ( pendingThreadData && pendingThreadData.threadType !== threadTypes.SIDEBAR && currentUserInfo.id ) { const { userInfos } = userStore; const members = [...pendingThreadData.memberIDs, currentUserInfo.id] .map(id => { const userInfo = userInfos[id]; if (!userInfo || !userInfo.username) { return undefined; } const { username } = userInfo; return { id, username }; }) .filter(Boolean); const newPendingThread = createPendingThread({ viewerID: currentUserInfo.id, threadType: pendingThreadData.threadType, members, }); finalNavInfo.activeChatThreadID = newPendingThread.id; finalNavInfo.pendingThread = newPendingThread; } } return finalNavInfo; })(); const currentAsOfPromise = (async () => { const hasNotAcknowledgedPolicies = await hasNotAcknowledgedPoliciesPromise; return hasNotAcknowledgedPolicies ? 0 : initialTime; })(); const pushApiPublicKeyPromise = (async () => { const pushConfig = await getWebPushConfig(); if (!pushConfig) { if (process.env.NODE_ENV !== 'development') { console.warn('keyserver/secrets/web_push_config.json should exist'); } return null; } return pushConfig.publicKey; })(); const inviteLinksStorePromise = (async () => { const primaryInviteLinks = await fetchPrimaryInviteLinks(viewer); const links = {}; for (const link of primaryInviteLinks) { if (link.primary) { links[link.communityID] = { primaryLink: link, }; } } return { links, }; })(); const keyserverStorePromise = (async () => { const { sessionID, updatesCurrentAsOf } = await promiseAll({ sessionID: sessionIDPromise, updatesCurrentAsOf: currentAsOfPromise, }); return { keyserverInfos: { [ashoatKeyserverID]: { cookie: undefined, sessionID, updatesCurrentAsOf, urlPrefix, connection: defaultConnectionInfo, lastCommunicatedPlatformDetails: null, }, }, }; })(); const { jsURL, fontsURL, cssInclude, olmFilename, opaqueURL, commQueryExecutorFilename, } = await assetInfoPromise; // prettier-ignore res.write(html` ${getTitle(0)} ${cssInclude}
`); const Loading = await loadingPromise; const reactStream = renderToNodeStream(); reactStream.pipe(res, { end: false }); await waitForStream(reactStream); res.write(html`
`); } const inviteSecretRegex = /^[a-z0-9]+$/i; // On native, if this responder is called, it means that the app isn't // installed. async function inviteResponder(req: $Request, res: $Response): Promise { const { secret } = req.params; const userAgent = req.get('User-Agent'); const detectionResult = detectBrowser(userAgent); if (detectionResult.os === 'Android OS') { const isSecretValid = inviteSecretRegex.test(secret); const referrer = isSecretValid ? `&referrer=${encodeURIComponent(`utm_source=invite/${secret}`)}` : ''; const redirectUrl = `${stores.googlePlayUrl}${referrer}`; res.writeHead(301, { Location: redirectUrl, }); res.end(); return; } else if (detectionResult.os !== 'iOS') { const urlFacts = getCommAppURLFacts(); const baseDomain = urlFacts?.baseDomain ?? ''; const basePath = urlFacts?.basePath ?? '/'; const redirectUrl = `${baseDomain}${basePath}handle/invite/${secret}`; res.writeHead(301, { Location: redirectUrl, }); res.end(); return; } const fontsURL = await getFontsURL(); res.end(html` Comm

Comm

To join this community, download the Comm app and reopen this invite link

Download Comm - + Invite Link
Visit Comm’s website arrow up right `); } export { websiteResponder, inviteResponder }; diff --git a/lib/facts/links.js b/lib/facts/links.js index 478494bd6..551306eb7 100644 --- a/lib/facts/links.js +++ b/lib/facts/links.js @@ -1,63 +1,63 @@ // @flow /* Invite Links */ -function inviteLinkUrl(secret: string): string { +function inviteLinkURL(secret: string): string { return `https://comm.app/invite/${secret}`; } /* QR Code */ -function qrCodeLinkUrl(aes256Param: string, ed25519Param: string): string { +function qrCodeLinkURL(aes256Param: string, ed25519Param: string): string { const keys = { aes256: aes256Param, ed25519: ed25519Param, }; const keysString = encodeURIComponent(JSON.stringify(keys)); return `comm://qr-code/${keysString}`; } /* Deep Link Utils */ function parseInstallReferrerFromInviteLinkURL(referrer: string): ?string { const referrerRegex = /utm_source=(invite\/(\S+))$/; const match = referrerRegex.exec(referrer); return match?.[1]; } type ParsedInviteLinkData = { +type: 'invite-link', +data: { +secret: string }, }; type ParsedQRCodeData = { +type: 'qr-code', +data: { +keys: string }, }; export type ParsedDeepLinkData = ParsedInviteLinkData | ParsedQRCodeData | null; function parseDataFromDeepLink(url: string): ParsedDeepLinkData { const inviteLinkSecretRegex = /invite\/(\S+)$/; const qrCodeKeysRegex = /qr-code\/(\S+)$/; const inviteLinkSecretMatch = inviteLinkSecretRegex.exec(url); if (inviteLinkSecretMatch) { return { type: 'invite-link', data: { secret: inviteLinkSecretMatch[1] }, }; } const qrCodeKeysMatch = qrCodeKeysRegex.exec(url); if (qrCodeKeysMatch) { return { type: 'qr-code', data: { keys: qrCodeKeysMatch[1] }, }; } return null; } export { - inviteLinkUrl, - qrCodeLinkUrl, + inviteLinkURL, + qrCodeLinkURL, parseInstallReferrerFromInviteLinkURL, parseDataFromDeepLink, }; diff --git a/native/invite-links/manage-public-link-screen.react.js b/native/invite-links/manage-public-link-screen.react.js index 211e09ab6..788c9f62e 100644 --- a/native/invite-links/manage-public-link-screen.react.js +++ b/native/invite-links/manage-public-link-screen.react.js @@ -1,223 +1,223 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; -import { inviteLinkUrl } from 'lib/facts/links.js'; +import { inviteLinkURL } from 'lib/facts/links.js'; import { useInviteLinksActions } from 'lib/hooks/invite-links.js'; import { primaryInviteLinksSelector } from 'lib/selectors/invite-links-selectors.js'; import { defaultErrorMessage, inviteLinkErrorMessages, } from 'lib/shared/invite-links.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import Button from '../components/button.react.js'; import TextInput from '../components/text-input.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; export type ManagePublicLinkScreenParams = { +community: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'ManagePublicLink'>, +route: NavigationRoute<'ManagePublicLink'>, }; function ManagePublicLinkScreen(props: Props): React.Node { const { community } = props.route.params; const inviteLink = useSelector(primaryInviteLinksSelector)[community.id]; const { error, isLoading, name, setName, createOrUpdateInviteLink, disableInviteLink, } = useInviteLinksActions(community.id, inviteLink); const styles = useStyles(unboundStyles); let errorComponent = null; if (error) { errorComponent = ( {inviteLinkErrorMessages[error] ?? defaultErrorMessage} ); } const onDisableButtonClick = React.useCallback(() => { Alert.alert( 'Disable public link', 'Are you sure you want to disable your public link?\n' + '\n' + 'Other communities will be able to claim the same URL.', [ { text: 'Confirm disable', style: 'destructive', onPress: disableInviteLink, }, { text: 'Cancel', }, ], { cancelable: true, }, ); }, [disableInviteLink]); let disablePublicLinkSection = null; if (inviteLink) { disablePublicLinkSection = ( You may also disable the community public link. ); } return ( Invite links make it easy for your friends to join your community. Anybody who knows your community’s invite link will be able to join it. Note that if you change your public link’s URL, other communities will be able to claim the old URL. INVITE URL - {inviteLinkUrl('')} + {inviteLinkURL('')} {errorComponent} {disablePublicLinkSection} ); } const unboundStyles = { sectionTitle: { fontSize: 14, fontWeight: '400', lineHeight: 20, color: 'modalBackgroundLabel', paddingHorizontal: 16, paddingBottom: 4, }, section: { borderBottomColor: 'modalSeparator', borderBottomWidth: 1, borderTopColor: 'modalSeparator', borderTopWidth: 1, backgroundColor: 'modalForeground', padding: 16, marginBottom: 24, }, disableLinkSection: { marginTop: 16, }, sectionText: { fontSize: 14, fontWeight: '400', lineHeight: 22, color: 'modalBackgroundLabel', }, withMargin: { marginBottom: 12, }, inviteLink: { flexDirection: 'row', alignItems: 'center', marginBottom: 8, }, inviteLinkPrefix: { fontSize: 14, fontWeight: '400', lineHeight: 22, color: 'disabledButtonText', marginRight: 2, }, input: { color: 'panelForegroundLabel', borderColor: 'panelSecondaryForegroundBorder', borderWidth: 1, borderRadius: 8, paddingVertical: 13, paddingHorizontal: 16, flex: 1, }, button: { borderRadius: 8, paddingVertical: 12, paddingHorizontal: 24, marginTop: 8, }, buttonPrimary: { backgroundColor: 'purpleButton', }, destructiveButton: { borderWidth: 1, borderRadius: 8, borderColor: 'vibrantRedButton', }, destructiveButtonText: { fontSize: 16, fontWeight: '500', lineHeight: 24, color: 'vibrantRedButton', textAlign: 'center', }, buttonText: { color: 'whiteText', textAlign: 'center', fontWeight: '500', fontSize: 16, lineHeight: 24, }, error: { fontSize: 12, fontWeight: '400', lineHeight: 18, textAlign: 'center', color: 'redText', }, }; export default ManagePublicLinkScreen; diff --git a/native/invite-links/view-invite-links-screen.react.js b/native/invite-links/view-invite-links-screen.react.js index 0116634f0..aa7b97c93 100644 --- a/native/invite-links/view-invite-links-screen.react.js +++ b/native/invite-links/view-invite-links-screen.react.js @@ -1,169 +1,169 @@ // @flow import Clipboard from '@react-native-clipboard/clipboard'; import * as React from 'react'; import { Text, View } from 'react-native'; import { TouchableOpacity } from 'react-native-gesture-handler'; -import { inviteLinkUrl } from 'lib/facts/links.js'; +import { inviteLinkURL } from 'lib/facts/links.js'; import { primaryInviteLinksSelector } from 'lib/selectors/invite-links-selectors.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { InviteLink } from 'lib/types/link-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import SingleLine from '../components/single-line.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { ManagePublicLinkRouteName, type NavigationRoute, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles, useColors } from '../themes/colors.js'; export type ViewInviteLinksScreenParams = { +community: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'ViewInviteLinks'>, +route: NavigationRoute<'ViewInviteLinks'>, }; const confirmCopy = () => displayActionResultModal('copied!'); function ViewInviteLinksScreen(props: Props): React.Node { const { community } = props.route.params; const inviteLink: ?InviteLink = useSelector(primaryInviteLinksSelector)[ community.id ]; const styles = useStyles(unboundStyles); const { modalForegroundLabel } = useColors(); - const linkUrl = inviteLinkUrl(inviteLink?.name ?? ''); + const linkUrl = inviteLinkURL(inviteLink?.name ?? ''); const onPressCopy = React.useCallback(() => { Clipboard.setString(linkUrl); setTimeout(confirmCopy); }, [linkUrl]); const { navigate } = props.navigation; const onEditButtonClick = React.useCallback(() => { navigate<'ManagePublicLink'>({ name: ManagePublicLinkRouteName, params: { community, }, }); }, [community, navigate]); const canManageLinks = threadHasPermission( community, threadPermissions.MANAGE_INVITE_LINKS, ); let publicLinkSection = null; if (inviteLink || canManageLinks) { let description; if (canManageLinks) { description = ( <> Public links allow unlimited uses and never expire. Edit public link ); } else { description = ( Share this invite link to help your friends join your community! ); } publicLinkSection = ( <> PUBLIC LINK {linkUrl} Copy {description} ); } return {publicLinkSection}; } const unboundStyles = { container: { flex: 1, paddingTop: 24, }, sectionTitle: { fontSize: 12, fontWeight: '400', lineHeight: 18, color: 'modalBackgroundLabel', paddingHorizontal: 16, paddingBottom: 4, }, section: { borderBottomColor: 'modalSeparator', borderBottomWidth: 1, borderTopColor: 'modalSeparator', borderTopWidth: 1, backgroundColor: 'modalForeground', padding: 16, }, link: { paddingHorizontal: 16, paddingVertical: 9, marginBottom: 16, backgroundColor: 'inviteLinkButtonBackground', borderRadius: 20, flexDirection: 'row', justifyContent: 'space-between', }, linkText: { fontSize: 14, fontWeight: '400', lineHeight: 22, color: 'inviteLinkLinkColor', flex: 1, }, button: { flexDirection: 'row', alignItems: 'center', }, copy: { fontSize: 12, fontWeight: '400', lineHeight: 18, color: 'modalForegroundLabel', paddingLeft: 8, }, details: { fontSize: 12, fontWeight: '400', lineHeight: 18, color: 'modalForegroundLabel', }, editLinkButton: { color: 'purpleLink', }, }; export default ViewInviteLinksScreen; diff --git a/native/markdown/markdown-link.react.js b/native/markdown/markdown-link.react.js index bd416b611..6fd126b06 100644 --- a/native/markdown/markdown-link.react.js +++ b/native/markdown/markdown-link.react.js @@ -1,108 +1,108 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, Linking } from 'react-native'; -import { inviteLinkUrl } from 'lib/facts/links.js'; +import { inviteLinkURL } from 'lib/facts/links.js'; import { MarkdownContext, type MarkdownContextType, } from './markdown-context.js'; import { MarkdownSpoilerContext } from './markdown-spoiler-context.js'; import { MessagePressResponderContext } from '../chat/message-press-responder-context.js'; import { TextMessageMarkdownContext } from '../chat/text-message-markdown-context.js'; import { DeepLinksContext } from '../navigation/deep-links-context-provider.react.js'; import Alert from '../utils/alert.js'; import { normalizeURL } from '../utils/url-utils.js'; function useHandleLinkClick( inputURL: string, markdownContext: MarkdownContextType, messageKey: ?string, ) { const { setLinkModalActive } = markdownContext; const onDismiss = React.useCallback(() => { messageKey && setLinkModalActive({ [messageKey]: false }); }, [setLinkModalActive, messageKey]); const url = normalizeURL(inputURL); const onConfirm = React.useCallback(() => { onDismiss(); Linking.openURL(url); }, [url, onDismiss]); let displayURL = url.substring(0, 64); if (url.length > displayURL.length) { displayURL += '…'; } const deepLinksContext = React.useContext(DeepLinksContext); return React.useCallback(() => { - if (url.startsWith(inviteLinkUrl(''))) { + if (url.startsWith(inviteLinkURL(''))) { deepLinksContext?.setCurrentLinkUrl(url); return; } messageKey && setLinkModalActive({ [messageKey]: true }); Alert.alert( 'External link', `You sure you want to open this link?\n\n${displayURL}`, [ { text: 'Cancel', style: 'cancel', onPress: onDismiss }, { text: 'Open', onPress: onConfirm }, ], { cancelable: true, onDismiss }, ); }, [ url, messageKey, setLinkModalActive, displayURL, onDismiss, onConfirm, deepLinksContext, ]); } type TextProps = React.ElementConfig; type Props = { +target: string, +children: React.Node, ...TextProps, }; function MarkdownLink(props: Props): React.Node { const { target, ...rest } = props; const markdownContext = React.useContext(MarkdownContext); invariant(markdownContext, 'MarkdownContext should be set'); const markdownSpoilerContext = React.useContext(MarkdownSpoilerContext); // Since MarkdownSpoilerContext may not be set, we need // to default isRevealed to true for when // we use the ternary operator in the onPress const isRevealed = markdownSpoilerContext?.isRevealed ?? true; const textMessageMarkdownContext = React.useContext( TextMessageMarkdownContext, ); const messageKey = textMessageMarkdownContext?.messageKey; const messagePressResponderContext = React.useContext( MessagePressResponderContext, ); const onPressMessage = messagePressResponderContext?.onPressMessage; const onPressLink = useHandleLinkClick(target, markdownContext, messageKey); return ( ); } export default MarkdownLink; diff --git a/native/qr-code/qr-code-screen.react.js b/native/qr-code/qr-code-screen.react.js index f0edbf9de..64a4ba37a 100644 --- a/native/qr-code/qr-code-screen.react.js +++ b/native/qr-code/qr-code-screen.react.js @@ -1,90 +1,90 @@ // @flow import * as React from 'react'; import { View, Text } from 'react-native'; import QRCode from 'react-native-qrcode-svg'; -import { qrCodeLinkUrl } from 'lib/facts/links.js'; +import { qrCodeLinkURL } from 'lib/facts/links.js'; import type { QRCodeSignInNavigationProp } from './qr-code-sign-in-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; type QRCodeScreenProps = { +navigation: QRCodeSignInNavigationProp<'QRCodeScreen'>, +route: NavigationRoute<'QRCodeScreen'>, }; -const qrCodeValue = qrCodeLinkUrl('random_aes256_key', 'device_ed25519_key'); +const qrCodeValue = qrCodeLinkURL('random_aes256_key', 'device_ed25519_key'); // eslint-disable-next-line no-unused-vars function QRCodeScreen(props: QRCodeScreenProps): React.Node { const styles = useStyles(unboundStyles); return ( Log in to Comm Open the Comm app on your phone and scan the QR code below How to find the scanner: Go to Profile Select Linked devices Click Add on the top right ); } const unboundStyles = { container: { flex: 1, alignItems: 'center', marginTop: 125, }, heading: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 12, }, headingSubtext: { fontSize: 12, color: 'panelForegroundLabel', paddingBottom: 30, }, instructionsBox: { alignItems: 'center', width: 300, marginTop: 40, padding: 15, borderColor: 'panelForegroundLabel', borderWidth: 2, borderRadius: 8, }, instructionsTitle: { fontSize: 12, color: 'panelForegroundLabel', paddingBottom: 15, }, instructionsStep: { fontSize: 12, padding: 1, color: 'panelForegroundLabel', }, instructionsBold: { fontWeight: 'bold', }, }; export default QRCodeScreen; diff --git a/web/account/qr-code-login.react.js b/web/account/qr-code-login.react.js index 12110f9bd..720c38b6c 100644 --- a/web/account/qr-code-login.react.js +++ b/web/account/qr-code-login.react.js @@ -1,36 +1,36 @@ // @flow import { QRCodeSVG } from 'qrcode.react'; import * as React from 'react'; -import { qrCodeLinkUrl } from 'lib/facts/links.js'; +import { qrCodeLinkURL } from 'lib/facts/links.js'; import css from './qr-code-login.css'; -const qrCodeValue = qrCodeLinkUrl('random_aes256_key', 'device_ed25519_key'); +const qrCodeValue = qrCodeLinkURL('random_aes256_key', 'device_ed25519_key'); function QrCodeLogin(): React.Node { return (
Log in to Comm
Open the Comm app on your phone and scan the QR code below
How to find the scanner:
Go to Profile
Select Linked devices
Click Add on the top right
); } export default QrCodeLogin; diff --git a/web/invite-links/copy-invite-link-button.react.js b/web/invite-links/copy-invite-link-button.react.js index 7450f3aa8..bf5606474 100644 --- a/web/invite-links/copy-invite-link-button.react.js +++ b/web/invite-links/copy-invite-link-button.react.js @@ -1,43 +1,43 @@ // @flow import * as React from 'react'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; -import { inviteLinkUrl } from 'lib/facts/links.js'; +import { inviteLinkURL } from 'lib/facts/links.js'; import { useResettingState } from 'lib/hooks/use-resetting-state.js'; import type { InviteLink } from 'lib/types/link-types.js'; import css from './copy-invite-link-button.css'; import Button from '../components/button.react.js'; type Props = { +inviteLink: InviteLink, }; const copiedMessageDurationMs = 2000; function CopyInviteLinkButton(props: Props): React.Node { const { inviteLink } = props; - const url = inviteLinkUrl(inviteLink.name); + const url = inviteLinkURL(inviteLink.name); const [copied, setCopied] = useResettingState(false, copiedMessageDurationMs); const copyLink = React.useCallback(async () => { try { await navigator.clipboard.writeText(url); setCopied(true); } catch (e) { setCopied(false); } }, [setCopied, url]); const buttonText = copied ? 'Copied!' : 'Copy'; return (
{url}
); } export default CopyInviteLinkButton; diff --git a/web/invite-links/manage/edit-link-modal.react.js b/web/invite-links/manage/edit-link-modal.react.js index ec011a165..246adc5af 100644 --- a/web/invite-links/manage/edit-link-modal.react.js +++ b/web/invite-links/manage/edit-link-modal.react.js @@ -1,118 +1,118 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; -import { inviteLinkUrl } from 'lib/facts/links.js'; +import { inviteLinkURL } from 'lib/facts/links.js'; import { useInviteLinksActions } from 'lib/hooks/invite-links.js'; import { defaultErrorMessage, inviteLinkErrorMessages, } from 'lib/shared/invite-links.js'; import type { InviteLink } from 'lib/types/link-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import css from './manage-invite-links-modal.css'; import Button from '../../components/button.react.js'; import Input from '../../modals/input.react.js'; import Modal from '../../modals/modal.react.js'; type Props = { +inviteLink: ?InviteLink, +enterViewMode: () => mixed, +enterDisableMode: () => mixed, +community: ThreadInfo, }; const disableButtonColor = { color: 'var(--error-primary)', borderColor: 'var(--error-primary)', }; function EditLinkModal(props: Props): React.Node { const { inviteLink, enterViewMode, enterDisableMode, community } = props; const { popModal } = useModalContext(); const { error, isLoading, name, setName, createOrUpdateInviteLink } = useInviteLinksActions(community.id, inviteLink); const onChangeName = React.useCallback( (event: SyntheticEvent) => { setName(event.currentTarget.value); }, [setName], ); let errorComponent = null; if (error) { errorComponent = (
{inviteLinkErrorMessages[error] ?? defaultErrorMessage}
); } let disableLinkComponent = null; if (inviteLink) { disableLinkComponent = ( <>
You may also disable the community public link
); } return (

Invite links make it easy for your friends to join your community. Anybody who knows your community’s invite link will be able to join it.

Note that if you change your public link’s URL, other communities will be able to claim the old URL.


Invite URL
- {inviteLinkUrl('')} + {inviteLinkURL('')}
{errorComponent}
{disableLinkComponent}
); } export default EditLinkModal;