feed.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. import { View, Row, Pressable } from 'app/design/view'
  2. import { NeoButton } from 'app/design/controls'
  3. import { useState, useRef, useMemo, useEffect } from 'react'
  4. import { getFormFieldByData } from 'app/lib/form/form-helpers'
  5. import KbAvoidingView, { useStickyComposerListInset } from 'app/ui/atoms/kb-avoiding-view'
  6. import { Platform, View as RNView } from 'react-native'
  7. import { useStableSafeAreaInsets } from 'app/lib/hooks/router'
  8. import { useCurrentUser } from 'app/context/user'
  9. import Profile from 'app/ui/molecules/profile/profile'
  10. import { appSetting, stripTags } from 'app/lib/util'
  11. import { Keyboard } from 'react-native'
  12. import { useFormContext } from 'react-hook-form'
  13. import { PollButton, LabelButton, FileButton } from 'app/lib/form/form-helpers'
  14. import { useIsDesktop, useWindowHeight } from 'app/context/measure';
  15. import emitter, { EVENTS } from 'app/context/emitter';
  16. import { Icon } from 'app/ui/atoms/icon'
  17. import { Text } from 'app/design/typography';
  18. import { useTranslation } from 'react-i18next';
  19. const dropdownTheme = appSetting('theme', 'dropdown');
  20. const menuSettings = appSetting('theme', 'dropdown_menu');
  21. const isWeb = Platform.OS === 'web';
  22. const nativeTruncateProps = isWeb
  23. ? {}
  24. : { numberOfLines: 1, ellipsizeMode: 'tail', textBreakStrategy: 'simple' };
  25. const ATTACHMENT_BUTTON = { style: 'bordered', controlSize: 'small', borderShape: 'capsule', tooltipSide: 'top' };
  26. function getAttachmentMenuItems(inputs) {
  27. const items = [];
  28. if (inputs?.photo) {
  29. items.push({
  30. id: 'photo',
  31. title: 'Add Photos',
  32. icon: 'Image',
  33. onAction: () => emitter.emit(EVENTS.fieldFiles('photo'), { action: 'add', source: 'library', mediaTypes: ['images'] }),
  34. });
  35. }
  36. if (inputs?.video) {
  37. items.push({
  38. id: 'video',
  39. title: 'Add Photos or Videos',
  40. icon: 'ImagePlay',
  41. onAction: () => emitter.emit(EVENTS.fieldFiles('video'), { action: 'add', source: 'library', mediaTypes: ['images', 'videos'] }),
  42. });
  43. }
  44. if (inputs?.file) {
  45. items.push({
  46. id: 'file',
  47. title: 'Add Files',
  48. icon: 'Paperclip',
  49. onAction: () => emitter.emit(EVENTS.fieldFiles('file'), { action: 'add' }),
  50. });
  51. }
  52. if (inputs?.labels) {
  53. items.push({
  54. id: 'labels',
  55. title: 'Add Labels',
  56. icon: 'Hash',
  57. onAction: () => emitter.emit(EVENTS.fieldLabels('labels'), { action: 'add' }),
  58. });
  59. }
  60. if (inputs?.polls) {
  61. items.push({
  62. id: 'polls',
  63. title: 'Add Polls',
  64. icon: 'ChartBarBig',
  65. onAction: () => emitter.emit(EVENTS.fieldPolls('polls'), { action: 'add' }),
  66. });
  67. }
  68. return items;
  69. }
  70. function AttachmentMenuList({ items, onSelect }) {
  71. const { t } = useTranslation();
  72. return (
  73. <View className={`mb-2 ${dropdownTheme.cnt}`}>
  74. {items.map((item) => (
  75. <Pressable
  76. key={item.id}
  77. onPress={() => onSelect(item)}
  78. className={menuSettings.item_ver}
  79. >
  80. <Row className="items-center">
  81. {item.icon ? (
  82. <View className={menuSettings.item_icon}>
  83. <Icon icon={item.icon} size={menuSettings.icon_size || 20} />
  84. </View>
  85. ) : null}
  86. <Text className={menuSettings.item_text}>{t(item.title)}</Text>
  87. </Row>
  88. </Pressable>
  89. ))}
  90. </View>
  91. );
  92. }
  93. function ComposerDock({ children }) {
  94. const { keyboardLift } = useStickyComposerListInset(0);
  95. return (
  96. <RNView
  97. pointerEvents="box-none"
  98. style={{
  99. position: 'absolute',
  100. left: 0,
  101. right: 0,
  102. bottom: keyboardLift,
  103. zIndex: 20,
  104. }}
  105. >
  106. {children}
  107. </RNView>
  108. );
  109. }
  110. function ComposerToolbar({ data, handleSubmit, isWeb, children }) {
  111. const { t } = useTranslation();
  112. const isDesktop = useIsDesktop();
  113. const [menuOpen, setMenuOpen] = useState(false);
  114. const menuItems = useMemo(
  115. () => getAttachmentMenuItems(data.inputs),
  116. [data.inputs]
  117. );
  118. const obfuscate = data.inputs['obfuscate_faces'] ? (
  119. <View className="flex-none">
  120. {getFormFieldByData(data.inputs['obfuscate_faces'], handleSubmit, 'default')}
  121. </View>
  122. ) : null;
  123. const leading = isDesktop ? (
  124. <RNView style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
  125. {obfuscate}
  126. {data.inputs['photo'] ? (
  127. <FileButton field_name="photo" icon="Image" tooltip={t('Add Photos')} {...ATTACHMENT_BUTTON} />
  128. ) : null}
  129. {data.inputs['video'] ? (
  130. <FileButton field_name="video" icon="ImagePlay" tooltip={t('Add Photos or Videos')} {...ATTACHMENT_BUTTON} />
  131. ) : null}
  132. {data.inputs['video'] && !isWeb ? (
  133. <FileButton field_name="video" icon="ImagePlay" {...ATTACHMENT_BUTTON} />
  134. ) : null}
  135. {data.inputs['file'] ? (
  136. <FileButton field_name="file" icon="Paperclip" {...ATTACHMENT_BUTTON} />
  137. ) : null}
  138. {data.inputs['labels'] ? (
  139. <LabelButton field_name="labels" {...ATTACHMENT_BUTTON} />
  140. ) : null}
  141. {data.inputs['polls'] ? (
  142. <PollButton field_name="polls" {...ATTACHMENT_BUTTON} />
  143. ) : null}
  144. </RNView>
  145. ) : (
  146. <RNView style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
  147. {obfuscate}
  148. {menuItems.length > 0 ? (
  149. <NeoButton
  150. {...ATTACHMENT_BUTTON}
  151. image="Plus"
  152. style={menuOpen ? 'borderedProminent' : ATTACHMENT_BUTTON.style}
  153. accessibilityLabel={t('Add attachment')}
  154. onPress={() => setMenuOpen((was) => !was)}
  155. />
  156. ) : null}
  157. </RNView>
  158. );
  159. return (
  160. <RNView style={{ alignSelf: 'stretch' }}>
  161. {!isDesktop && menuOpen && menuItems.length > 0 ? (
  162. <AttachmentMenuList
  163. items={menuItems}
  164. onSelect={(item) => {
  165. setMenuOpen(false);
  166. item.onAction?.();
  167. }}
  168. />
  169. ) : null}
  170. <RNView
  171. style={{
  172. width: '100%',
  173. flexDirection: 'row',
  174. alignItems: 'center',
  175. justifyContent: 'space-between',
  176. }}
  177. >
  178. {leading}
  179. {children}
  180. </RNView>
  181. </RNView>
  182. );
  183. }
  184. function AuthorAudienceButton({ profileData, displayName, destination, destinationIcon, onPress }) {
  185. return (
  186. <NeoButton
  187. style="borderless"
  188. controlSize="regular"
  189. borderShape="capsule"
  190. align="start"
  191. contentInsets="mediaLeading"
  192. onPress={onPress}
  193. accessibilityLabel={destination ? `${displayName}, ${destination}` : displayName}
  194. classNames={{
  195. root: 'w-max max-w-full min-w-0',
  196. container: 'w-max max-w-full min-w-0 gap-x-2 pe-4 -ms-1 -mt-1',
  197. }}
  198. >
  199. <View className="pointer-events-none flex-none">
  200. <Profile {...profileData} displaySize="md" displayType="unit_wo_info" showLinks={false} />
  201. </View>
  202. <View className="min-w-0 max-w-full overflow-hidden">
  203. <Text
  204. className="min-w-0 max-w-full truncate text-sm font-semibold tracking-tight leading-5 text-button-foreground"
  205. {...nativeTruncateProps}
  206. >
  207. {displayName}
  208. </Text>
  209. {destination ? (
  210. <Row className="min-w-0 max-w-full items-center gap-1">
  211. {typeof destinationIcon === 'string' ? (
  212. <Icon icon={destinationIcon} size={12} className="flex-none text-muted-foreground" />
  213. ) : destinationIcon ? (
  214. <View className="pointer-events-none flex-none">{destinationIcon}</View>
  215. ) : null}
  216. <Text
  217. className="min-w-0 shrink truncate text-xs leading-4 text-secondary-foreground"
  218. {...nativeTruncateProps}
  219. >
  220. {destination}
  221. </Text>
  222. {onPress ? (
  223. <Icon icon="ChevronsUpDown" size={12} className="flex-none text-muted-foreground" />
  224. ) : null}
  225. </Row>
  226. ) : null}
  227. </View>
  228. </NeoButton>
  229. );
  230. }
  231. export default function FormFeed({ data, handleSubmit, exProps, name, response }) {
  232. const { t } = useTranslation();
  233. const formContext = useFormContext()
  234. const isFormOnly = exProps?.formOnly === true || name === 'feed_edit';
  235. const [showImage, setShowImage] = useState(isFormOnly ? true : false);
  236. const [responseId, setResponseId] = useState(0)
  237. const isIos = Platform.OS === 'ios'
  238. const scrollViewRef = useRef(null)
  239. const screenHeight = useWindowHeight();
  240. const insets = useStableSafeAreaInsets();
  241. const { currentUser } = useCurrentUser();
  242. // iOS: distance from screen top to KbAvoidingView = safe area + modal padding (16) + form header (~48) + gap (12).
  243. // Android resizes the window itself, so keep the previous working offset.
  244. const modalOffset = isIos ? insets.top + 76 : 60;
  245. const rawEditorText = formContext.watch('text');
  246. const object_privacy_view = formContext.watch('object_privacy_view');
  247. const hasText = useMemo(() => stripTags(rawEditorText || '').trim().length > 0, [rawEditorText]);
  248. function onClose() {
  249. setShowImage(false)
  250. exProps?.onClose?.();
  251. }
  252. useEffect(() => {
  253. // The success cue plays in `Form` when UNA takes the post.
  254. if (response?.id && response?.id != responseId) {
  255. emitter.emit(EVENTS.feed, { action: 'new_content', data: response });
  256. onClose();
  257. setResponseId(response?.id)
  258. }
  259. }, [response?.id])
  260. useEffect(() => {
  261. if (showImage) {
  262. scrollViewRef.current?.scrollToEnd({ animated: true })
  263. }
  264. }, [showImage])
  265. useEffect(() => {
  266. const keyboardDidShowListener = Keyboard.addListener(
  267. 'keyboardDidShow',
  268. () => {
  269. scrollViewRef.current?.scrollToEnd({ animated: true })
  270. }
  271. )
  272. return () => {
  273. keyboardDidShowListener.remove()
  274. }
  275. }, [])
  276. const isHiddenVisibility = data?.inputs?.['object_privacy_view']?.origtype == 'hidden' || !data?.inputs?.['object_privacy_view']
  277. const displayProfile = exProps?.item?.author_data || {
  278. ...currentUser,
  279. url_avatar: currentUser.avatar,
  280. url: null,
  281. };
  282. const profileData = {
  283. ...displayProfile,
  284. url_avatar: displayProfile.url_avatar || displayProfile.avatar,
  285. url: null,
  286. };
  287. const isPollsPresent = !!data.inputs['polls'];
  288. const isLabelsPresent = !!data.inputs['labels'];
  289. const isButtonDisabled = !hasText || ((!isHiddenVisibility && object_privacy_view == '')) ? true : false;
  290. // Same guards as the submit button: nothing to post or a post already in flight.
  291. const handleEditorEnterSubmit = () => {
  292. if (isButtonDisabled || formContext.formState.isSubmitting) return;
  293. handleSubmit();
  294. };
  295. if (isFormOnly) {
  296. return (
  297. <RNView style={{ position: 'relative', width: '100%', flex: 1, gap: 12 }}>
  298. <View className="items-start justify-start ">
  299. <Row className="w-full items-center justify-between gap-x-2">
  300. <View className="min-w-0 max-w-full shrink">
  301. {data?.inputs?.['object_privacy_view'] ? getFormFieldByData(
  302. {
  303. ...data.inputs['object_privacy_view'],
  304. },
  305. handleSubmit,
  306. 'nofield',
  307. {
  308. onShowModal: setShowImage,
  309. showModal: showImage,
  310. maxLength: 0,
  311. noContainer: true,
  312. renderTrigger: ({ onPress, displayText, icon }) => (
  313. <AuthorAudienceButton
  314. profileData={profileData}
  315. displayName={displayProfile.display_name}
  316. destination={displayText}
  317. destinationIcon={icon}
  318. onPress={onPress}
  319. />
  320. ),
  321. }
  322. ) : (
  323. <AuthorAudienceButton
  324. profileData={profileData}
  325. displayName={displayProfile.display_name}
  326. />
  327. )}
  328. </View>
  329. <NeoButton
  330. style="bordered"
  331. controlSize="small"
  332. borderShape="circle"
  333. image="X"
  334. accessibilityLabel={t('Close')}
  335. onPress={onClose}
  336. classNames={{ root: 'flex-none' }}
  337. />
  338. </Row>
  339. </View>
  340. <KbAvoidingView className="flex-auto" modalOffset={modalOffset}>
  341. {getFormFieldByData(data.inputs['action'], handleSubmit, 'default')}
  342. {getFormFieldByData(data.inputs['object_cf'], handleSubmit, 'default')}
  343. {getFormFieldByData(data.inputs['owner_id'], handleSubmit, 'default')}
  344. {getFormFieldByData(data.inputs['type'], handleSubmit, 'default')}
  345. <View className="flex-auto justify-start pb-14">
  346. <View className="w-full flex-auto justify-start ">
  347. <View
  348. className="flex-auto "
  349. style={{
  350. ...(isWeb && { transition: 'height 0.1s cubic-bezier(0.25, 0.1, 0.25, 1)' })
  351. }}
  352. >
  353. {getFormFieldByData(
  354. data.inputs['text'],
  355. handleSubmit,
  356. 'custom',
  357. {
  358. form_name: name,
  359. styles: { verticalAlign: 'top' },
  360. focus: true,
  361. noPadding: true,
  362. bg: 'transparent',
  363. placeholder: t('Write here...'),
  364. linkify: true,
  365. autofocus: true,
  366. classes: 'flex-auto',
  367. initialHeight: 160,
  368. maxHeight: screenHeight / 2 - 80 ,
  369. // Cmd/Ctrl+Enter posts; plain Enter is a newline.
  370. onEnterSubmit: handleEditorEnterSubmit,
  371. }
  372. )}
  373. </View>
  374. <View >
  375. <Row className='flex-wrap w-full'>
  376. {
  377. getFormFieldByData(
  378. data.inputs['video'],
  379. handleSubmit,
  380. 'notitle',
  381. {
  382. hide_button: true,
  383. list_only: true,
  384. form_name: name,
  385. asDefaultStorage: !data.inputs['photo'],
  386. }
  387. )
  388. }
  389. {
  390. getFormFieldByData(
  391. data.inputs['photo'],
  392. handleSubmit,
  393. 'notitle',
  394. { hide_button: true, list_only: true, asDefaultStorage: true, form_name: name }
  395. )
  396. }
  397. {
  398. getFormFieldByData(
  399. data.inputs['file'],
  400. handleSubmit,
  401. 'notitle',
  402. { hide_button: true, list_only: true, form_name: name }
  403. )
  404. }
  405. </Row>
  406. {isLabelsPresent && (
  407. <View className="flex-auto">
  408. {
  409. getFormFieldByData(
  410. data.inputs['labels'],
  411. handleSubmit,
  412. 'notitle',
  413. { hide_button: true, noPadding: true }
  414. )
  415. }
  416. </View>
  417. )}
  418. {isPollsPresent && (
  419. <View className="">
  420. {getFormFieldByData(
  421. data.inputs['polls'],
  422. handleSubmit,
  423. 'custom',
  424. { hide_button: true }
  425. )}
  426. </View>
  427. )}
  428. </View>
  429. </View>
  430. </View>
  431. </KbAvoidingView>
  432. <ComposerDock>
  433. <ComposerToolbar
  434. data={data}
  435. handleSubmit={handleSubmit}
  436. isWeb={isWeb}
  437. >
  438. {getFormFieldByData(
  439. data.inputs['tlb_do_submit'],
  440. handleSubmit,
  441. 'default',
  442. {
  443. form_name: name,
  444. form_layout: 'hor',
  445. disabled: isButtonDisabled,
  446. noPadding: true,
  447. size: 'sm',
  448. style: 'borderedProminent',
  449. rounded: true,
  450. notFullWidth: true,
  451. icon: "SendHorizontal",
  452. bare: true,
  453. }
  454. )}
  455. </ComposerToolbar>
  456. </ComposerDock>
  457. </RNView>
  458. )
  459. }
  460. }