comments-browse.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886
  1. import { appSetting, stripTags } from 'app/lib/util';
  2. import { Pressable, View, Row } from 'app/design/view';
  3. import UniList from 'app/ui/atoms/unilist'
  4. import { EdgeBlurView, edgeBlurConfig } from 'app/ui/atoms/edge-blur'
  5. import { Text } from 'app/design/typography'
  6. import { useState, useReducer, useRef, useEffect, useCallback } from 'react';
  7. import { components } from 'app/components/registry';;
  8. import { NeoButton } from 'app/design/controls'
  9. import useFetchForm from 'app/lib/hooks/use-fetch-form'
  10. import { formResponseHasFieldErrors } from 'app/lib/form/form-helpers'
  11. import { playCue } from 'app/lib/sound'
  12. import { fetcher } from 'app/lib/fetcher';
  13. import Loading from 'app/ui/atoms/loading'
  14. import Form from 'app/components/elements/form';
  15. import DropdownMenu from 'app/ui/atoms/dropdown-menu';
  16. import { subscribe } from 'app/ui/atoms/socket';
  17. import { useCurrentUser } from 'app/context/user';
  18. import { useTranslation } from 'react-i18next';
  19. import { useInfiniteQuery } from '@tanstack/react-query'
  20. import {
  21. refetchUniListReducer,
  22. isSameItemsForUniList
  23. } from 'app/lib/browse-query'
  24. import Snackbar from 'app/ui/atoms/snackbar'
  25. import emitter, { EVENTS } from 'app/context/emitter'
  26. import { appStatic } from 'app/lib/app-static';
  27. import { ProfileDisplayName } from 'app/customization/functions';
  28. import { Keyboard, Platform, TouchableWithoutFeedback } from 'react-native';
  29. /**
  30. * Bottom-anchored fade for a floating comment composer, keyed by the surface it
  31. * covers. Solid at the bottom so the input stays legible, transparent by the top
  32. * so comments scrolling behind remain partly visible.
  33. *
  34. * The run-up comes from `FADE_RUN_UP`, applied as an inline `paddingTop`: the panel
  35. * is bottom-anchored, so top padding grows the gradient box upward past the input
  36. * without moving the input, letting content fade well before it reaches the field.
  37. * It has to be inline rather than a `pt-*` utility — the wrapper already sets
  38. * `web:sm:p-2.5`, and that compound variant outranks any plain padding class.
  39. * Inline styles apply identically on native.
  40. *
  41. * The colour classes are whole static literals on purpose: Tailwind (web) and
  42. * Uniwind (native) both discover utilities by scanning source text, so an
  43. * interpolated name like `from-${surface}` would never be generated. Uniwind
  44. * parses `linear-gradient` natively, so these work on both platforms.
  45. */
  46. const FADE_RUN_UP = 48;
  47. const FADE_SURFACE_CLASS = {
  48. card: 'bg-linear-to-t from-card from-40% to-transparent',
  49. background: 'bg-linear-to-t from-background from-40% to-transparent',
  50. };
  51. /** EnrichedTextInput keeps first responder unless explicitly blurred (unlike plain TextInput). */
  52. function dismissCommentEditorKeyboard() {
  53. if (Platform.OS === 'web') return;
  54. emitter.emit(EVENTS.editor, { action: 'blur' });
  55. Keyboard.dismiss();
  56. }
  57. export function CommentsBrowse({
  58. browse,
  59. requestUrl,
  60. module,
  61. addItems,
  62. height = 0,
  63. classesBrowse = '',
  64. commentsTitle = "Comments",
  65. replyId,
  66. hideActions = false,
  67. scrollToIndex = false,
  68. isModal = false,
  69. marginBottom = 0,
  70. useCustomScrollHandler,
  71. embedded = false,
  72. }) {
  73. const UnitComments = components['unit']['comments'];
  74. const { t } = useTranslation();
  75. const flashListRef = useRef(null);
  76. /** Ignore item taps that start as a scroll (chat UX: keep keyboard while scrolling). */
  77. const listDraggingRef = useRef(false);
  78. const listDragTimeoutRef = useRef(null);
  79. const markListDragging = useCallback(() => {
  80. listDraggingRef.current = true;
  81. if (listDragTimeoutRef.current) clearTimeout(listDragTimeoutRef.current);
  82. }, []);
  83. const clearListDragging = useCallback(() => {
  84. if (listDragTimeoutRef.current) clearTimeout(listDragTimeoutRef.current);
  85. // Brief grace so a cancelled press after drag does not blur the composer.
  86. listDragTimeoutRef.current = setTimeout(() => {
  87. listDraggingRef.current = false;
  88. }, 80);
  89. }, []);
  90. const dismissComposerIfIdle = useCallback(() => {
  91. if (listDraggingRef.current) return;
  92. dismissCommentEditorKeyboard();
  93. }, []);
  94. const { currentUser } = useCurrentUser();
  95. const viewMode = browse?.data?.view;
  96. const [refetchState, dispatch] = useReducer(refetchUniListReducer, {
  97. visibleItems: [],
  98. hasNewData: false
  99. })
  100. const refetchRef = useRef({
  101. skipToast: false,
  102. isFirstLoad: true,
  103. prevItems: []
  104. })
  105. // State, not the ref: the empty placeholder must re-render once the first
  106. // page lands, even when the list stays empty.
  107. const [isLoaded, setIsLoaded] = useState(false)
  108. const baseParams = {
  109. //static
  110. module: module,
  111. object_id: browse?.data?.object_id,
  112. per_view: browse?.data?.per_view,
  113. view: browse?.data?.view,
  114. max_level: browse?.data?.max_level,
  115. //dynamic
  116. order_way: browse?.data?.order,
  117. start_from: browse?.data?.start,
  118. total_count: browse?.data?.total_count
  119. }
  120. const [browseParams, setBrowseParams] = useState(baseParams);
  121. const [scrollIndex, setScrollIndex] = useState(scrollToIndex);
  122. function prepareUrl(params) {
  123. let def = {
  124. module: browseParams.module,
  125. object_id: browseParams.object_id,
  126. start_from: params?.start_from ?? browse?.data?.start ?? 0,
  127. order_way: browseParams.order_way,
  128. }
  129. return requestUrl + JSON.stringify({ ...def, ...params })
  130. }
  131. async function fetchComments({ pageParam }) {
  132. const {
  133. start_from = 0,
  134. } = pageParam || {}
  135. const sRequest = prepareUrl({
  136. is_form: false,
  137. start_from,
  138. })
  139. const sResponse = await fetcher(sRequest)
  140. const data = sResponse?.data?.browse?.data
  141. return {
  142. raw: sResponse,
  143. tree: data?.data,
  144. start: data?.start,
  145. count: data?.count,
  146. per_view: data?.per_view,
  147. total_count: data?.total_count,
  148. }
  149. }
  150. //Scroll to item by initial params
  151. useEffect(() => {
  152. setScrollIndex(scrollToIndex)
  153. }, [scrollToIndex]);
  154. useEffect(() => {
  155. if (scrollIndex !== false && scrollIndex !== true) {
  156. scrollToItemByCommentId(scrollIndex);
  157. }
  158. //to the end
  159. if (scrollIndex === true) {
  160. scrollToItemByIndex(dataOut.length - 1);
  161. }
  162. }, [scrollIndex]);
  163. useEffect(() => {
  164. const sub1 = subscribe('cmts_' + browseParams.module + '_' + browseParams.object_id, 'comment_added', refetch);
  165. const sub2 = subscribe('cmts_' + browseParams.module + '_' + browseParams.object_id, 'comment_edited', refetch);
  166. const sub3 = subscribe('cmts_' + browseParams.module + '_' + browseParams.object_id, 'comment_deleted', refetch);
  167. const subscription = emitter.addListener(EVENTS.page, (data) => {
  168. if (data.action == 'reload') {
  169. refetchRef.current.skipToast = true
  170. refetch();
  171. }
  172. })
  173. const subscription2 = emitter.addListener(EVENTS.commentThread(baseParams.module, baseParams.object_id), (data) => {
  174. if (data.action == 'remove_content') {
  175. dispatch({ type: 'REMOVE_ITEM', id: data.data.id })
  176. if (refetchRef.current?.prevItems) {
  177. refetchRef.current.prevItems = refetchRef.current.prevItems.filter(item => item.id != data.id)
  178. }
  179. refetchRef.current.skipToast = true;
  180. setBrowseParams(prev => ({
  181. ...prev,
  182. total_count: prev.total_count - 1,
  183. }));
  184. }
  185. if (data.action == 'new_content') {
  186. //MANY BE NEED TO IMPROVE
  187. const newItem = data.data;
  188. // Use ref — it holds the current list inside the useEffect closure
  189. const items = refetchRef.current.prevItems || [];
  190. // Parent ID (assumed to be in data.cmt_parent_id)
  191. const parentId = newItem.data?.cmt_parent_id || newItem.cmt_parent_id || 0;
  192. let insertIndex = 0;
  193. if (parentId == 0) {
  194. // Root comment
  195. // To prepend (newest on top):
  196. insertIndex = browseParams.order_way == 'asc' ? items.length : 0;
  197. // To append (oldest on top): insertIndex = items.length;
  198. newItem.level = 0;
  199. } else {
  200. const parentIndex = items.findIndex(i => (i.data?.cmt_id == parentId) || (i.id == parentId));
  201. if (parentIndex !== -1) {
  202. const parent = items[parentIndex];
  203. newItem.level = (parent.level || 0) + 1;
  204. let i = parentIndex + 1;
  205. while (i < items.length && items[i].level > parent.level) {
  206. i++;
  207. }
  208. insertIndex = i;
  209. } else {
  210. insertIndex = 0;
  211. newItem.level = 0;
  212. }
  213. setBrowseParams(prev => ({
  214. ...prev,
  215. total_count: prev.total_count + 1,
  216. }));
  217. }
  218. const newItems = [...items];
  219. newItems.splice(insertIndex, 0, newItem);
  220. dispatch({ type: 'SET_ITEMS', items: newItems })
  221. if (refetchRef.current) {
  222. refetchRef.current.prevItems = newItems
  223. refetchRef.current.skipToast = true
  224. }
  225. setScrollIndex(newItem.id)
  226. }
  227. })
  228. return () => {
  229. sub1();
  230. sub2();
  231. sub3();
  232. subscription.remove();
  233. subscription2.remove()
  234. }
  235. }, [])
  236. const qKey = ['comments', module, currentUser?.id, browseParams.object_id, browseParams.order_way]
  237. const {
  238. data: pagesData,
  239. fetchNextPage,
  240. hasNextPage,
  241. isFetchingNextPage,
  242. refetch,
  243. } = useInfiniteQuery({
  244. queryKey: qKey,
  245. queryFn: fetchComments,
  246. // Pages without a comments block (e.g. an edit form opened in the
  247. // post modal) have no thread to load.
  248. enabled: !!requestUrl,
  249. initialPageParam: {
  250. start_from: 0,
  251. },
  252. getNextPageParam: (lastPage) => {
  253. if (!lastPage || lastPage.start === 0 || lastPage.count === 0) return undefined
  254. return {
  255. start_from: lastPage.start
  256. }
  257. },
  258. initialData: () => {
  259. if (!browse?.data) return undefined
  260. const d = browse.data
  261. return {
  262. pages: [
  263. {
  264. raw: { data: { browse: { data: d } } },
  265. tree: d.data,
  266. start: d.start,
  267. count: d.count,
  268. per_view: d.per_view,
  269. total_count: d.total_count,
  270. },
  271. ],
  272. pageParams: [
  273. {
  274. start_from: 0,
  275. },
  276. ],
  277. }
  278. },
  279. staleTime: appSetting('browse', 'stale_time'),
  280. refetchOnWindowFocus: true,
  281. refetchOnReconnect: true,
  282. })
  283. useEffect(() => {
  284. if (!pagesData) return
  285. const items = flattenPagesForComments(pagesData, viewMode)
  286. if (refetchRef.current.isFirstLoad) {
  287. dispatch({ type: 'SET_ITEMS', items })
  288. refetchRef.current.prevItems = items
  289. refetchRef.current.isFirstLoad = false
  290. setIsLoaded(true)
  291. return
  292. }
  293. if (isSameItemsForUniList(refetchRef.current.prevItems, items)) {
  294. return
  295. }
  296. if (refetchRef.current.skipToast) {
  297. dispatch({ type: 'SET_ITEMS', items })
  298. refetchRef.current.skipToast = false
  299. } else {
  300. dispatch({ type: 'SHOW_NEW_DATA' })
  301. }
  302. refetchRef.current.prevItems = items
  303. }, [pagesData, viewMode])
  304. useEffect(() => {
  305. if (refetchRef.current.isFirstLoad && browseParams.order_way === baseParams.order_way) {
  306. return
  307. }
  308. // Reload data when order_way changes
  309. if (!refetchRef.current.isFirstLoad || browseParams.order_way !== baseParams.order_way) {
  310. refetchRef.current.skipToast = true
  311. refetchRef.current.isFirstLoad = true
  312. refetch()
  313. }
  314. }, [browseParams.order_way])
  315. const handleEndReached = useCallback(
  316. (lastItemIndex) => {
  317. if (!browseParams.object_id) return
  318. if (!hasNextPage) return
  319. if (isFetchingNextPage) return
  320. if (lastItemIndex == false) return
  321. refetchRef.current.skipToast = true
  322. fetchNextPage()
  323. },
  324. [hasNextPage, browseParams.object_id, isFetchingNextPage]
  325. )
  326. const handleOrder = async (orderWay) => {
  327. setBrowseParams(prev => ({
  328. ...prev,
  329. order_way: orderWay,
  330. }));
  331. }
  332. const title = t(module + '_title') === module + '_title' ? t(commentsTitle) : t(module + '_title');
  333. const header = browseParams.total_count > 0 && false ? (
  334. <Row className={'flex-row ' + (classesBrowse ? classesBrowse : `px-3 pt-3 sm:px-4 justify-between items-center mt-3 border-t border-muted/60`)}>
  335. <Text className='flex-auto text-base font-semibold text-secondary-foreground'>{title} ({browseParams.total_count})</Text>
  336. {!appSetting('comments', 'hide_sort') && <View className="ml-4">
  337. <Pressable className="flex-auto" onPress={(event) => { event.preventDefault() }}>
  338. <DropdownMenu items={[
  339. { id: 'newest', name: 'asc', title: t('Oldest first'), selected: browseParams.order_way == 'asc' },
  340. { id: 'oldest', name: 'desc', title: t('Newest first'), selected: browseParams.order_way == 'desc' }
  341. ]} onSelect={(oItem) => { handleOrder(oItem.name) }}
  342. buttonProps={{
  343. controlSize: 'mini',
  344. image: browseParams.order_way == 'asc' ? 'ArrowDownAZ' : 'ArrowDownZA',
  345. accessibilityLabel: t('Sort'),
  346. }}
  347. />
  348. </Pressable>
  349. </View>}
  350. </Row>) : <></>;
  351. const extra = (addItems || []).filter(i => i.id !== 'block_comments-empty');
  352. const dataOut = !refetchState.visibleItems.some(i => i.id === 'block_header') && extra.length
  353. ? [...extra, { id: 'block_header', data: header }, ...refetchState.visibleItems]
  354. : refetchState.visibleItems;
  355. function scrollToItemByCommentId(cmtId) {
  356. const itemIndex = dataOut.findIndex(obj => obj.id == cmtId);
  357. if (itemIndex < 0) return;
  358. scrollToItemByIndex(itemIndex);
  359. }
  360. function scrollToItemByIndex(itemIndex) {
  361. if (itemIndex < 0) return;
  362. const isLast = itemIndex >= dataOut.length - 1;
  363. const scrollTo = (animated) => {
  364. const list = flashListRef.current;
  365. if (!list) return;
  366. // Includes footer spacer (composer clearance) when present.
  367. if (isLast && typeof list.scrollToEnd === 'function') {
  368. list.scrollToEnd({ animated });
  369. return;
  370. }
  371. list.scrollToIndex?.({
  372. animated,
  373. index: itemIndex,
  374. align: 'end',
  375. behavior: animated ? 'smooth' : 'auto',
  376. viewPosition: 1,
  377. });
  378. };
  379. // Retry once after row heights are measured (long threads otherwise undershoot).
  380. setTimeout(() => scrollTo(true), 300);
  381. setTimeout(() => scrollTo(false), 850);
  382. }
  383. const renderListFooter = useCallback(() => (
  384. <TouchableWithoutFeedback onPress={dismissComposerIfIdle}>
  385. <View>
  386. {(browseParams.object_id && hasNextPage && isFetchingNextPage) ? (
  387. <View className=''><Loading /></View>
  388. ) : (browseParams.object_id && !refetchState.visibleItems.length && isLoaded ? appStatic('components_comments_empty') : null)}
  389. {/* Spacer so last comments clear sticky composer (+ keyboard on native). */}
  390. {marginBottom > 0 ? <View style={{ height: marginBottom }} collapsable={false} /> : null}
  391. </View>
  392. </TouchableWithoutFeedback>
  393. ), [
  394. browseParams.object_id,
  395. hasNextPage,
  396. isFetchingNextPage,
  397. refetchState.visibleItems.length,
  398. isLoaded,
  399. marginBottom,
  400. dismissComposerIfIdle,
  401. ]);
  402. const renderItem = useCallback(({ item, index }) => {
  403. if (item.id.toString().includes('block')) {
  404. return (
  405. <TouchableWithoutFeedback onPress={dismissComposerIfIdle}>
  406. <View>{item.data}</View>
  407. </TouchableWithoutFeedback>
  408. );
  409. }
  410. return (
  411. <TouchableWithoutFeedback onPress={dismissComposerIfIdle}>
  412. <View className="px-4" key={index}>
  413. <UnitComments
  414. selectedId={scrollToIndex}
  415. hideActions={hideActions}
  416. replyId={replyId}
  417. {...item}
  418. module={browseParams.module}
  419. view={viewMode}
  420. max_level={browseParams.max_level}
  421. isNewComment={false}
  422. />
  423. </View>
  424. </TouchableWithoutFeedback>
  425. );
  426. }, [scrollToIndex, hideActions, replyId, browseParams.module, browseParams.max_level, viewMode, dismissComposerIfIdle]);
  427. return (
  428. <>
  429. <View className={embedded ? 'w-full' : 'flex-1 w-full min-h-0'} style={height > 0 ? { height } : undefined}>
  430. <UniList
  431. mode='simple'
  432. isModal={isModal}
  433. useCustomScrollHandler={useCustomScrollHandler}
  434. useWindowScroll={!isModal && !embedded}
  435. no_scroll={embedded}
  436. flowLayout={embedded}
  437. // UniList forces window scroll whenever it cannot resolve a height,
  438. // which silently overrides `useWindowScroll: false`. In a modal the
  439. // window cannot scroll, so without a real px height here the list is
  440. // unscrollable and its overflow is clipped. Embedded (list-open
  441. // task) lays out in flow so the browse card can scroll.
  442. height={embedded ? undefined : (height > 0 ? height : undefined)}
  443. style={embedded ? undefined : { flex: 1 }}
  444. data={dataOut}
  445. extraData={marginBottom}
  446. refer={flashListRef}
  447. onRefresh={refetch}
  448. renderItem={renderItem}
  449. onEndReached={handleEndReached}
  450. ListFooterComponent={renderListFooter}
  451. keyboardShouldPersistTaps="always"
  452. keyboardDismissMode="none"
  453. onScrollBeginDrag={markListDragging}
  454. onScrollEndDrag={clearListDragging}
  455. onMomentumScrollBegin={markListDragging}
  456. onMomentumScrollEnd={clearListDragging}
  457. />
  458. </View>
  459. {/*!refetchState.visibleItems.length && appStatic('components_comments_empty')*/}
  460. <Snackbar
  461. visible={refetchState.hasNewData}
  462. position="top"
  463. onPress={() => {
  464. const latestItems = flattenPagesForComments(pagesData, viewMode)
  465. dispatch({ type: 'SET_ITEMS', items: latestItems })
  466. refetchRef.current.prevItems = latestItems
  467. // TODO: scroll to the right place here
  468. if (flashListRef.current) {
  469. flashListRef.current.scrollToIndex?.({
  470. index: latestItems.length - 1,
  471. animated: true,
  472. })
  473. }
  474. setBrowseParams(prev => ({
  475. ...prev,
  476. total_count: pagesData.pages[0].total_count,
  477. }));
  478. }}
  479. variant="primary"
  480. title={t('Show new comments')}
  481. size="sm"
  482. />
  483. </>
  484. )
  485. }
  486. function buildFlatListFromTree(items, viewMode) {
  487. const result = []
  488. function walk(items, level, last_child_in, lvls) {
  489. if (!items) return
  490. Object.keys(items).forEach((k) => {
  491. let ilen = Object.keys(items[k]).length
  492. let item = ilen == 1 ? items[k][Object.keys(items[k])[0]] : items[k]
  493. let childs = Object.keys(item.items || {})
  494. let last_child = 0
  495. if (childs.length > 0) {
  496. last_child = item.items[childs[childs.length - 1]].id
  497. }
  498. item.level = level
  499. item.last_child = last_child_in
  500. lvls[level] = last_child_in != item.id
  501. item.lvls = lvls.slice()
  502. // Keep parent as-is for now — can optimize later
  503. item.parent = result.filter(
  504. (item2) => item2.data.cmt_id == item.data.cmt_parent_id
  505. )[0]
  506. result.push(item)
  507. if (item.items && viewMode != 'flat') {
  508. walk(item.items, level + 1, last_child, lvls.slice())
  509. }
  510. })
  511. }
  512. walk(items, 0, 0, [])
  513. return result
  514. }
  515. function flattenPagesForComments(pagesData, viewMode) {
  516. if (!pagesData?.pages?.length) return []
  517. const all = []
  518. pagesData.pages.forEach((page) => {
  519. const tree =
  520. page?.raw?.data?.browse?.data?.data
  521. || page.tree
  522. || page.data
  523. if (!tree) return
  524. const flat = buildFlatListFromTree(tree, viewMode)
  525. all.push(...flat)
  526. })
  527. return all
  528. }
  529. export function CommentsBrowseShort({
  530. contentUrl,
  531. browseData,
  532. module,
  533. handleReply
  534. }) {
  535. const UnitComments = components['unit']['comments'];
  536. const viewMode = browseData?.view;
  537. const items = browseData?.data ? buildFlatListFromTree(browseData.data, viewMode) : [];
  538. const maxCount= appSetting('comments', 'count_in_feed');
  539. return (maxCount ? items.slice(0, maxCount) : items).map((item, index) => (
  540. <View key={item.id}>
  541. <UnitComments
  542. contentUrl={contentUrl}
  543. {...item}
  544. module={module}
  545. view={viewMode}
  546. max_level={browseData?.max_level}
  547. handleReply={handleReply}
  548. />
  549. </View>))
  550. }
  551. export function findParent(data, c, o, insert) {
  552. if (Array.isArray(data)) {
  553. //for first level
  554. data.map(function (d, k) {
  555. if (o.data.cmt_vparent_id == d[Object.keys(d)[0]].id) {
  556. if (insert == 'before')
  557. data[k][Object.keys(data[k])[0]].items = { ...c, ...data[k][Object.keys(data[k])[0]].items };
  558. else
  559. data[k][Object.keys(data[k])[0]].items = { ...data[k][Object.keys(data[k])[0]].items, ...c };
  560. }
  561. data[k][Object.keys(data[k])[0]].items = findParent(data[k][Object.keys(data[k])[0]].items, c, o, insert)
  562. })
  563. }
  564. else {
  565. //for another levels
  566. Object.keys(data).forEach(function (k) {
  567. if (o.data.cmt_vparent_id == data[k].id) {
  568. if (insert == 'before')
  569. data[k].items = { ...c, ...data[k].items };
  570. else
  571. data[k].items = { ...data[k].items, ...c };
  572. }
  573. data[k].items = findParent(data[k].items, c, o)
  574. });
  575. }
  576. return data;
  577. }
  578. export function parseData(browse, dynamicData) {
  579. if (!dynamicData?.data?.browse?.data?.data)
  580. return;
  581. dynamicData.data.browse.data.data.map(function (c, kc) {
  582. let o = c[Object.keys(c)[0]];
  583. // add in root
  584. if (o.data.cmt_vparent_id == 0) {
  585. let bPresent = false;
  586. browse.data.data.forEach(function (k) {
  587. if (Object.keys(k)[0] == Object.keys(c)[0])
  588. bPresent = true;
  589. });
  590. if (!bPresent) {
  591. if (dynamicData.data.browse.insert == 'before') {
  592. browse.data.data = browse.data.data.concat([c]);
  593. }
  594. else {
  595. browse.data.data = [c].concat(browse.data.data);
  596. }
  597. }
  598. }
  599. else {
  600. browse.data.data = findParent(browse.data.data, c, o, dynamicData.data.browse.insert);
  601. }
  602. });
  603. return browse;
  604. }
  605. /**
  606. * `fadeSurface` — when the composer floats over scrolling comments (modal, and the
  607. * fixed composer on small web screens) pass the surface colour it sits on
  608. * (`'card'` | `'background'`). The panel then fades from that colour at the bottom
  609. * to transparent at the top, so comments passing behind stay partly visible.
  610. * Omit it when the composer is in normal flow (desktop full page) — there is
  611. * nothing behind it to reveal.
  612. */
  613. export function CommentsForm({ form: initialForm, requestUrl, module, objectId, isModal = false, fadeSurface }) {
  614. if (!initialForm?.data?.inputs)
  615. return <></>
  616. return <CommentsFormInner form={initialForm} requestUrl={requestUrl} module={module} objectId={objectId} isModal={isModal} fadeSurface={fadeSurface} />
  617. }
  618. function CommentsFormInner ({ form: initialForm, requestUrl, module, objectId, isModal = false, fadeSurface }) {
  619. const { t } = useTranslation();
  620. const [formData, setFormData] = useState({});
  621. const [form, setForm] = useState(initialForm);
  622. const [commentForm, setCommentForm] = useState();
  623. const url = requestUrl + JSON.stringify({ 'module': module, 'object_id': objectId });
  624. const { data: dynamicData } = useFetchForm(url, commentForm);
  625. useEffect(() => {
  626. const subscription = emitter.addListener(EVENTS.commentThread(module, objectId), (data) => {
  627. if (data.action == 'reply_comment') {
  628. setFormData({
  629. text: stripTags(data.data.cmt_text),
  630. parent_id: data.data.cmt_id,
  631. author: data.data.author_data,
  632. cmt_id: data.data.cmt_id,
  633. cmt_object_id: data.data.cmt_object_id
  634. })
  635. }
  636. })
  637. const subscription2 = emitter.addListener(EVENTS.comment, (data) => {
  638. if (data.action == 'send') {
  639. setFormData({})
  640. }
  641. })
  642. return () => {
  643. subscription.remove();
  644. subscription2.remove();
  645. }
  646. }, [])
  647. useEffect(() => {
  648. if (!(formData.parent_id > 0)) return;
  649. let cancelled = false;
  650. const updateFormInputs = async () => {
  651. let mentionText = '';
  652. if (appSetting('comments', 'mentions')) {
  653. const sUrl = appSetting('urls', 'cmts_menthion_url');
  654. if (sUrl) {
  655. const sResponse = await fetcher(`/api.php?r=${sUrl}&params[]=${formData.cmt_id}&params[]=${formData.cmt_object_id}`);
  656. if (sResponse?.data) {
  657. mentionText = `<a class="bx-mention-link ${sResponse.data.add_classes}" ts="${formData.ts}" data-id="[object Object]" href="/mention${sResponse.data.id}" title="${sResponse.data.name.trim()}" dchar="@" data-profile-id="-1" contenteditable="false">${sResponse.data.name.trim()}</a>&shy;&nbsp;`;
  658. }
  659. } else if (formData.author?.url === "/javascript:") {
  660. mentionText = `<a class="bx-mention-link" ts="${formData.ts}" data-id="[object Object]" href="#" title="${formData.author.display_name.trim()}" dchar="@" data-profile-id="-1" contenteditable="false">${formData.author.display_name.trim()}</a>&shy;&nbsp;`;
  661. } else if (formData.author?.url) {
  662. mentionText = `<a class="bx-mention-link" ts="${formData.ts}" href="${formData.author.url}">${formData.author.display_name.trim()}</a>&shy;&nbsp;`;
  663. }
  664. }
  665. if (cancelled) return;
  666. // Parent id only — avoid remounting editor via cmt_text value/autofocus.
  667. setForm(prevForm => ({
  668. ...prevForm,
  669. data: {
  670. ...prevForm.data,
  671. inputs: {
  672. ...prevForm.data.inputs,
  673. cmt_parent_id: {
  674. ...prevForm.data.inputs.cmt_parent_id,
  675. value: formData.parent_id
  676. },
  677. }
  678. }
  679. }));
  680. if (mentionText) {
  681. emitter.emit(EVENTS.editor, { action: 'set_content', value: mentionText });
  682. }
  683. // Direct bridge focus after reply banner + content settle.
  684. // Avoid timeout remount (ghost TextInput) — it makes the composer slide in on iOS.
  685. requestAnimationFrame(() => {
  686. if (cancelled) return;
  687. setTimeout(() => {
  688. if (cancelled) return;
  689. emitter.emit(EVENTS.editor, { action: 'focus' });
  690. }, 50);
  691. });
  692. };
  693. updateFormInputs();
  694. return () => {
  695. cancelled = true;
  696. };
  697. }, [formData.parent_id, formData.cmt_id, formData.cmt_object_id, formData.author, formData.ts]);
  698. useEffect(() => {
  699. // The reply to a posted comment: error when UNA rejects it, subtle success only once the new comment is in it.
  700. if (formResponseHasFieldErrors(dynamicData?.data)) playCue('error');
  701. else if (dynamicData?.data?.browse?.new?.[0]) playCue('success', { emphasis: 'subtle' });
  702. if (dynamicData?.data?.browse && dynamicData?.data?.browse?.new?.[0]) {
  703. emitter.emit(EVENTS.commentThread(module, objectId), { action: 'new_content', data: dynamicData.data.browse.data.data[0]['i' + dynamicData.data.browse.new[0]] });
  704. handleCancel()
  705. }
  706. }, [dynamicData]);
  707. const handleCancel = async () => {
  708. setFormData({});
  709. setForm(prevForm => ({
  710. ...prevForm,
  711. data: {
  712. ...prevForm.data,
  713. inputs: {
  714. ...prevForm.data.inputs,
  715. cmt_parent_id: {
  716. ...prevForm.data.inputs.cmt_parent_id,
  717. value: 0
  718. },
  719. cmt_text: {
  720. ...prevForm.data.inputs.cmt_text,
  721. value: ''
  722. }
  723. }
  724. }
  725. }));
  726. emitter.emit(EVENTS.editor, { action: 'set_content', value: '' });
  727. }
  728. const onFormSubmit = (formData, d) => {
  729. setCommentForm(formData);
  730. }
  731. const combinedExProps = {
  732. ...(form?.exProps || {}),
  733. browse: dynamicData?.data?.browse,
  734. isModal,
  735. };
  736. return (
  737. <EdgeBlurView
  738. edge="bottom"
  739. config={fadeSurface ? edgeBlurConfig('footer') : null}
  740. className=" p-2 sm:p-4 "
  741. washClassName={FADE_SURFACE_CLASS[fadeSurface] || ''}
  742. style={fadeSurface ? { paddingTop: FADE_RUN_UP } : undefined}
  743. >
  744. {
  745. formData.parent_id > 0 && (<View className='bg-accent rounded-xl border border-accent px-2.5 py-2 mb-2'>
  746. <Row className='items-start justify-between max-w-full relative'>
  747. <View className=' flex-auto pr-4'>
  748. <Row className='max-w-full '>
  749. <Text className='text-xs text-popover-foreground '>{t('Reply to:')} </Text>
  750. <Text className='font-semibold text-xs text-popover-foreground '>{ProfileDisplayName(formData.author.display_name)}</Text>
  751. </Row>
  752. <Text className=' text-sm overflow-hidden text-popover-foreground ' numberOfLines={3}>{formData.parent_id > 0 ? formData.text : ''}</Text>
  753. </View>
  754. <View className=" -right-1 -top-1">
  755. <NeoButton style="borderless" controlSize="mini" borderShape="circle" image="X" accessibilityLabel={t('Cancel reply')} onPress={() => handleCancel()} />
  756. </View>
  757. </Row>
  758. </View>)
  759. }
  760. <Form {...form} exProps={combinedExProps} resetOnSubmit={true} classContainerName={" flex-row flex-wrap w-full items-start justify-between"} onFormSubmit={onFormSubmit} />
  761. </EdgeBlurView>
  762. )
  763. }