comments.js 11 KB

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