fastcomments-react-native-sdk
Version:
React Native FastComments Components. Add live commenting to any React Native application.
431 lines (430 loc) • 26.9 kB
JavaScript
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" }) })] });
}