diff --git a/lib/hooks/invite-links.js b/lib/hooks/invite-links.js index f7c647d20..c124d8ddc 100644 --- a/lib/hooks/invite-links.js +++ b/lib/hooks/invite-links.js @@ -1,310 +1,319 @@ // @flow import * as React from 'react'; import { addKeyserverActionType } from '../actions/keyserver-actions.js'; import { useCreateOrUpdatePublicLink, createOrUpdatePublicLinkActionTypes, useDisableInviteLink, disableInviteLinkLinkActionTypes, } from '../actions/link-actions.js'; import { joinThreadActionTypes, useJoinThread, } from '../actions/thread-actions.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { createLoadingStatusSelector } from '../selectors/loading-selectors.js'; import { isLoggedInToKeyserver } from '../selectors/user-selectors.js'; import type { KeyserverOverride } from '../shared/invite-links.js'; import { useIsKeyserverURLValid } from '../shared/keyserver-utils.js'; import { callSingleKeyserverEndpointTimeout } from '../shared/timeouts.js'; import type { CalendarQuery } from '../types/entry-types.js'; import type { SetState } from '../types/hook-types.js'; import { defaultKeyserverInfo } from '../types/keyserver-types.js'; import type { InviteLink, InviteLinkVerificationResponse, } from '../types/link-types.js'; import type { LoadingStatus } from '../types/loading-types.js'; import type { ThreadJoinPayload } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useDispatch, useSelector } from '../utils/redux-utils.js'; const createOrUpdatePublicLinkStatusSelector = createLoadingStatusSelector( createOrUpdatePublicLinkActionTypes, ); const disableInviteLinkStatusSelector = createLoadingStatusSelector( disableInviteLinkLinkActionTypes, ); function useInviteLinksActions( communityID: string, inviteLink: ?InviteLink, ): { +error: ?string, +isLoading: boolean, + +isChanged: boolean, +name: string, +setName: SetState, +createOrUpdateInviteLink: () => mixed, +disableInviteLink: () => mixed, } { const [name, setName] = React.useState( inviteLink?.name ?? Math.random().toString(36).slice(-9), ); const [error, setError] = React.useState(null); const dispatchActionPromise = useDispatchActionPromise(); const callCreateOrUpdatePublicLink = useCreateOrUpdatePublicLink(); const createCreateOrUpdateActionPromise = React.useCallback(async () => { setError(null); try { return await callCreateOrUpdatePublicLink({ name, communityID, }); } catch (e) { setError(e.message); throw e; } }, [callCreateOrUpdatePublicLink, communityID, name]); const createOrUpdateInviteLink = React.useCallback(() => { void dispatchActionPromise( createOrUpdatePublicLinkActionTypes, createCreateOrUpdateActionPromise(), ); }, [createCreateOrUpdateActionPromise, dispatchActionPromise]); const disableInviteLinkServerCall = useDisableInviteLink(); const createDisableLinkActionPromise = React.useCallback(async () => { setError(null); try { return await disableInviteLinkServerCall({ name, communityID, }); } catch (e) { setError(e.message); throw e; } }, [disableInviteLinkServerCall, communityID, name]); const disableInviteLink = React.useCallback(() => { void dispatchActionPromise( disableInviteLinkLinkActionTypes, createDisableLinkActionPromise(), ); }, [createDisableLinkActionPromise, dispatchActionPromise]); const disableInviteLinkStatus = useSelector(disableInviteLinkStatusSelector); const createOrUpdatePublicLinkStatus = useSelector( createOrUpdatePublicLinkStatusSelector, ); const isLoading = createOrUpdatePublicLinkStatus === 'loading' || disableInviteLinkStatus === 'loading'; return React.useMemo( () => ({ error, isLoading, + isChanged: name !== inviteLink?.name, name, setName, createOrUpdateInviteLink, disableInviteLink, }), - [createOrUpdateInviteLink, disableInviteLink, error, isLoading, name], + [ + createOrUpdateInviteLink, + disableInviteLink, + error, + inviteLink?.name, + isLoading, + name, + ], ); } const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); type AcceptInviteLinkParams = { +verificationResponse: InviteLinkVerificationResponse, +inviteSecret: string, +keyserverOverride: ?KeyserverOverride, +calendarQuery: () => CalendarQuery, +onFinish: () => mixed, +onInvalidLinkDetected: () => mixed, }; function useAcceptInviteLink(params: AcceptInviteLinkParams): { +joinCommunity: () => mixed, +joinThreadLoadingStatus: LoadingStatus, } { const { verificationResponse, inviteSecret, keyserverOverride, calendarQuery, onFinish, onInvalidLinkDetected, } = params; React.useEffect(() => { if (verificationResponse.status === 'already_joined') { onFinish(); } }, [onFinish, verificationResponse.status]); const dispatch = useDispatch(); const callJoinThread = useJoinThread(); const communityID = verificationResponse.community?.id; let keyserverID = keyserverOverride?.keyserverID; if (!keyserverID && communityID) { keyserverID = extractKeyserverIDFromID(communityID); } const isKeyserverKnown = useSelector(state => keyserverID ? !!state.keyserverStore.keyserverInfos[keyserverID] : false, ); const isAuthenticated = useSelector(isLoggedInToKeyserver(keyserverID)); const keyserverURL = keyserverOverride?.keyserverURL; const isKeyserverURLValid = useIsKeyserverURLValid(keyserverURL); const [ongoingJoinData, setOngoingJoinData] = React.useState mixed, +reject: () => mixed, +communityID: string, }>(null); const timeoutRef = React.useRef(); React.useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); const createJoinCommunityAction = React.useCallback(() => { return new Promise((resolve, reject) => { if ( !keyserverID || !communityID || keyserverID !== extractKeyserverIDFromID(communityID) ) { reject(); onInvalidLinkDetected(); setOngoingJoinData(null); return; } const timeoutID = setTimeout(() => { reject(); setOngoingJoinData(oldData => { if (oldData) { onInvalidLinkDetected(); } return null; }); }, callSingleKeyserverEndpointTimeout); timeoutRef.current = timeoutID; const resolveAndClearTimeout = (result: ThreadJoinPayload) => { clearTimeout(timeoutID); resolve(result); }; const rejectAndClearTimeout = () => { clearTimeout(timeoutID); reject(); }; setOngoingJoinData({ resolve: resolveAndClearTimeout, reject: rejectAndClearTimeout, communityID, }); }); }, [communityID, keyserverID, onInvalidLinkDetected]); React.useEffect(() => { void (async () => { if (!ongoingJoinData || isKeyserverKnown) { return; } const isValid = await isKeyserverURLValid(); if (!isValid || !keyserverURL) { onInvalidLinkDetected(); ongoingJoinData.reject(); setOngoingJoinData(null); return; } dispatch({ type: addKeyserverActionType, payload: { keyserverAdminUserID: keyserverID, newKeyserverInfo: defaultKeyserverInfo(keyserverURL), }, }); })(); }, [ dispatch, isKeyserverKnown, isKeyserverURLValid, ongoingJoinData, keyserverID, keyserverURL, onInvalidLinkDetected, ]); React.useEffect(() => { void (async () => { if (!ongoingJoinData || !isAuthenticated) { return; } const threadID = ongoingJoinData.communityID; const query = calendarQuery(); try { const result = await callJoinThread({ threadID, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [threadID] }, ], }, inviteLinkSecret: inviteSecret, }); onFinish(); ongoingJoinData.resolve(result); } catch (e) { onInvalidLinkDetected(); ongoingJoinData.reject(); } finally { setOngoingJoinData(null); } })(); }, [ calendarQuery, callJoinThread, communityID, inviteSecret, isAuthenticated, ongoingJoinData, onFinish, onInvalidLinkDetected, ]); const dispatchActionPromise = useDispatchActionPromise(); const joinCommunity = React.useCallback(() => { void dispatchActionPromise( joinThreadActionTypes, createJoinCommunityAction(), ); }, [createJoinCommunityAction, dispatchActionPromise]); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); return React.useMemo( () => ({ joinCommunity, joinThreadLoadingStatus, }), [joinCommunity, joinThreadLoadingStatus], ); } export { useInviteLinksActions, useAcceptInviteLink }; diff --git a/native/invite-links/manage-public-link-screen.react.js b/native/invite-links/manage-public-link-screen.react.js index 28ba46072..566264d3b 100644 --- a/native/invite-links/manage-public-link-screen.react.js +++ b/native/invite-links/manage-public-link-screen.react.js @@ -1,223 +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'; 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 1801a6b83..954c46348 100644 --- a/web/invite-links/manage/edit-link-modal.react.js +++ b/web/invite-links/manage/edit-link-modal.react.js @@ -1,118 +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'; 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, name, setName, createOrUpdateInviteLink } = - useInviteLinksActions(community.id, inviteLink); + 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;