multi-post-form.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import { Row, View, Pressable } from 'app/design/view'
  2. import { Text } from 'app/design/typography'
  3. import { NeoButton } from 'app/design/controls'
  4. import { useState, useEffect, useMemo } from 'react'
  5. import { useWindowDimensions } from 'react-native'
  6. import { menuItemsByNameNew, cloneObject, getBreakpoint, isWeb } from 'app/lib/util'
  7. import { useCurrentUser } from 'app/context/user'
  8. import Profile from 'app/ui/molecules/profile/profile'
  9. import { useTranslation } from 'react-i18next'
  10. import FormModal, { handleFormModal, getFormModal } from 'app/ui/molecules/dialogs/form-modal';
  11. import { BlockWrapper } from 'app/components/block-wrapper'
  12. import { useBreakpointName } from 'app/context/measure'
  13. import { pickLibraryMedia } from 'app/lib/media/pick-media'
  14. import { queueFieldAssets } from 'app/lib/form/pending-field-assets'
  15. // The first form's media field, as its own "Add Photos or Videos" button in the
  16. // composer toolbar would open it (forms/feed.js getAttachmentMenuItems).
  17. function getMediaField(formPage) {
  18. const findInputs = (node) => {
  19. if (!node || typeof node !== 'object') return null;
  20. if (node.inputs && typeof node.inputs === 'object') return node.inputs;
  21. for (const key of Object.keys(node)) {
  22. const inputs = findInputs(node[key]);
  23. if (inputs) return inputs;
  24. }
  25. return null;
  26. };
  27. const inputs = findInputs(formPage?.elements);
  28. if (inputs?.video?.type === 'files')
  29. return { name: 'video', mediaTypes: ['images', 'videos'], icon: 'ImagePlay', title: 'Add Photos or Videos' };
  30. if (inputs?.photo?.type === 'files')
  31. return { name: 'photo', mediaTypes: ['images'], icon: 'Image', title: 'Add Photos' };
  32. return null;
  33. }
  34. // The "Create new …" trigger is not a form input (nothing is typed here), so it
  35. // is a plain Pressable rather than a NeoButton or a TextInput look-alike: text
  36. // only on phones, fading while pressed; a capsule on tablet and desktop.
  37. const TRIGGER_CLASS = 'flex-1 min-w-0 h-11 justify-center rounded-full active:opacity-50 '
  38. + 'sm:px-4 sm:bg-button sm:hover:bg-button-hover web:transition-[background-color,opacity] web:duration-150';
  39. const TRIGGER_TEXT_CLASS = 'text-base font-medium tracking-tight text-muted-foreground';
  40. /**
  41. * `trigger_label` comes from the block's App config (Studio > Pages > block > "Config (for App
  42. * in JSON format)"), e.g. `{"trigger_label": "Ask an expert"}`, and replaces "Create new <type>"
  43. * on the trigger, which opens the first type of the `sys_create_post` menu.
  44. */
  45. export default function MultiPostForm({ data, blockWrapperProps, trigger_label: triggerLabelSetting }) {
  46. const { currentUser } = useCurrentUser();
  47. const { t } = useTranslation()
  48. const { width: windowWidth } = useWindowDimensions();
  49. // Other content types: tablet and desktop only. Web hides them with
  50. // classes (SSR-safe); native does not render them on phones, since a
  51. // `display: none` subtree of native (Expo UI) buttons trips Fabric's
  52. // layout ownership assert in debug builds (facebook/react-native#52349).
  53. const showOtherTypes = isWeb || !!getBreakpoint(windowWidth);
  54. // Phones get a borderless media button (hydration-safe: bordered until hydrated on web).
  55. const mediaButtonStyle = useBreakpointName() === '' ? 'borderless' : 'bordered';
  56. const [pageData, setPageData] = useState(false);
  57. const [pageDataDef, setPageDataDef] = useState(false);
  58. const menu_add_items = menuItemsByNameNew('', data.menu, currentUser).filter(item => item.name != 'more-auto');
  59. const firstForm = menu_add_items.shift();
  60. const profileData = useMemo(() => ({
  61. ...currentUser,
  62. url_avatar: currentUser.avatar,
  63. url: null,
  64. }));
  65. useEffect(() => {
  66. const fetchData = async () => {
  67. const sResponse = await getFormModal(firstForm, data.params);
  68. setPageDataDef(sResponse.data);
  69. };
  70. fetchData();
  71. }, []);
  72. // Show tooltip after configured delay on first feed visit
  73. // Keep showing until user opens the post form
  74. const mediaField = useMemo(() => getMediaField(pageDataDef), [pageDataDef]);
  75. const getFirstForm = async () => {
  76. if (pageDataDef) {
  77. setPageData({ ...cloneObject(pageDataDef), ts: Date.now() });
  78. }
  79. else {
  80. const a = await getFormModal(firstForm, data.params);
  81. setPageData({ ...a.data, ts: Date.now() });
  82. }
  83. }
  84. if (menu_add_items.length == 0 && !firstForm)
  85. return;
  86. const triggerLabel = triggerLabelSetting
  87. ? t(triggerLabelSetting)
  88. : t('Create new ') + firstForm.title.toLowerCase();
  89. const handleTriggerPress = () => {
  90. // User clicked to create post - permanently dismiss tooltip
  91. void getFirstForm();
  92. };
  93. // Fast track to "Add Photos or Videos": pick first (on web the file dialog
  94. // needs the click's user gesture), then open the form with the files queued
  95. // for its media field, which uploads them on mount. Unsupported picks still
  96. // open the form, where the field says so, like a pick inside the form.
  97. const handleMediaPress = async () => {
  98. if (!mediaField) return;
  99. const picked = await pickLibraryMedia(mediaField.mediaTypes);
  100. if (!picked) return;
  101. queueFieldAssets(mediaField.name, picked.assets, picked.unsupported);
  102. await getFirstForm();
  103. };
  104. return (
  105. <BlockWrapper {...blockWrapperProps}>
  106. {/* One row straight in the block: its chrome (background, padding, title) is the
  107. block's design box, so no card of its own. */}
  108. <Row className="items-center gap-3 min-w-0">
  109. <View className="flex-none">
  110. <Profile {...profileData} displaySize="base" displayType="unit_wo_info" />
  111. </View>
  112. <Pressable
  113. className={TRIGGER_CLASS}
  114. accessibilityRole="button"
  115. accessibilityLabel={triggerLabel}
  116. onPress={handleTriggerPress}
  117. >
  118. <Text className={TRIGGER_TEXT_CLASS} numberOfLines={1}>{triggerLabel}</Text>
  119. </Pressable>
  120. {mediaField && (
  121. <NeoButton
  122. style={mediaButtonStyle}
  123. borderShape="circle"
  124. controlSize="regular"
  125. image={mediaField.icon}
  126. tooltip={t(mediaField.title)}
  127. accessibilityLabel={t(mediaField.title)}
  128. onPress={handleMediaPress}
  129. />
  130. )}
  131. <FormModal key={pageData?.ts} pageData={pageData} setPageData={setPageData} />
  132. {menu_add_items.length > 0 && showOtherTypes && <Row className="hidden sm:flex gap-3 flex-none">
  133. {menu_add_items.map((item, index) => (
  134. <NeoButton
  135. key={item.name}
  136. style="bordered"
  137. borderShape="circle"
  138. controlSize="regular"
  139. image={item.icon}
  140. tooltip={item.title}
  141. accessibilityLabel={item.title}
  142. onPress={() => handleFormModal(item, null, setPageData, data.params)}
  143. />
  144. ))}
  145. </Row>}
  146. </Row>
  147. </BlockWrapper>
  148. )
  149. }