UNPKG

fastcomments-react-native-sdk

Version:

React Native FastComments Components. Add live commenting to any React Native application.

431 lines (430 loc) 26.9 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { View, Text, Image, Linking, ActivityIndicator, TextInput, useWindowDimensions, TouchableOpacity } from "react-native"; import { none, useHookstate, useHookstateEffect } from "@hookstate/core"; import { FastCommentsImageAsset } from '../types'; import { getDefaultAvatarSrc } from "../services/default-avatar"; import { ModalMenu } from "./modal-menu"; import { useEffect } from 'react'; import { ThreeDot } from "./three-dot"; import { NotificationBell } from "./notification-bell"; import { CommentAreaMessage } from "./comment-area-message"; import { CommentTextArea } from "./comment-text-area"; import { getActionTenantId, getActionURLID } from "../services/tenants"; import { newBroadcastId } from "../services/broadcast-id"; import { createURLQueryString, makeRequest } from "../services/http"; import { handleNewCustomConfig } from "../services/custom-config"; import { incOverallCommentCount } from "../services/comment-count"; import { addCommentToTree } from "../services/comment-trees"; import { setupUserPresenceState } from "../services/user-presense"; import { persistSubscriberState } from "../services/live"; import RenderHtml from 'react-native-render-html'; import { EmoticonBar } from './wysiwyg/emoticon-bar'; // TODO replace with translatedError response which would reduce initial bundle size const SignUpErrorsTranslationIds = { 'username-taken': 'USERNAME_TAKEN', 'invalid-name': 'INVALID_USERNAME', 'invalid-name-is-email': 'USERNAME_CANT_BE_EMAIL' }; async function logout(state, callbacks) { if (state.config.sso.get()) { if (state.config.sso.get().logoutURL) { await Linking.openURL(state.config.sso.get().logoutURL); return; } else if (state.config.sso.get().logoutCallback) { state.config.sso.get().logoutCallback(''); } } await makeRequest({ apiHost: state.apiHost.get(), method: 'PUT', url: '/auth/logout' }); const currentUser = state.currentUser.get(); let currentUserId = currentUser && 'id' in currentUser && currentUser.id; // reset SSO config to log out the user. if (state.config.sso.get()) { state.config.sso.set((sso) => { if (sso) { sso.userDataJSONBase64 = null; sso.verificationHash = null; } return sso; }); } // update the cached sso state passed in all api calls // if we allow anon, just turn off SSO in API calls - since we will just use anon commenting until widget is reloaded with SSO config again. state.ssoConfigString.set(state.config.allowAnon.get() ? undefined : (state.config.sso.get() ? JSON.stringify(state.config.sso.get()) : undefined)); // reset the currently logged in user state.currentUser.set(null); persistSubscriberState(state, state.urlIdWS.get(), state.tenantIdWS.get(), null); // reconnect w/o a user callbacks?.onAuthenticationChange && callbacks.onAuthenticationChange('logout', currentUser, null); state.userNotificationState.set({ isOpen: false, isLoading: false, count: 0, notifications: [], isPaginationInProgress: false, isSubscribed: false, }); state.userPresenceState.set((userPresenceState) => { if (currentUserId) { delete userPresenceState.usersOnlineMap[currentUserId]; delete userPresenceState.userIdsToCommentIds[currentUserId]; } return userPresenceState; }); await setupUserPresenceState(state, state.urlIdWS.get()); } async function submit({ state, parentComment, onReplySuccess, onAuthenticationChange, }, commentReplyState) { if (state.config.readonly.get()) { return; } const replyingToId = parentComment?._id; let isAuthenticating = false; const allowAnon = state.config.allowAnon.get(); const currentUserBeforeSubmit = state.currentUser.get(); const lastCurrentUserId = currentUserBeforeSubmit && 'id' in currentUserBeforeSubmit ? currentUserBeforeSubmit.id : null; if (!currentUserBeforeSubmit && commentReplyState.username) { isAuthenticating = true; } if (!commentReplyState.comment || (!allowAnon && (!(commentReplyState.username || currentUserBeforeSubmit?.username) || (!allowAnon && !(commentReplyState.email || (currentUserBeforeSubmit && 'email' in currentUserBeforeSubmit && currentUserBeforeSubmit.email)))))) { return; } // TODO validate email // if (!allowAnon && ... is email invalid ...) { // return; // } const tenantIdToUse = getActionTenantId({ state, tenantId: parentComment?.tenantId }); const urlIdToUse = getActionURLID({ state, urlId: parentComment?.urlId }); const date = new Date(); const newComment = { tenantId: tenantIdToUse, urlId: urlIdToUse, url: state.config.url.get(), pageTitle: state.config.pageTitle.get(), commenterName: currentUserBeforeSubmit && 'username' in currentUserBeforeSubmit ? currentUserBeforeSubmit.username : commentReplyState.username.get(), commenterEmail: currentUserBeforeSubmit && 'email' in currentUserBeforeSubmit ? currentUserBeforeSubmit.email : commentReplyState.email.get(), commenterLink: currentUserBeforeSubmit && 'websiteUrl' in currentUserBeforeSubmit ? currentUserBeforeSubmit.websiteUrl : commentReplyState.websiteUrl.get(), avatarSrc: currentUserBeforeSubmit && state.config.simpleSSO.get() ? currentUserBeforeSubmit.avatarSrc : undefined, comment: commentReplyState.comment.get(), parentId: replyingToId, date: date.valueOf(), localDateString: date.toString(), localDateHours: date.getHours(), productId: state.config.productId.get(), meta: state.config.commentMeta.get(), // mentions: inputElement.currentCommentMentions, TODO // hashTags: inputElement.currentCommentHashTags, TODO moderationGroupIds: state.config.moderationGroupIds.get(), isFromMyAccountPage: state.config.tenantId.get() === 'all' }; const broadcastId = newBroadcastId(); try { // console.log('Submitting for current user', currentUserBeforeSubmit); // console.log('Submitting for reply state', commentReplyState); console.log('Submitting', newComment); const response = await makeRequest({ apiHost: state.apiHost.get(), method: 'POST', url: `/comments/${tenantIdToUse}/${createURLQueryString({ urlId: urlIdToUse, sso: state.ssoConfigString.get(), broadcastId, sessionId: currentUserBeforeSubmit && 'sessionId' in currentUserBeforeSubmit ? currentUserBeforeSubmit.sessionId : undefined, })}`, body: newComment }); let showSuccessMessage = false; if (response.customConfig) { handleNewCustomConfig(state, response.customConfig); } const comment = response.comment; const wasSuccessful = response.status === 'success' && comment; if (wasSuccessful) { comment.wasPostedCurrentSession = true; state.commentCountOnClient.set((commentCountOnClient) => { commentCountOnClient++; return commentCountOnClient; }); state.commentsById[comment._id].set(comment); if (replyingToId) { comment.repliesHidden = false; } addCommentToTree(state.allComments, state.commentsTree, state.commentsById, comment, !!state.config.newCommentsToBottom.get()); incOverallCommentCount(state.config.countAll.get(), state, comment.parentId); if (response.user) { if (state.config.simpleSSO.get()) { // for avatar, for example. state.currentUser.merge(response.user); } else { state.currentUser.set(response.user); } onAuthenticationChange && onAuthenticationChange('user-set', state.currentUser.get(), comment); } if (currentUserBeforeSubmit && response.user && 'sessionId' in response.user && response.user.sessionId) { state.currentUser.merge({ sessionId: response.user.sessionId }); onAuthenticationChange && onAuthenticationChange('session-id-set', state.currentUser.get(), comment); } if (replyingToId === null && !state.config.disableSuccessMessage.get()) { showSuccessMessage = true; } const newCurrentUserId = currentUserBeforeSubmit && 'id' in currentUserBeforeSubmit ? currentUserBeforeSubmit.id : null; // reconnect to new websocket channel if needed if (newCurrentUserId !== lastCurrentUserId || response.userIdWS !== state.userIdWS.get()) { // TODO // persistSubscriberState(urlIdWS, tenantIdWS, response.userIdWS); } else { // noinspection ES6MissingAwait setupUserPresenceState(state, response.userIdWS); } } else { if (isAuthenticating) { state.currentUser.set(null); // saved to authenticate - can't say we are logged in. onAuthenticationChange && onAuthenticationChange('authentication-failed', null, comment); } if (response.translations) { state.config.translations.merge(response.translations); } } if (response.maxCharacterLength && response.maxCharacterLength !== state.config.maxCommentCharacterLength.get()) { state.config.maxCommentCharacterLength.set(response.maxCharacterLength); // update UI } commentReplyState.set((commentReplyState) => { const newCommentReplyState = { ...commentReplyState, isReplySaving: false, showAuthInputForm: false, showSuccessMessage, lastSaveResponse: response, }; if (wasSuccessful) { // we only reset these on success. newCommentReplyState.username = none; newCommentReplyState.email = none; newCommentReplyState.websiteUrl = none; newCommentReplyState.comment = ''; } return newCommentReplyState; }); // important that this is after commentReplyState.set otherwise we will detatch the ReplyArea onReplySuccess() and then // we will do a state mutation on a destroyed state object, causing HOOKSTATE-106. if (wasSuccessful) { onReplySuccess && onReplySuccess(comment); } } catch (response) { if ('customConfig' in response && response.customConfig) { handleNewCustomConfig(state, response.customConfig); } if (isAuthenticating) { state.currentUser.set(null); // saved to authenticate - can't say we are logged in. onAuthenticationChange && onAuthenticationChange('authentication-failed', null, newComment); } commentReplyState.set((commentReplyState) => { return { ...commentReplyState, isReplySaving: false, showAuthInputForm: false, showSuccessMessage: false, lastSaveResponse: response }; }); } } export function ReplyArea(props) { const { imageAssets, onNotificationSelected, onAuthenticationChange, onReplySuccess, parentComment, pickGIF, pickImage, replyingTo, styles, translations, } = props; const state = useHookstate(props.state); // create scoped state const currentUser = state.currentUser?.get(); const needsAuth = !currentUser && !!parentComment; const valueGetter = {}; const focusObserver = {}; useHookstateEffect(() => { if (parentComment) { focusObserver.setFocused && focusObserver.setFocused(true); } }, [parentComment]); const commentReplyState = useHookstate({ isReplySaving: false, showSuccessMessage: false, // for root comment area, we don't show the auth input form until they interact to save screen space. showAuthInputForm: needsAuth, }); useEffect(() => { if (!!parentComment && state.config.useSingleReplyField.get()) { commentReplyState.comment.set(`**@${parentComment.commenterName}** `); } }, [parentComment]); const { width } = useWindowDimensions(); const getLatestInputValue = () => { const latestValue = valueGetter.getValue ? valueGetter.getValue() : ''; commentReplyState.comment.set(latestValue); }; // parentId check is so that we don't allow new comments to root, but we do allow new comments **inline** if (state.config.readonly.get() || (!parentComment && state.config.noNewRootComments.get())) { return null; } // TODO OPTIMIZE BENCHMARK: faster solution than using RenderHtml. RenderHtml is easy because the translation is HTML, but it only has <b></b> elements. // We can't hardcode the order of the bold elements due to localization, so rendering HTML is nice. But we can probably transform this into native elements faster than RenderHtml. const replyToText = parentComment // we intentionally don't use the REPLYING_TO_AS translation like web to save horizontal space for the cancel button ? _jsx(RenderHtml, { source: { html: translations.REPLYING_TO.replace('[to]', parentComment?.commenterName) }, contentWidth: width, baseStyle: styles.replyArea?.replyingToText }) : null; const ssoConfig = state.config.sso?.get() || state.config.simpleSSO?.get(); let ssoLoginWrapper; let reactsBar; let topBar; let commentInputArea; let commentSubmitButton; let authFormArea; if (!currentUser && ssoConfig && !state.config.allowAnon.get()) { if (ssoConfig.loginURL || ssoConfig.loginCallback) { // if they don't define a URL, we just show a message. ssoLoginWrapper = _jsx(View, { style: styles.replyArea?.ssoLoginWrapper, children: _jsxs(TouchableOpacity, { style: styles.replyArea?.ssoLoginButton, onPress: async () => { if (ssoConfig.loginURL) { await Linking.openURL(ssoConfig.loginURL); } else if (ssoConfig?.loginCallback) { ssoConfig.loginCallback(''); // we do not need instance id in react widget } }, children: [_jsx(Image, { source: imageAssets[FastCommentsImageAsset.ICON_BUBBLE_WHITE], style: { width: 22, height: 22 } }), _jsx(Text, { style: styles.replyArea?.ssoLoginButtonText, children: translations.LOG_IN })] }) }); } else { ssoLoginWrapper = _jsx(View, { style: styles.replyArea?.ssoLoginWrapper, children: _jsxs(View, { style: styles.replyArea?.ssoLoginButton, children: [_jsx(Image, { source: imageAssets[FastCommentsImageAsset.ICON_BUBBLE_WHITE], style: { width: 22, height: 22 } }), _jsx(Text, { style: styles.replyArea?.ssoLoginButtonText, children: translations.LOG_IN_TO_COMMENT })] }) }); } } else { if (!parentComment && currentUser) { topBar = _jsxs(View, { style: styles.replyArea?.topBar, children: [_jsxs(View, { style: styles.replyArea?.loggedInInfo, children: [_jsx(View, { style: styles.replyArea?.topBarAvatarWrapper, children: _jsx(Image, { style: styles.replyArea?.topBarAvatar, source: currentUser.avatarSrc ? { uri: currentUser.avatarSrc } : getDefaultAvatarSrc(imageAssets) }) }), _jsx(Text, { style: styles.replyArea?.topBarUsername, children: currentUser.username })] }), _jsxs(View, { style: styles.replyArea?.topBarRight, children: [(!ssoConfig || (ssoConfig && (ssoConfig.logoutURL || ssoConfig.logoutCallback))) && _jsx(ModalMenu, { closeIcon: imageAssets[state.config.hasDarkBackground.get() ? FastCommentsImageAsset.ICON_CROSS_WHITE : FastCommentsImageAsset.ICON_CROSS], styles: styles, items: [ { id: 'logout', label: translations.LOG_OUT, handler: async (setModalId) => { await logout(state, { onAuthenticationChange }); setModalId(null); } } ], openButton: _jsx(ThreeDot, { styles: styles }) }), _jsx(NotificationBell, { imageAssets: imageAssets, onNotificationSelected: onNotificationSelected, state: state, styles: styles, translations: translations })] })] }); } let commentInputAreaContent; if (commentReplyState.showSuccessMessage.get()) { commentInputAreaContent = CommentAreaMessage({ message: translations.COMMENT_HAS_BEEN_SUBMITTED, styles }); } else { const inlineReactImages = state.config.inlineReactImages.get(); let emoticonBarConfig = {}; if (inlineReactImages) { emoticonBarConfig.emoticons = inlineReactImages.map(function (src) { return [src, _jsx(Image, { source: { uri: src }, style: styles.commentTextAreaEmoticonBar?.icon })]; }); reactsBar = _jsx(EmoticonBar, { config: emoticonBarConfig, styles: styles.commentTextAreaEmoticonBar }); } commentInputAreaContent = _jsx(CommentTextArea, { emoticonBarConfig: emoticonBarConfig, styles: styles, state: state.get(), value: commentReplyState.comment.get(), output: valueGetter, focusObserver: focusObserver, onFocus: () => needsAuth && !commentReplyState.showAuthInputForm.get() && commentReplyState.showAuthInputForm.set(true), pickImage: pickImage, pickGIF: pickGIF }); } commentInputArea = _jsx(View, { style: [styles.replyArea?.commentInputArea, (commentReplyState.isReplySaving.get() ? styles.replyArea?.commentInputAreaReplySaving : null)], children: commentInputAreaContent }); const handleSubmit = async () => { getLatestInputValue(); if (commentReplyState.showSuccessMessage.get() && !parentComment) { commentReplyState.showSuccessMessage.set(false); // hide success message in this case } else { commentReplyState.isReplySaving.set(true); try { // note! This will modify commentReplyState and call onReplySuccess in the desired/required order. await submit({ state, parentComment, onReplySuccess }, commentReplyState); } catch (e) { console.error('Failed to save a comment', e); } if (parentComment && parentComment) { parentComment.replyBoxOpen = false; } } }; if (!commentReplyState.isReplySaving.get()) { commentSubmitButton = _jsx(View, { style: styles.replyArea?.replyButtonWrapper, children: _jsxs(TouchableOpacity, { style: styles.replyArea?.replyButton, onPress: handleSubmit, children: [_jsx(Text, { style: styles.replyArea?.replyButtonText, children: commentReplyState.showSuccessMessage.get() ? translations.WRITE_ANOTHER_COMMENT : translations.SUBMIT_REPLY }), _jsx(Image, { source: parentComment ? (state.config.hasDarkBackground.get() ? imageAssets[FastCommentsImageAsset.ICON_RETURN_WHITE] : imageAssets[FastCommentsImageAsset.ICON_RETURN]) : (state.config.hasDarkBackground.get() ? imageAssets[FastCommentsImageAsset.ICON_BUBBLE_WHITE] : imageAssets[FastCommentsImageAsset.ICON_BUBBLE]), style: styles.replyArea?.replyButtonIcon })] }) }); } if (commentReplyState.showAuthInputForm.get() || (commentReplyState.lastSaveResponse.get()?.code && SignUpErrorsTranslationIds[commentReplyState.lastSaveResponse.get().code])) { // checking for just true here causes the user to appear to logout on any failure, which is weird. authFormArea = _jsxs(View, { style: styles.replyArea?.userInfoInput, children: [!state.config.disableEmailInputs.get && _jsx(Text, { style: styles.replyArea?.emailReasoning, children: state.config.allowAnon.get() ? translations.ENTER_EMAIL_TO_KEEP_COMMENT : translations.ENTER_EMAIL_TO_COMMENT }), !state.config.disableEmailInputs.get() && _jsx(TextInput, { style: styles.replyArea?.authInput, multiline: false, maxLength: 70, placeholder: translations.EMAIL_FOR_VERIFICATION, textContentType: "emailAddress", keyboardType: "email-address", autoComplete: "email", value: commentReplyState.email.get(), returnKeyType: state.config.enableCommenterLinks.get() ? 'next' : 'send', onChangeText: (value) => commentReplyState.email.set(value) }), _jsx(TextInput, { style: styles.replyArea?.authInput, multiline: false, maxLength: 70, placeholder: translations.PUBLICLY_DISPLAYED_USERNAME, textContentType: 'username', autoComplete: 'username', value: commentReplyState.username.get(), returnKeyType: state.config.enableCommenterLinks.get() ? 'next' : 'send', onChangeText: (value) => commentReplyState.username.set(value) }), state.config.enableCommenterLinks.get() && _jsx(TextInput, { style: styles.replyArea?.authInput, maxLength: 500, placeholder: translations.ENTER_A_LINK, onChangeText: (value) => commentReplyState.websiteUrl.set(value) }), commentReplyState.lastSaveResponse.get()?.code && SignUpErrorsTranslationIds[commentReplyState.lastSaveResponse.get().code] && _jsx(Text, { style: styles.replyArea?.error, children: translations[SignUpErrorsTranslationIds[commentReplyState.lastSaveResponse.get().code]] }), !state.config.disableEmailInputs && _jsx(Text, { style: styles.replyArea?.solicitationInfo, children: translations.NO_SOLICITATION_EMAILS })] }); } // We don't allow cancelling when replying to top-level comments. // This is currently disabled because the reply box open state is now completely manged in CommentBottom as an optimization. // if (parentComment) { // replyCancelButton = <View style={styles.replyArea?.replyCancelButtonWrapper}> // <TouchableOpacity style={styles.replyArea?.replyCancelButton} onPress={() => parentComment.replyBoxOpen.set(false)}> // <Image source={imageAssets[FastCommentsImageAsset.ICON_CROSS]} // style={{width: 9, height: 9}}/> // </TouchableOpacity> // </View> // } } let displayError; const lastSaveResponse = commentReplyState.lastSaveResponse.get(); if (lastSaveResponse && lastSaveResponse.status !== 'success') { if (lastSaveResponse.code === 'banned') { let bannedText = translations.BANNED_COMMENTING; if (lastSaveResponse.bannedUntil) { bannedText += ' ' + translations.BAN_ENDS.replace('[endsText]', new Date(lastSaveResponse.bannedUntil).toLocaleString()); } displayError = _jsx(Text, { style: styles.replyArea?.error, children: bannedText }); } else if (lastSaveResponse.code === 'user-rate-limited') { displayError = _jsx(Text, { style: styles.replyArea?.error, children: translations.COMMENTING_TOO_QUICKLY }); } else if (lastSaveResponse.code === 'rate-limited') { displayError = _jsx(Text, { style: styles.replyArea?.error, children: translations.RATE_LIMITED }); } else if (lastSaveResponse.code === 'profile-comments-private') { displayError = _jsx(Text, { style: styles.replyArea?.error, children: translations.PROFILE_COMMENTS_PRIVATE }); } else if (lastSaveResponse.code === 'profile-dm-private') { displayError = _jsx(Text, { style: styles.replyArea?.error, children: translations.PROFILE_DM_PRIVATE }); } else if (lastSaveResponse.code === 'comment-too-big') { displayError = _jsx(Text, { style: styles.replyArea?.error, children: translations.COMMENT_TOO_BIG.replace('[count]', lastSaveResponse.maxCharacterLength + '') }); } else if (lastSaveResponse.translatedError) { displayError = _jsx(Text, { style: styles.replyArea?.error, children: "lastSaveResponse.translatedError" }); } else if (lastSaveResponse.code) { // TODO this case should probably be deprecated and replaced by the server sending translatedError const translatedError = translations[lastSaveResponse.code]; displayError = _jsx(Text, { style: styles.replyArea?.error, children: translatedError }); } else { // generic error displayError = _jsx(Text, { style: styles.replyArea?.error, children: state.translations.ERROR_MESSAGE.get() }); } } // Sometimes you want to put this all on one line, so having it in one container is helpful. erebus-dark theme uses this. const topBarInputAreaAndSubmit = _jsxs(View, { style: styles.replyArea?.topBarAndInputArea, children: [topBar, commentInputArea, authFormArea, commentSubmitButton] }); return _jsxs(View, { children: [replyToText && _jsxs(View, { style: styles.replyArea?.replyingTo, children: [replyToText, state.config.useSingleReplyField.get() && _jsx(TouchableOpacity, { onPress: () => { // TODO CONFIRM replyingTo && replyingTo(null); commentReplyState.comment.set(''); }, children: _jsx(Text, { style: styles.replyArea?.replyingToCancelText, children: state.translations.CANCEL.get() }) })] }), ssoLoginWrapper, displayError, reactsBar, topBarInputAreaAndSubmit, commentReplyState.isReplySaving.get() && _jsx(View, { style: styles.replyArea?.loadingView, children: _jsx(ActivityIndicator, { size: "large" }) })] }); }