comments.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import { View, Row, ScrollView } from 'app/design/view'
  2. import { useEffect, useMemo } from 'react'
  3. import { getFormFieldByData } from 'app/lib/form/form-helpers'
  4. import { Keyboard, Platform } from 'react-native'
  5. import { useFormContext } from 'react-hook-form'
  6. import { editorHtmlHasContent, editorHtmlIsSubmittable, hasFiles, removeEmptyTags, getStoragePickerKind } from 'app/lib/util'
  7. import { useCurrentUser } from 'app/context/user'
  8. import Profile from 'app/ui/molecules/profile/profile'
  9. import { FileButton } from 'app/lib/form/form-helpers'
  10. import { useWindowHeight, useIsDesktop } from 'app/context/measure';
  11. import emitter, { EVENTS } from 'app/context/emitter';
  12. import { useTranslation } from 'react-i18next'
  13. import { StarsAction } from 'app/ui/atoms/stars'
  14. export default function FormComments(props) {
  15. const { t } = useTranslation()
  16. const isWeb = Platform.OS == 'web'
  17. const screenHeight = useWindowHeight();
  18. const isDesktop = useIsDesktop();
  19. const exProps = props?.exProps ?? {};
  20. const isModalForm = !!exProps.isModal;
  21. const shouldGrowFromBottom = isModalForm || !isDesktop;
  22. const minHeightEditor = props.data.inputs['cmt_id']?.value ? 160 : 20;
  23. const maxHeightEditor = isWeb ? 160 : (screenHeight - 300) / 2 // 300 is approximate keyboard height
  24. const formContext = useFormContext()
  25. const { register, setValue } = formContext
  26. const { currentUser } = useCurrentUser()
  27. let isAutoFocus = false;
  28. if (props.data.inputs['cmt_text']?.value){
  29. isAutoFocus = true;
  30. }
  31. if (props.data.inputs['cmt_text'].autofocus){
  32. isAutoFocus = props.data.inputs['cmt_text'].autofocus;
  33. }
  34. const rawEditorText = formContext.watch('cmt_text')
  35. const imagesValue = formContext.watch('cmt_image')
  36. const moodValue = formContext.watch('cmt_mood')
  37. const hasText = useMemo(
  38. () => editorHtmlHasContent(rawEditorText),
  39. [rawEditorText]
  40. );
  41. const hasSubmittableText = useMemo(
  42. () => editorHtmlIsSubmittable(rawEditorText),
  43. [rawEditorText]
  44. );
  45. function setIsFocus() {
  46. // setIsExImage(false)
  47. }
  48. useEffect(() => {
  49. // The success cue plays when UNA returns the new comment (comments-browse).
  50. if (formContext.formState.isSubmitted) {
  51. formContext.setValue('cmt_text', '');
  52. if (props.data.inputs['cmt_mood']) {
  53. formContext.setValue('cmt_mood', 0);
  54. }
  55. emitter.emit(EVENTS.comment, { action: 'send'})
  56. emitter.emit(EVENTS.editor, { action: 'set_content', value: '' })
  57. emitter.emit(EVENTS.fieldFiles('cmt_image'), { action: 'clear' })
  58. if (!isWeb) {
  59. emitter.emit(EVENTS.editor, { action: 'blur' })
  60. Keyboard.dismiss()
  61. }
  62. }
  63. }, [formContext.formState.isSubmitted, formContext, isWeb]);
  64. const handleSubmitWithSanitization = () => {
  65. let sanitizedHtml = removeEmptyTags(formContext.getValues('cmt_text') || '');
  66. const images = formContext.getValues('cmt_image');
  67. if (!editorHtmlIsSubmittable(sanitizedHtml) && !hasFiles(images)) return;
  68. formContext.setValue('cmt_text', sanitizedHtml, { shouldValidate: true, shouldDirty: true });
  69. props.handleSubmit();
  70. };
  71. const handleEditorEnterSubmit = () => {
  72. handleSubmitWithSanitization();
  73. };
  74. props.data.inputs['cmt_submit'].hide_errors = true
  75. props.data.inputs['cmt_submit'].icon = 'ArrowUp'
  76. props.data.inputs['cmt_submit'].style = 'bordered'
  77. props.data.inputs['cmt_submit'].rounded = 'true'
  78. props.data.inputs['cmt_submit'].icon_only = true
  79. props.data.inputs['cmt_image'].rounded = 'true'
  80. props.data.inputs['cmt_image'].variant = 'default'
  81. // Comment storage decides: images-only modules keep the photo icon, others get a generic attachment.
  82. const attachIcon = getStoragePickerKind(props.data.inputs['cmt_image'].ext_allow) === 'any' ? 'Paperclip' : 'Image'
  83. const hasContent = !!hasText || !!imagesValue || props.data.inputs['cmt_text'].html == 2 || props.data.inputs['cmt_text'].html == 1
  84. // Jobs reviews send `cmt_mood` (star rating). Keep it on the composer
  85. // toolbar so the empty pill stays a single line — same row as the
  86. // placeholder and attach button — then it drops to the bottom with
  87. // attach/send once the composer expands.
  88. const hasMood = !!props.data.inputs['cmt_mood']
  89. const moodInline = hasMood && !hasContent
  90. useEffect(() => {
  91. if (!hasMood) return
  92. register('cmt_mood')
  93. }, [register, hasMood])
  94. return (
  95. // Resting composer is a single-line pill; once there's content (or the
  96. // rich toolbar expands it) the taller box switches to a card radius.
  97. <View className={`w-full flex-auto bg-card/90 backdrop-blur-lg shadow-input-outline dark:shadow-input-outline-deep ${hasContent ? 'rounded-xl' : 'rounded-full'}`}>
  98. {getFormFieldByData(
  99. props.data.inputs['action'],
  100. props.handleSubmit,
  101. 'custom'
  102. )}
  103. {getFormFieldByData(
  104. props.data.inputs['cmt_cf'],
  105. props.handleSubmit,
  106. 'custom'
  107. )}
  108. {getFormFieldByData(
  109. props.data.inputs['cmt_parent_id'],
  110. props.handleSubmit,
  111. 'custom'
  112. )}
  113. {getFormFieldByData(
  114. props.data.inputs['id'],
  115. props.handleSubmit,
  116. 'custom'
  117. )}
  118. {getFormFieldByData(
  119. props.data.inputs['sys'],
  120. props.handleSubmit,
  121. 'custom'
  122. )}
  123. <Row className={`w-full gap-1 flex-auto ${shouldGrowFromBottom ? 'items-end' : 'items-start'} `}>
  124. <View className="flex-auto">
  125. <View className="relative items-stretch">
  126. <Row className={moodInline ? 'items-center' : undefined}>
  127. <View
  128. className={`p-3 min-h-12 flex-auto min-w-0 items-center ${shouldGrowFromBottom ? 'justify-center' : 'justify-start'} ${hasContent ? 'mb-10 w-full' : 'ms-10'}`}
  129. style={{
  130. ...(isWeb && { transition: 'height 0.1s cubic-bezier(0.25, 0.1, 0.25, 1), padding-bottom 0.1s cubic-bezier(0.25, 0.1, 0.25, 1)' }),
  131. }}
  132. >
  133. {getFormFieldByData(
  134. props.data.inputs['cmt_text'],
  135. props.handleSubmit,
  136. 'custom',
  137. {
  138. form_name: props.name,
  139. container_class: 'comments',
  140. classes: 'text-card-foreground tiptap-comments',
  141. autofocus: isAutoFocus,
  142. bg: 'transparent',
  143. placeholder: t('Leave a comment...'),
  144. noPadding: true,
  145. initialHeight: minHeightEditor,
  146. maxHeight: maxHeightEditor,
  147. onFocus: setIsFocus,
  148. // Cmd/Ctrl+Enter submits; plain Enter is a newline
  149. // unless `comments.submit_comment_on_enter` is on.
  150. onEnterSubmit: handleEditorEnterSubmit,
  151. }
  152. )}
  153. </View>
  154. <View className={moodInline ? 'pe-1.5' : 'absolute bottom-1.5 right-1.5'}>
  155. <Row className="items-center justify-center gap-1.5">
  156. {hasMood ? (
  157. <StarsAction
  158. rating={Number(moodValue) || 0}
  159. onChange={(next) => setValue('cmt_mood', next, { shouldDirty: true })}
  160. starSize={isDesktop ? 22 : 20}
  161. hitSize={isDesktop ? 40 : 36}
  162. />
  163. ) : null}
  164. <FileButton style="borderless" field_name="cmt_image" size="sm" icon={attachIcon} source="library" />
  165. {!isWeb ? (
  166. <FileButton field_name="cmt_image" style="borderless" size="sm" icon="Camera" source="camera" />
  167. ) : null}
  168. {hasContent ? (
  169. <View>
  170. {getFormFieldByData(
  171. props.data.inputs['cmt_submit'],
  172. handleSubmitWithSanitization,
  173. 'custom',
  174. {
  175. form_name: props.name,
  176. disabled: !hasSubmittableText && !hasFiles(imagesValue),
  177. className: 'w-full h-full',
  178. notFullWidth: true,
  179. noPadding: true,
  180. icon_only: true,
  181. icon: 'ArrowUp',
  182. title: t('Send'),
  183. size: 'sm',
  184. style: 'glassProminent',
  185. rounded: true,
  186. alt: t('Post'),
  187. tooltip: t('Post'),
  188. }
  189. )}
  190. </View>
  191. ) : null}
  192. </Row>
  193. </View>
  194. </Row>
  195. {currentUser ? (
  196. <View className="absolute flex bottom-1.5 left-1.5 items-center justify-center">
  197. <Profile
  198. {...currentUser}
  199. url_avatar={currentUser.avatar}
  200. displayType="unit_wo_info"
  201. displaySize="md"
  202. />
  203. </View>
  204. ) : null}
  205. </View>
  206. </View>
  207. </Row>
  208. <ScrollView horizontal={true}>
  209. <Row className={imagesValue ? 'flex-wrap mb-1 mx-1' : 'flex-wrap'}>
  210. {getFormFieldByData(
  211. props.data.inputs['cmt_image'],
  212. props.handleSubmit,
  213. 'notitle',
  214. { hide_button: true, list_only: true, asDefaultStorage: true, form_name: props.name}
  215. )}
  216. </Row>
  217. </ScrollView>
  218. </View>
  219. )
  220. }