diff --git a/keyserver/src/creators/invite-link-creator.js b/keyserver/src/creators/invite-link-creator.js index 8f5baf0cf..74cef9505 100644 --- a/keyserver/src/creators/invite-link-creator.js +++ b/keyserver/src/creators/invite-link-creator.js @@ -1,285 +1,283 @@ // @flow import Filter from 'bad-words'; import uuid from 'uuid'; -import { - inviteLinkBlobHash, - inviteSecretRegex, -} from 'lib/shared/invite-links.js'; +import { inviteSecretRegex } from 'lib/shared/invite-links-constants.js'; +import { inviteLinkBlobHash } from 'lib/shared/invite-links.js'; import { isStaff } from 'lib/shared/staff-utils.js'; import type { CreateOrUpdatePublicLinkRequest, InviteLink, } from 'lib/types/link-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { reservedUsernamesSet } from 'lib/utils/reserved-users.js'; import createIDs from './id-creator.js'; import { dbQuery, MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE, SQL, } from '../database/database.js'; import { fetchPrimaryInviteLinks } from '../fetchers/link-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; import { download, type BlobDownloadResult, assignHolder, uploadBlob, deleteBlob, type BlobOperationResult, } from '../services/blob.js'; import { Viewer } from '../session/viewer.js'; import { thisKeyserverID } from '../user/identity.js'; import { getAndAssertKeyserverURLFacts } from '../utils/urls.js'; const badWordsFilter = new Filter(); async function createOrUpdatePublicLink( viewer: Viewer, request: CreateOrUpdatePublicLinkRequest, ): Promise { if (!inviteSecretRegex.test(request.name)) { throw new ServerError('invalid_characters'); } if (badWordsFilter.isProfane(request.name)) { throw new ServerError('offensive_words'); } if (!isStaff(viewer.id) && reservedUsernamesSet.has(request.name)) { throw new ServerError('link_reserved'); } const permissionPromise = checkThreadPermission( viewer, request.communityID, threadPermissions.MANAGE_INVITE_LINKS, ); const existingPrimaryLinksPromise = fetchPrimaryInviteLinks(viewer); const threadIDs = new Set([request.communityID]); if (request.threadID) { threadIDs.add(request.threadID); } const fetchThreadInfoPromise = fetchServerThreadInfos({ threadIDs, }); const blobDownloadPromise = getInviteLinkBlob(request.name); const canManageThreadLinksPromise = request.threadID ? checkThreadPermission( viewer, request.threadID, threadPermissions.MANAGE_INVITE_LINKS, ) : false; const [ hasPermission, existingPrimaryLinks, { threadInfos }, blobDownloadResult, canManageThreadLinks, ] = await Promise.all([ permissionPromise, existingPrimaryLinksPromise, fetchThreadInfoPromise, blobDownloadPromise, canManageThreadLinksPromise, ]); if (!hasPermission || (request.threadID && !canManageThreadLinks)) { throw new ServerError('invalid_credentials'); } if (blobDownloadResult.found) { throw new ServerError('already_in_use'); } const defaultRoleIDs: { [string]: string } = {}; for (const threadID of threadIDs) { const threadInfo = threadInfos[threadID]; if (!threadInfo) { throw new ServerError('invalid_parameters'); } const defaultRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].isDefault, ); if (!defaultRoleID) { throw new ServerError('invalid_parameters'); } defaultRoleIDs[threadID] = defaultRoleID; } const existingPrimaryLink = existingPrimaryLinks.find( link => link.communityID === request.communityID && link.primary && (request.threadID ? link.threadID === request.threadID : !link.threadID), ); const blobHolder = uuid.v4(); const blobResult = await uploadInviteLinkBlob(request.name, blobHolder); if (!blobResult.success) { if (blobResult.reason === 'HASH_IN_USE') { throw new ServerError('already_in_use'); } else { throw new ServerError('unknown_error'); } } if (existingPrimaryLink) { const query = SQL` UPDATE invite_links SET name = ${request.name}, blob_holder = ${blobHolder} WHERE \`primary\` = 1 AND community = ${request.communityID} `; if (request.threadID) { query.append(SQL`AND thread = ${request.threadID}`); } else { query.append(SQL`AND thread IS NULL`); } try { await dbQuery(query); const holder = existingPrimaryLink.blobHolder; if (holder) { await deleteBlob( { hash: inviteLinkBlobHash(existingPrimaryLink.name), holder, }, true, ); } } catch (e) { await deleteBlob( { hash: inviteLinkBlobHash(request.name), holder: blobHolder, }, true, ); if (e.errno === MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE) { throw new ServerError('already_in_use'); } throw new ServerError('invalid_parameters'); } return { name: request.name, primary: true, role: defaultRoleIDs[request.communityID], communityID: request.communityID, expirationTime: null, limitOfUses: null, numberOfUses: 0, }; } const [id] = await createIDs('invite_links', 1); const row = [ id, request.name, true, request.communityID, defaultRoleIDs[request.communityID], blobHolder, request.threadID ?? null, request.threadID ? defaultRoleIDs[request.threadID] : null, ]; const createLinkQuery = SQL` INSERT INTO invite_links(id, name, \`primary\`, community, role, blob_holder, thread, thread_role) SELECT ${row} WHERE NOT EXISTS ( SELECT i.id FROM invite_links i WHERE i.\`primary\` = 1 AND i.community = ${request.communityID} `; if (request.threadID) { createLinkQuery.append(SQL`AND thread = ${request.threadID}`); } else { createLinkQuery.append(SQL`AND thread IS NULL`); } createLinkQuery.append(SQL`)`); let result = null; const deleteIDs = SQL` DELETE FROM ids WHERE id = ${id} `; try { result = (await dbQuery(createLinkQuery))[0]; } catch (e) { await Promise.all([ dbQuery(deleteIDs), deleteBlob( { hash: inviteLinkBlobHash(request.name), holder: blobHolder, }, true, ), ]); if (e.errno === MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE) { throw new ServerError('already_in_use'); } throw new ServerError('invalid_parameters'); } if (result.affectedRows === 0) { await Promise.all([ dbQuery(deleteIDs), deleteBlob( { hash: inviteLinkBlobHash(request.name), holder: blobHolder, }, true, ), ]); throw new ServerError('invalid_parameters'); } return { name: request.name, primary: true, role: defaultRoleIDs[request.communityID], communityID: request.communityID, expirationTime: null, limitOfUses: null, numberOfUses: 0, }; } function getInviteLinkBlob(secret: string): Promise { const hash = inviteLinkBlobHash(secret); return download(hash); } async function uploadInviteLinkBlob( linkSecret: string, holder: string, ): Promise { const keyserverID = await thisKeyserverID(); const { baseDomain, basePath } = getAndAssertKeyserverURLFacts(); const keyserverURL = baseDomain + basePath; const payload = { keyserverID, keyserverURL, }; const payloadString = JSON.stringify(payload); const key = inviteLinkBlobHash(linkSecret); const blob = new Blob([payloadString]); const uploadResult = await uploadBlob(blob, key); if (!uploadResult.success) { return uploadResult; } return await assignHolder({ holder, hash: key }); } export { createOrUpdatePublicLink, uploadInviteLinkBlob, getInviteLinkBlob }; diff --git a/keyserver/src/responders/website-responders.js b/keyserver/src/responders/website-responders.js index cdb57b560..9918ade7c 100644 --- a/keyserver/src/responders/website-responders.js +++ b/keyserver/src/responders/website-responders.js @@ -1,407 +1,407 @@ // @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 * as React from 'react'; // eslint-disable-next-line import/extensions import ReactDOMServer from 'react-dom/server'; import { promisify } from 'util'; import stores from 'lib/facts/stores.js'; -import { inviteSecretRegex } from 'lib/shared/invite-links.js'; +import { inviteSecretRegex } from 'lib/shared/invite-links-constants.js'; import getTitle from 'web/title/get-title.js'; import { waitForStream } from '../utils/json-stream.js'; import { getAndAssertKeyserverURLFacts, getAppURLFactsFromRequestURL, getWebAppURLFacts, } from '../utils/urls.js'; // eslint-disable-next-line react/no-deprecated 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, +backupClientFilename: string, +webworkersOpaqueFilename: 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: '', backupClientFilename: '', webworkersOpaqueFilename: '', }; 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'], backupClientFilename: webworkersManifest['backup-client-wasm_bg.wasm'], webworkersOpaqueFilename: webworkersManifest['comm_opaque2_wasm_bg.wasm'], }; return assetInfo; } catch (e) { console.warn(e); 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 (e) { console.warn(e); throw new Error( 'Could not load app.build.cjs. ' + 'Did you forget to run `yarn dev` in the web folder?', ); } } function stripLastSlash(input: string): string { return input.replace(/\/$/, ''); } async function websiteResponder(req: $Request, res: $Response): Promise { const { basePath } = getAppURLFactsFromRequestURL(req.originalUrl); const baseURL = stripLastSlash(basePath); const keyserverURLFacts = getAndAssertKeyserverURLFacts(); const keyserverURL = `${keyserverURLFacts.baseDomain}${stripLastSlash( keyserverURLFacts.basePath, )}`; const loadingPromise = getWebpackCompiledRootComponentForSSR(); const assetInfoPromise = getAssetInfo(); const { jsURL, fontsURL, cssInclude, olmFilename, commQueryExecutorFilename, backupClientFilename, webworkersOpaqueFilename, } = 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.end(html`
`); } // 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 = getWebAppURLFacts(); 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/shared/invite-links-constants.js b/lib/shared/invite-links-constants.js new file mode 100644 index 000000000..051d13256 --- /dev/null +++ b/lib/shared/invite-links-constants.js @@ -0,0 +1,26 @@ +// @flow + +const inviteLinkErrorMessages: { +[string]: string } = { + invalid_characters: 'Link cannot contain any spaces or special characters.', + offensive_words: 'No offensive or abusive words allowed.', + already_in_use: 'Public link URL already in use.', + link_reserved: + 'This public link is currently reserved. Please contact support@' + + 'comm.app if you would like to claim this link.', +}; + +const defaultErrorMessage = 'Unknown error.'; + +const inviteSecretRegexString = '[a-zA-Z0-9-]+'; + +const inviteSecretRegex: RegExp = new RegExp( + `^${inviteSecretRegexString}$`, + 'i', +); + +export { + inviteSecretRegex, + inviteSecretRegexString, + defaultErrorMessage, + inviteLinkErrorMessages, +}; diff --git a/lib/shared/invite-links.js b/lib/shared/invite-links.js index 8c6c502b5..3697c915d 100644 --- a/lib/shared/invite-links.js +++ b/lib/shared/invite-links.js @@ -1,61 +1,37 @@ // @flow import blobService from '../facts/blob-service.js'; import { getBlobFetchableURL } from '../utils/blob-service.js'; -const inviteLinkErrorMessages: { +[string]: string } = { - invalid_characters: 'Link cannot contain any spaces or special characters.', - offensive_words: 'No offensive or abusive words allowed.', - already_in_use: 'Public link URL already in use.', - link_reserved: - 'This public link is currently reserved. Please contact support@' + - 'comm.app if you would like to claim this link.', -}; - -const defaultErrorMessage = 'Unknown error.'; - function inviteLinkBlobHash(secret: string): string { return `invite_${secret}`; } export type KeyserverOverride = { +keyserverID: string, +keyserverURL: string, }; async function getKeyserverOverrideForAnInviteLink( secret: string, ): Promise { const blobURL = getBlobFetchableURL(inviteLinkBlobHash(secret)); const result = await fetch(blobURL, { method: blobService.httpEndpoints.GET_BLOB.method, }); if (result.status !== 200) { return null; } const resultText = await result.text(); const resultObject = JSON.parse(resultText); if (resultObject.keyserverID && resultObject.keyserverURL) { const keyserverURL: string = resultObject.keyserverURL; return { keyserverID: resultObject.keyserverID, keyserverURL: keyserverURL.replace(/\/$/, ''), }; } return null; } -const inviteSecretRegexString = '[a-zA-Z0-9-]+'; -const inviteSecretRegex: RegExp = new RegExp( - `^${inviteSecretRegexString}$`, - 'i', -); - -export { - inviteLinkErrorMessages, - defaultErrorMessage, - inviteLinkBlobHash, - getKeyserverOverrideForAnInviteLink, - inviteSecretRegexString, - inviteSecretRegex, -}; +export { inviteLinkBlobHash, getKeyserverOverrideForAnInviteLink }; diff --git a/lib/utils/url-utils.js b/lib/utils/url-utils.js index e293bdb5b..5464a2553 100644 --- a/lib/utils/url-utils.js +++ b/lib/utils/url-utils.js @@ -1,166 +1,166 @@ // @flow import t, { type TInterface } from 'tcomb'; import { idSchemaRegex, tID, tShape, pendingThreadIDRegex, tUserID, } from './validation-utils.js'; -import { inviteSecretRegexString } from '../shared/invite-links.js'; +import { inviteSecretRegexString } from '../shared/invite-links-constants.js'; type MutableURLInfo = { year?: number, month?: number, // 1-indexed verify?: string, calendar?: boolean, chat?: boolean, thread?: string, settings?: | 'account' | 'friend-list' | 'block-list' | 'keyservers' | 'build-info' | 'danger-zone', threadCreation?: boolean, selectedUserList?: $ReadOnlyArray, inviteSecret?: string, qrCode?: boolean, ... }; export type URLInfo = $ReadOnly; export const urlInfoValidator: TInterface = tShape({ year: t.maybe(t.Number), month: t.maybe(t.Number), verify: t.maybe(t.String), calendar: t.maybe(t.Boolean), chat: t.maybe(t.Boolean), thread: t.maybe(tID), settings: t.maybe( t.enums.of([ 'account', 'friend-list', 'block-list', 'keyservers', 'build-info', 'danger-zone', ]), ), threadCreation: t.maybe(t.Boolean), selectedUserList: t.maybe(t.list(tUserID)), inviteSecret: t.maybe(t.String), qrCode: t.maybe(t.Boolean), }); // We use groups to capture parts of the URL and any changes // to regexes must be reflected in infoFromURL. const yearRegex = new RegExp('(/|^)year/([0-9]+)(/|$)', 'i'); const monthRegex = new RegExp('(/|^)month/([0-9]+)(/|$)', 'i'); const threadRegex = new RegExp(`(/|^)thread/(${idSchemaRegex})(/|$)`, 'i'); const verifyRegex = new RegExp('(/|^)verify/([a-f0-9]+)(/|$)', 'i'); const calendarRegex = new RegExp('(/|^)calendar(/|$)', 'i'); const chatRegex = new RegExp('(/|^)chat(/|$)', 'i'); const accountSettingsRegex = new RegExp('(/|^)settings/account(/|$)', 'i'); const friendListRegex = new RegExp('(/|^)settings/friend-list(/|$)', 'i'); const blockListRegex = new RegExp('(/|^)settings/block-list(/|$)', 'i'); const keyserversRegex = new RegExp('(/|^)settings/keyservers(/|$)', 'i'); const buildInfoRegex = new RegExp('(/|^)settings/build-info(/|$)', 'i'); const dangerZoneRegex = new RegExp('(/|^)settings/danger-zone(/|$)', 'i'); const threadPendingRegex = new RegExp( `(/|^)thread/(${pendingThreadIDRegex})(/|$)`, 'i', ); const threadCreationRegex = new RegExp( '(/|^)thread/new(/([0-9]+([+][0-9]+)*))?(/|$)', 'i', ); const inviteLinkRegex = new RegExp( `(/|^)handle/invite/(${inviteSecretRegexString})(/|$)`, 'i', ); const qrCodeLoginRegex = new RegExp('(/|^)qr-code(/|$)', 'i'); function infoFromURL(url: string): URLInfo { const yearMatches = yearRegex.exec(url); const monthMatches = monthRegex.exec(url); const threadMatches = threadRegex.exec(url); const verifyMatches = verifyRegex.exec(url); const calendarTest = calendarRegex.test(url); const chatTest = chatRegex.test(url); const accountSettingsTest = accountSettingsRegex.test(url); const friendListTest = friendListRegex.test(url); const blockListTest = blockListRegex.test(url); const keyserversSettingsTest = keyserversRegex.test(url); const buildInfoTest = buildInfoRegex.test(url); const dangerZoneTest = dangerZoneRegex.test(url); const threadPendingMatches = threadPendingRegex.exec(url); const threadCreateMatches = threadCreationRegex.exec(url); const inviteLinkMatches = inviteLinkRegex.exec(url); const qrCodeLoginMatches = qrCodeLoginRegex.exec(url); const returnObj: MutableURLInfo = {}; if (yearMatches) { returnObj.year = parseInt(yearMatches[2], 10); } if (monthMatches) { const month = parseInt(monthMatches[2], 10); if (month < 1 || month > 12) { throw new Error('invalid_month'); } returnObj.month = month; } if (threadMatches) { returnObj.thread = threadMatches[2]; } if (threadPendingMatches) { returnObj.thread = threadPendingMatches[2]; } if (threadCreateMatches) { returnObj.threadCreation = true; returnObj.selectedUserList = threadCreateMatches[3]?.split('+') ?? []; } if (verifyMatches) { returnObj.verify = verifyMatches[2]; } if (inviteLinkMatches) { returnObj.inviteSecret = inviteLinkMatches[2]; } if (calendarTest) { returnObj.calendar = true; } else if (chatTest) { returnObj.chat = true; } else if (accountSettingsTest) { returnObj.settings = 'account'; } else if (friendListTest) { returnObj.settings = 'friend-list'; } else if (blockListTest) { returnObj.settings = 'block-list'; } else if (keyserversSettingsTest) { returnObj.settings = 'keyservers'; } else if (buildInfoTest) { returnObj.settings = 'build-info'; } else if (dangerZoneTest) { returnObj.settings = 'danger-zone'; } else if (qrCodeLoginMatches) { returnObj.qrCode = true; } return returnObj; } const setURLPrefix = 'SET_URL_PREFIX'; export type URLPathParams = { +[name: string]: string }; function replacePathParams(path: string, params: URLPathParams = {}): string { for (const name in params) { path = path.replace(`:${name}`, params[name]); } return path; } export { infoFromURL, setURLPrefix, replacePathParams }; diff --git a/native/invite-links/manage-public-link-screen.react.js b/native/invite-links/manage-public-link-screen.react.js index 566264d3b..1426b147c 100644 --- a/native/invite-links/manage-public-link-screen.react.js +++ b/native/invite-links/manage-public-link-screen.react.js @@ -1,224 +1,224 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; 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'; +} from 'lib/shared/invite-links-constants.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-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, isChanged, 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('')} {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/web/invite-links/manage/edit-link-modal.react.js b/web/invite-links/manage/edit-link-modal.react.js index 954c46348..bab8246d8 100644 --- a/web/invite-links/manage/edit-link-modal.react.js +++ b/web/invite-links/manage/edit-link-modal.react.js @@ -1,124 +1,124 @@ // @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 { useInviteLinksActions } from 'lib/hooks/invite-links.js'; import { defaultErrorMessage, inviteLinkErrorMessages, -} from 'lib/shared/invite-links.js'; +} from 'lib/shared/invite-links-constants.js'; import type { InviteLink } from 'lib/types/link-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-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, isChanged, 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('')}
{errorComponent}
{disableLinkComponent}
); } export default EditLinkModal;