feed-item-parts.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  1. import Link from 'app/ui/atoms/link'
  2. import Time from 'app/ui/atoms/time'
  3. import Profile from 'app/ui/molecules/profile/profile'
  4. import ProfileHoverCard from 'app/ui/molecules/profile/profile-hover-card'
  5. import {
  6. memo,
  7. useState,
  8. useEffect,
  9. useRef,
  10. } from 'react'
  11. import { useCurrentUser } from 'app/context/user'
  12. import {
  13. appSetting,
  14. menuItemsByName,
  15. visibilityById,
  16. runMenuItemCallback,
  17. decodeText,
  18. } from 'app/lib/util'
  19. import { Text } from 'app/design/typography'
  20. import { View, Row } from 'app/design/view'
  21. import { Platform, StyleSheet } from 'react-native'
  22. import { Modal, NeoButton, NeoButtonLink } from 'app/design/controls'
  23. import Menu from 'app/components/menu'
  24. import DropdownMenu from 'app/ui/atoms/dropdown-menu'
  25. import { fetcher } from 'app/lib/fetcher'
  26. import { components } from 'app/components/registry'
  27. import Card from 'app/ui/molecules/page/card'
  28. import AnimatedBlock from 'app/ui/atoms/animated-block'
  29. import { CommentsBrowseShort } from 'app/components/elements/comments-browse';
  30. import { Pressable } from 'app/design/view'
  31. import { subscribeTimelineEdit } from 'app/lib/timeline-edits'
  32. import { commentsObjectKey, objectStateGeneration, storeObjectsFrom, useObjectState } from 'app/lib/object-state'
  33. import { getDataForMenu } from 'app/lib/util'
  34. import Form from 'app/components/elements/form'
  35. import useFetchForm from 'app/lib/hooks/use-fetch-form'
  36. import { useTranslation } from 'react-i18next'
  37. import Loading from 'app/ui/atoms/loading'
  38. import * as FeedItems from 'app/lib/feed-items'
  39. import { Icon } from 'app/ui/atoms/icon'
  40. import { SafeMenuTrigger } from 'app/ui/atoms/safe-menu-trigger'
  41. import { stripTags, isWeb } from 'app/lib/util'
  42. import emitter, { EVENTS } from 'app/context/emitter';
  43. export const FeedEditForm = memo(({ setViewState, viewState, id }) => {
  44. const { t } = useTranslation()
  45. const [postData, setPostData] = useState(null)
  46. const { data: dynamicData } = useFetchForm(
  47. '/api.php?r=bx_timeline/get_edit_form/&params[]=' + id,
  48. postData
  49. )
  50. const onFormSubmit = (formData, d) => {
  51. setPostData(formData)
  52. }
  53. useEffect(() => {
  54. if (dynamicData?.data?.item) setViewState({ view: '' })
  55. }),
  56. [dynamicData]
  57. return (
  58. <Modal
  59. onVisible={true}
  60. transparent={true}
  61. headerBorder={true}
  62. padding=" "
  63. >
  64. <View className='p-3 flex-auto'>
  65. <Form
  66. {...viewState.data.form}
  67. classContainerName="flex-row flex-wrap w-full items-start justify-between"
  68. onFormSubmit={onFormSubmit}
  69. exProps={{
  70. onClose: () => {
  71. setViewState({ view: '' })
  72. },
  73. item: viewState?.data?.item,
  74. }}
  75. />
  76. </View>
  77. </Modal>
  78. )
  79. })
  80. export const CommentsSection = memo(
  81. ({
  82. isCommentsModal,
  83. commentsDataInline,
  84. data,
  85. isShowMoreComments,
  86. showCommentsModal,
  87. url,
  88. t,
  89. }) => {
  90. return (
  91. <View className="border-t border-border/40 p-1 lg:p-2 ">
  92. {isShowMoreComments && (
  93. <View className="p-0.5 me-auto">
  94. {isCommentsModal ? (
  95. <NeoButton
  96. style="borderless"
  97. controlSize="mini"
  98. label={t('View more comments...')}
  99. onPress={() => {
  100. showCommentsModal()
  101. }}
  102. />
  103. ) : (
  104. <NeoButtonLink
  105. href={url}
  106. style="borderless"
  107. controlSize="mini"
  108. label={t('View more comments...')}
  109. />
  110. )}
  111. </View>
  112. )}
  113. <View className="px-2 ">
  114. <CommentsBrowseShort
  115. contentUrl={url}
  116. browseData={commentsDataInline?.data}
  117. module={data?.cmts.module}
  118. handleReply={isCommentsModal ? showCommentsModal : 'link'}
  119. /></View>
  120. </View>
  121. )
  122. }
  123. )
  124. export const MainContent = memo(({ url, data, fulltext }) => {
  125. const bIsTitle =
  126. data?.content?.title && data?.content?.title?.trim() != ''
  127. ? true
  128. : false
  129. let content_attach = []
  130. if (data?.content?.images_attach && data?.content?.images_attach?.length > 0) {
  131. content_attach = content_attach.concat(data.content.images_attach)
  132. }
  133. if (data?.content?.videos_attach && data?.content?.videos_attach?.length > 0) {
  134. content_attach = content_attach.concat(data.content.videos_attach)
  135. }
  136. let files_attach = []
  137. if (data?.content?.files_attach && data?.content?.files_attach.length > 0) {
  138. files_attach = files_attach.concat(data.content.files_attach)
  139. }
  140. const styles = StyleSheet.create(
  141. Platform.OS !== 'web'
  142. ? {
  143. card_image: {
  144. borderRadius: 0,
  145. },
  146. }
  147. : {}
  148. )
  149. const commonProps = {
  150. isCompact: false,
  151. content_attach,
  152. url,
  153. data,
  154. styles,
  155. bIsTitle,
  156. fulltext,
  157. }
  158. const unitTypes = appSetting('feed', 'units')
  159. const contentType = data?.type
  160. const componentName = unitTypes[contentType]
  161. const ContentComponent = FeedItems[componentName]
  162. return ContentComponent ? (
  163. <ContentComponent {...commonProps} />
  164. ) : (
  165. <FeedItems.DefaultView
  166. {...commonProps}
  167. files_attach={files_attach}
  168. bIsTimelineContent={
  169. data?.type?.includes('timeline') || data?.type === 'bx_channels'
  170. }
  171. />
  172. )
  173. })
  174. export function prepareData(data) {
  175. const url = data?.url?.includes('://') ? data?.url : (data?.url?.startsWith('/') ? data?.url : '/' + data?.url)
  176. let commentsData = null
  177. let isShowMoreComments = false
  178. if (data?.cmts?.data?.length > 0) {
  179. commentsData = {
  180. id: 'cmt_list',
  181. insert: 'before',
  182. type: 'browse',
  183. data: data?.cmts,
  184. }
  185. if (data?.cmts?.total_count > appSetting('comments', 'count_in_feed')) {
  186. isShowMoreComments = true
  187. }
  188. }
  189. return { url, commentsData, isShowMoreComments }
  190. }
  191. // `feed_type_*` translations wrap the noun in <b>…</b>; split() keeps it at odd indexes.
  192. const ACTION_NOUN = /<b>([\s\S]*?)<\/b>/;
  193. export const ItemInfo = memo(({ data, t }) => {
  194. const [showContextList, setShowContextList] = useState(false)
  195. const owners = data.owners
  196. ? data.owners.filter(
  197. (item) => item.author_data?.id != data.context_data?.id
  198. )
  199. : []
  200. const OwnersList = () =>
  201. owners?.length > 0 ? (
  202. owners?.length == 1 ? (
  203. <>
  204. <Icon className="text-muted-foreground" icon='Dot' size={14} />
  205. <Link href={data.owners[0].url} emulate={true}>
  206. <Text className=" text-secondary-foreground web:hover:text-accent-foreground font-normal text-sm leading-5 ">
  207. {owners[0].title}
  208. </Text>
  209. </Link>
  210. </>
  211. ) : (
  212. <>
  213. <Icon className="text-muted-foreground" icon='Dot' size={14} />
  214. <Pressable
  215. onPress={() => {
  216. setShowContextList(true)
  217. }}
  218. >
  219. <Text className=" bg-muted px-1.5 leading-5 items-center justify-text-center justify-center h-5 rounded-md text-muted-foreground web:hover:text-accent-foreground text-sm font-normal">
  220. {owners[0].title} + {owners.length - 1}
  221. </Text>
  222. </Pressable>
  223. <Modal
  224. onVisible={showContextList}
  225. onClose={() => {
  226. setShowContextList(false)
  227. }}
  228. transparent={true}
  229. headerBorder={true}
  230. title={t('Posted to')}
  231. >
  232. <View className="gap-x-2 mb-2">
  233. {owners.map((item, index) => (
  234. <Row
  235. className="items-center py-1 pl-2 my-1 border border-border/60 rounded-lg web:hover:bg-primary/10 active:bg-primary/20 "
  236. key={'chk' + index}
  237. >
  238. <Link
  239. key={`link-{$index}`}
  240. href={item.url}
  241. emulate={true}
  242. >
  243. <Text className="text-muted-foreground text-sm">
  244. {' '}
  245. {item.title}
  246. </Text>
  247. </Link>
  248. </Row>
  249. ))}
  250. </View>
  251. </Modal>
  252. </>
  253. )
  254. ) : (
  255. <></>
  256. )
  257. const statusFlags = []
  258. if (Number(data.pinned) > 0)
  259. statusFlags.push({ key: 'pinned', icon: 'Pin' })
  260. if (Number(data.promoted) > 0)
  261. statusFlags.push({ key: 'promoted', icon: 'Flame' })
  262. if (Number(data.sticked) > 0)
  263. statusFlags.push({ key: 'sticked', icon: 'Pin' })
  264. const FeedType = () => {
  265. const typeKey = 'feed_type_' + data.type
  266. let l = t(typeKey)
  267. if (l === typeKey) {
  268. // A type with no `item_*` label shows no action text rather than its raw key.
  269. const itemKey = 'item_' + data.type
  270. const item = t(itemKey)
  271. l = item === itemKey ? '' : t('added a') + ' <b>' + item + '</b>'
  272. }
  273. if (data.type == 'timeline_common_repost') {
  274. l += ' <b>' + data.content.owner_name + "'s " + data.content.parse_type + '</b>'
  275. }
  276. // The action reads regular, the thing it names semibold: "added a <b>Discussion</b>".
  277. const parts = l ? l.split(ACTION_NOUN) : []
  278. return (
  279. l && (
  280. <>
  281. <Icon className="text-muted-foreground" icon='Dot' size={14} />
  282. <Text className="text-secondary-foreground font-normal text-sm leading-5 ">
  283. {parts.map((part, i) => i % 2
  284. ? <Text key={i} className="font-semibold">{part}</Text>
  285. : decodeText(part))}
  286. </Text>
  287. </>
  288. )
  289. )
  290. }
  291. return (
  292. <>
  293. <FeedType />
  294. <OwnersList />
  295. {statusFlags.map(({ key, icon }) => (
  296. <Row key={key} className="items-center">
  297. <Icon className="text-muted-foreground" icon="Dot" size={14} />
  298. <Icon className="text-secondary-foreground py-0.5" icon={icon} width={14} height={14} />
  299. </Row>
  300. ))}
  301. </>
  302. )
  303. })
  304. export const MenuManage = ({ id, menu, setViewState, data }) => {
  305. const { t } = useTranslation()
  306. const [menuData, setMenuData] = useState(false)
  307. const fetchedRef = useRef(false)
  308. const dataRef = useRef(data)
  309. useEffect(() => {
  310. if (dataRef.current === data) return
  311. dataRef.current = data
  312. if (!fetchedRef.current || !menu?.object || menu.items) return
  313. getDataForMenu(menu, setMenuData)
  314. }, [data, menu])
  315. if (!menu?.object) return null
  316. if (menu.items)
  317. return <MenuManage_ id={id} menu={menu} setViewState={setViewState} />
  318. if (!menuData)
  319. return (
  320. <SafeMenuTrigger>
  321. <NeoButton
  322. style="borderless"
  323. controlSize="small"
  324. borderShape="circle"
  325. image="Ellipsis"
  326. accessibilityLabel={t('More options')}
  327. expoUI={false}
  328. classNames={{ root: 'self-center' }}
  329. onPress={() => {
  330. fetchedRef.current = true
  331. if (Platform.OS === 'web')
  332. setMenuData({
  333. ...menu,
  334. items: [{ name: 'loader' }],
  335. })
  336. getDataForMenu(menu, setMenuData)
  337. }}
  338. />
  339. </SafeMenuTrigger>
  340. )
  341. return (
  342. <MenuManage_
  343. id={id}
  344. menu={menuData}
  345. defaultOpen={true}
  346. setViewState={setViewState}
  347. />
  348. )
  349. }
  350. const MenuManage_ = memo(({ id, menu, setViewState, defaultOpen }) => {
  351. const { t } = useTranslation()
  352. const { currentUser } = useCurrentUser()
  353. const handleMenuManageSelect = async (oItem, event) => {
  354. switch (oItem.name) {
  355. case 'item-edit':
  356. const oResultEdit = await fetcher(
  357. '/api.php?r=bx_timeline/get_edit_form/&params[]=' + id
  358. )
  359. setViewState({ view: 'edited', data: oResultEdit.data })
  360. break
  361. case 'item-delete':
  362. await fetcher(
  363. '/api.php?r=bx_timeline/delete/&params[]=' + id
  364. )
  365. emitter.emit(EVENTS.feed, { action: 'remove_content', id: id });
  366. break
  367. default:
  368. await runMenuItemCallback(oItem)
  369. break
  370. }
  371. }
  372. let oReport = undefined
  373. const aMenuManageItems = !!currentUser
  374. ? menu &&
  375. menuItemsByName(menu?.object, menu?.items, currentUser).map(
  376. (aItem) => {
  377. let sTitle = aItem.title
  378. if (!!aItem.display_type && aItem.display_type == 'element') {
  379. const Element = components['molecule'][String(aItem.data.type)]
  380. if (!!Element) {
  381. sTitle = (
  382. <Element
  383. mode="dropdown-menu"
  384. key={aItem.id ? aItem.id : aItem.name}
  385. {...aItem.data}
  386. />
  387. )
  388. }
  389. }
  390. if (aItem.name == 'loader') {
  391. sTitle = <Loading size="small" />
  392. }
  393. return {
  394. id: aItem.id ? aItem.id : aItem.name,
  395. name: aItem.name,
  396. link: aItem.link,
  397. title: sTitle,
  398. icon: aItem.icon,
  399. image: aItem.image,
  400. data: aItem.data,
  401. }
  402. }
  403. )
  404. : []
  405. return (
  406. aMenuManageItems?.length > 0 && (
  407. <>
  408. <View className="flex-none" aria-label={t('Manage menu')}>
  409. <DropdownMenu
  410. mode="popup"
  411. items={aMenuManageItems}
  412. defaultOpen={defaultOpen}
  413. onSelect={handleMenuManageSelect}
  414. buttonProps={{
  415. style: 'borderless',
  416. controlSize: 'small',
  417. borderShape: 'circle',
  418. image: 'Ellipsis',
  419. accessibilityLabel: t('More options'),
  420. }}
  421. />
  422. </View>
  423. {!!oReport && oReport}
  424. </>
  425. )
  426. )
  427. })
  428. export const ActionMenu = memo(({ data }) => {
  429. const settings = appSetting('feed', 'actions_menu');
  430. // From the third item on, titles show from `sm` up. Items are copied, never written to.
  431. const items = data?.items?.length > 2
  432. ? data.items.map((item, i) => {
  433. if (i < 2) return item
  434. const params = { 'button_show_title_from_size': 'sm' }
  435. return item.data ? { ...item, data: { ...item.data, params } } : { ...item, params }
  436. })
  437. : data?.items
  438. const data_other = {...data, items: items?.filter(item => item.class != " bx-mi-primary" && item.primary != 1 )};
  439. const data_primary = {...data, items: items?.filter(item => item.class == " bx-mi-primary" || item.primary == 1 )};
  440. return settings && <Row className="flex-auto justify-between gap-1">
  441. <Menu {...data_primary} displayType="button" params={settings} />
  442. <Menu {...data_other} displayType="button" params={settings} />
  443. </Row>
  444. })
  445. export const CounterMenu = memo(({ data }) => {
  446. const settings = appSetting('feed', 'counters_menu')
  447. const data_other = {...data, items: data?.items.filter(item => item.class != " bx-mi-primary" && item.primary != 1 )};
  448. const data_primary = {...data, items: data?.items.filter(item => item.class == " bx-mi-primary" || item.primary == 1 )};
  449. return settings && <Row className="flex-auto justify-between">
  450. <Menu {...data_primary} displayType="button" params={settings} />
  451. <Menu {...data_other} displayType="button" params={settings} />
  452. </Row>
  453. })
  454. export const VisibilityInfo = memo(({ data }) => {
  455. const { t } = useTranslation()
  456. if (data.feed_type == 'owner') return null
  457. const visibilityData = visibilityById(data.object_privacy_view, t)
  458. const icon = visibilityData ? visibilityData.icon : ''
  459. const text = visibilityData ? visibilityData.text : ''
  460. const isUser = data.object_privacy_view < 0
  461. return (
  462. <View className="gap-1 flex-row items-center ">
  463. {isUser ? (
  464. <Profile
  465. {...data.author_data}
  466. displayType="unit_wo_info"
  467. displaySize="2xs"
  468. />
  469. ) : icon ? (
  470. <Icon className="text-secondary-foreground py-0.5 " icon={icon} width={14} height={14} />
  471. ) : null}
  472. <Text className="text-secondary-foreground text-sm font-normal leading-5">
  473. {isUser ? data.author_data.display_name : text}
  474. </Text>
  475. </View>
  476. )
  477. })
  478. export const AuthorActions = memo(({ data }) => {
  479. if (!data.author_actions?.length) return null
  480. return data.author_actions.map((item) => {
  481. const Element = components['molecule'][String(item.type)]
  482. if (!Element)
  483. return null
  484. return (
  485. <Element
  486. key={`action-${item.cid}-${item.iid}`}
  487. params={{
  488. button_style: 'link',
  489. button_size: 'small',
  490. button_border_shape: 'capsule',
  491. hide_icon: true,
  492. }}
  493. {...item}
  494. />
  495. )
  496. })
  497. })
  498. export const Author = memo(({ data, url, t }) => {
  499. const Badges = components['molecule']['badges']
  500. const dataIcon =
  501. data.object_privacy_view < 0 && data.feed_type != 'owner'
  502. ? data.context_data
  503. : data.author_data
  504. const shouldShowVisibilityInfo =
  505. data.feed_type != 'owner' &&
  506. (data.object_privacy_view < 0 || !!visibilityById(data.object_privacy_view, t))
  507. // Create a hover card wrapper function that only wraps avatar/name
  508. const hoverCardWrapper = (content) => (
  509. <ProfileHoverCard profileData={dataIcon}>
  510. {content}
  511. </ProfileHoverCard>
  512. );
  513. const TimestampLink = (
  514. <Link
  515. href={url}
  516. {...(isWeb
  517. ? { emulate: false }
  518. : {
  519. mode: 'text',
  520. hitSlop: { top: 8, bottom: 8, left: 8, right: 8 },
  521. })}
  522. size="sm"
  523. variant="secondary"
  524. className="font-normal"
  525. >
  526. <Time ts={data.date} className="text-secondary-foreground" />
  527. </Link>
  528. );
  529. return (
  530. <View className="flex-auto">
  531. <Profile
  532. {...dataIcon}
  533. displayType="unit"
  534. displaySize="base"
  535. showInfo={
  536. <Row className="items-center flex-wrap min-h-5">
  537. {TimestampLink}
  538. {shouldShowVisibilityInfo && (
  539. <>
  540. <Icon className="text-muted-foreground" icon='Dot' size={14} />
  541. <VisibilityInfo data={data} />
  542. </>
  543. )}
  544. <ItemInfo data={data} t={t} />
  545. </Row>
  546. }
  547. showInfo2={<Badges badges={data.author_badges} size="3xs" />}
  548. hoverCardWrapper={hoverCardWrapper}
  549. />
  550. </View>
  551. )
  552. })
  553. export function SmallUnit({ data }) {
  554. let url = '/' + data.url
  555. // Create a hover card wrapper function for avatar only
  556. const hoverCardWrapper = (content) => (
  557. <ProfileHoverCard profileData={data.author_data}>
  558. {content}
  559. </ProfileHoverCard>
  560. );
  561. return (
  562. <AnimatedBlock>
  563. <Link href={url} className="w-full" emulate={true}>
  564. <Card className={' w-full tl-' + data.id}>
  565. <View className=" mr-2 xl:mr-3 rounded-full flex-none bg-secondary-500/10">
  566. <Profile
  567. {...data.author_data}
  568. displayType="unit_wo_info"
  569. displaySize="base"
  570. hoverCardWrapper={hoverCardWrapper}
  571. />
  572. </View>
  573. <View className="flex-auto flex-col my-auto">
  574. <Row className="flex-row gap-2">
  575. <Text className=" text-sm flex-auto font-medium text-secondary-foreground ">
  576. {data.author_data.display_name}
  577. </Text>
  578. <Time variant="link" className="text-xs flex-none leading-5 "
  579. ts={data.date}
  580. ></Time>
  581. </Row>
  582. <Text
  583. className="flex-auto text-lg font-bold text-secondary-foreground "
  584. numberOfLines={1}
  585. >
  586. {data.content.title}
  587. </Text>
  588. <View className="flex-row w-full items-end content-end">
  589. <Text
  590. className="flex-auto mr-2 text-base text-muted-foreground "
  591. numberOfLines={1}
  592. >
  593. {data.plainText}
  594. {stripTags(data.content.text)}
  595. </Text>
  596. <View className="flex-none bg-primary rounded-full my-auto h-min px-1.5">
  597. {data.cmts.count > 0 && (
  598. <Text className="text-xs text-white dark:text-black font-medium">
  599. {data.cmts.count}
  600. </Text>
  601. )}
  602. </View>
  603. </View>
  604. </View>
  605. </Card>
  606. </Link>
  607. </AnimatedBlock>
  608. )
  609. }
  610. const cmtsFromProps = (cmts) => cmts
  611. /** Card fields derived from a timeline item (first image, first comments). */
  612. function enrichFeedItem(data) {
  613. const mainImage = data?.content?.images?.length > 0 ? data.content.images[0] : null;
  614. const comments = data?.cmts?.data?.length > 0
  615. ? data.cmts.data[0][Object.keys(data.cmts.data[0])[0]].data
  616. : null;
  617. return { ...data, mainImage, comments, showMore: true };
  618. }
  619. export const UnitFeed = ({ data, mode, DefaultUnit, SmallUnit, feed_type }) => {
  620. // Only the first item seeds the state; later edits arrive through setDatas.
  621. const [datas, setDatas] = useState(() => enrichFeedItem(data))
  622. const itemId = datas?.id
  623. // Comments preview shared through the object store: a reload after a socket
  624. // edit outlives the card, so the list cache doesn't bring back old comments.
  625. const cmtsKey = datas?.cmts?.unit === 'comments' && datas.cmts.object_id != null
  626. ? commentsObjectKey(datas.cmts.module, datas.cmts.object_id)
  627. : null
  628. const [cmts] = useObjectState(cmtsKey, datas?.cmts, cmtsFromProps)
  629. const shown = cmts === datas?.cmts ? datas : enrichFeedItem({ ...datas, cmts })
  630. // Reload the card when its post is edited. The edited item is enriched like
  631. // the initial one: without `mainImage` the card dropped its picture.
  632. useEffect(() => {
  633. if (itemId == null) return undefined
  634. return subscribeTimelineEdit(itemId, async () => {
  635. const generation = objectStateGeneration()
  636. const result = await fetcher(
  637. '/api.php?r=' +
  638. appSetting('urls', 'feed_item') +
  639. '{"params":{"browse":"id","value":' +
  640. itemId +
  641. '}}'
  642. )
  643. if (result.data) {
  644. // Counters, reactions and comments of the post for every widget,
  645. // unless the viewer logged out or switched profiles meanwhile.
  646. if (generation === objectStateGeneration()) storeObjectsFrom(result.data)
  647. setDatas(enrichFeedItem(result.data))
  648. }
  649. })
  650. }, [itemId])
  651. return mode == 'small' ? (
  652. <SmallUnit data={shown} />
  653. ) : (
  654. <DefaultUnit data={shown} />
  655. )
  656. }