Explorar el Código

Keep likes, reactions and comments state across cached lists (#5838)

* Keep likes, reactions and comments state across cached lists

A widget kept the result of its own action or a socket event in local
state, so going to another page and back showed the post as the list
cache had it: no reaction, no new comment, until F5.

NEO: app/lib/object-state keeps the state of social objects and comment
previews by object key, shared by every widget of the object (both halves
of a menu, the same post in two feeds). Writers are own action responses,
socket events and a post reloaded after a socket `edited`. Props win only
when their `ts` is newer, so a reloaded list updates older copies; without
`ts` (an older UNA) the shared state wins. Nothing is stored on the Next
server; the store is cleared with the page caches on logout.

UNA: elements of votes, reactions, scores, favorites, features, reports
and comments, their action responses (and so the `voted` socket payload)
and the comments list API carry `ts`, the server time the data was built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: bind the reactions vote listener to the latest update

applyUpdate now writes to the shared object state and is a new function
each render; the per-channel listener calls it through a ref.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
RomanL hace 1 semana
padre
commit
6e21fd9e65
Se han modificado 30 ficheros con 492 adiciones y 75 borrados
  1. 1 0
      inc/classes/BxDolFavorite.php
  2. 1 0
      inc/classes/BxDolFeature.php
  3. 1 0
      inc/classes/BxDolScore.php
  4. 1 0
      inc/classes/BxDolVote.php
  5. 1 0
      inc/classes/BxDolVoteReactions.php
  6. 17 3
      neo/packages/app/components/elements/feed-item-parts.js
  7. 3 0
      neo/packages/app/components/elements/feed-item.js
  8. 7 7
      neo/packages/app/components/forms/comments.js
  9. 7 7
      neo/packages/app/components/forms/messenger.js
  10. 3 1
      neo/packages/app/lib/cache/clear-page-cache.ts
  11. 191 0
      neo/packages/app/lib/object-state.ts
  12. 18 0
      neo/packages/app/lib/util/html-text.ts
  13. 9 2
      neo/packages/app/ui/molecules/objects/comments.js
  14. 3 5
      neo/packages/app/ui/molecules/objects/favorites.js
  15. 9 2
      neo/packages/app/ui/molecules/objects/features.js
  16. 9 4
      neo/packages/app/ui/molecules/objects/helpers.js
  17. 3 5
      neo/packages/app/ui/molecules/objects/likes.js
  18. 35 5
      neo/packages/app/ui/molecules/objects/reactions.tsx
  19. 36 21
      neo/packages/app/ui/molecules/objects/reports.js
  20. 3 5
      neo/packages/app/ui/molecules/objects/reposts.js
  21. 3 4
      neo/packages/app/ui/molecules/objects/scores.js
  22. 3 4
      neo/packages/app/ui/molecules/objects/stars.js
  23. 1 0
      template/scripts/BxBaseCmts.php
  24. 1 0
      template/scripts/BxBaseCmtsServices.php
  25. 1 0
      template/scripts/BxBaseFavorite.php
  26. 1 0
      template/scripts/BxBaseFeature.php
  27. 2 0
      template/scripts/BxBaseReport.php
  28. 1 0
      template/scripts/BxBaseScore.php
  29. 1 0
      template/scripts/BxBaseVote.php
  30. 120 0
      tests/integration/vote/ObjectApiTimestampTest.php

+ 1 - 0
inc/classes/BxDolFavorite.php

@@ -331,6 +331,7 @@ class BxDolFavorite extends BxDolObject
         ];
 
         $aResult['api'] = [
+            'ts' => microtime(true),
             'is_favorited' => $aResult['favorited'],
             'is_disabled' => $aResult['disabled'],
             'icon' => $aResult['label_icon'],

+ 1 - 0
inc/classes/BxDolFeature.php

@@ -220,6 +220,7 @@ class BxDolFeature extends BxDolObject
         ];
 
         $aResult['api'] = [
+            'ts' => microtime(true),
             'is_featured' => $aResult['featured'],
             'is_disabled' => $aResult['disabled'],
             'icon' => $aResult['label_icon'],

+ 1 - 0
inc/classes/BxDolScore.php

@@ -357,6 +357,7 @@ class BxDolScore extends BxDolObjectVote
         ];
 
         $aResult['api'] = [
+            'ts' => microtime(true),
             'performer_id' => $iAuthorId,
             'is_voted' => $aResult['voted'], //TODO: Remove after 14.0.0-RC3 is released.
             'is_disabled' => $aResult['disabled'], //TODO: Remove after 14.0.0-RC3 is released.

+ 1 - 0
inc/classes/BxDolVote.php

@@ -365,6 +365,7 @@ class BxDolVote extends BxDolObjectVote
         ];
 
         $aResult['api'] = [
+            'ts' => microtime(true),
             'performer_id' => $iAuthorId,
             'is_voted' => $aResult['voted'],
             'is_disabled' => $aResult['disabled'],

+ 1 - 0
inc/classes/BxDolVoteReactions.php

@@ -581,6 +581,7 @@ class BxDolVoteReactions extends BxTemplVote
         ];
 
         $aResult['api'] = [
+            'ts' => microtime(true),
             'performer_id' => $iAuthorId,
             'is_voted' => $bVotedAny,
             'is_disabled' => $bDisabled,

+ 17 - 3
neo/packages/app/components/elements/feed-item-parts.js

@@ -31,6 +31,7 @@ import AnimatedBlock from 'app/ui/atoms/animated-block'
 import { CommentsBrowseShort } from 'app/components/elements/comments-browse';
 import { Pressable } from 'app/design/view'
 import { subscribeTimelineEdit } from 'app/lib/timeline-edits'
+import { commentsObjectKey, storeObjectsFrom, useObjectState } from 'app/lib/object-state'
 import { getDataForMenu } from 'app/lib/util'
 import Form from 'app/components/elements/form'
 import useFetchForm from 'app/lib/hooks/use-fetch-form'
@@ -671,6 +672,8 @@ export function SmallUnit({ data }) {
     )
 }
 
+const cmtsFromProps = (cmts) => cmts
+
 /** Card fields derived from a timeline item (first image, first comments). */
 function enrichFeedItem(data) {
     const mainImage = data?.content?.images?.length > 0 ? data.content.images[0] : null;
@@ -686,6 +689,14 @@ export const UnitFeed = ({ data, mode, DefaultUnit, SmallUnit, feed_type }) => {
     const [datas, setDatas] = useState(enrichedData)
     const itemId = datas?.id
 
+    // Comments preview shared through the object store: a reload after a socket
+    // edit outlives the card, so the list cache doesn't bring back old comments.
+    const cmtsKey = datas?.cmts?.unit === 'comments' && datas.cmts.object_id != null
+        ? commentsObjectKey(datas.cmts.module, datas.cmts.object_id)
+        : null
+    const [cmts] = useObjectState(cmtsKey, datas?.cmts, cmtsFromProps)
+    const shown = cmts === datas?.cmts ? datas : enrichFeedItem({ ...datas, cmts })
+
     // Reload the card when its post is edited. The edited item is enriched like
     // the initial one: without `mainImage` the card dropped its picture.
     useEffect(() => {
@@ -698,14 +709,17 @@ export const UnitFeed = ({ data, mode, DefaultUnit, SmallUnit, feed_type }) => {
                 itemId +
                 '}}'
             )
-            if (result.data)
+            if (result.data) {
+                // Counters, reactions and comments of the post for every widget.
+                storeObjectsFrom(result.data)
                 setDatas(enrichFeedItem(result.data))
+            }
         })
     }, [itemId])
 
     return mode == 'small' ? (
-        <SmallUnit data={datas} />
+        <SmallUnit data={shown} />
     ) : (
-        <DefaultUnit data={datas} />
+        <DefaultUnit data={shown} />
     )
 }

+ 3 - 0
neo/packages/app/components/elements/feed-item.js

@@ -9,6 +9,7 @@ import { UnitImages } from 'app/lib/feed-items'
 import { PollItem } from 'app/components/elements/entity-poll';
 import { BlockWrapper } from 'app/components/block-wrapper'
 import { fetcher } from 'app/lib/fetcher'
+import { storeObjectsFrom } from 'app/lib/object-state'
 
 export default function ElementFeedItem({ data, isModal, blockWrapperProps }) {
     const eventId = data?.event?.id
@@ -26,6 +27,8 @@ export default function ElementFeedItem({ data, isModal, blockWrapperProps }) {
                 eventId +
                 '}}'
             )
+            // Counters and reactions of the post for every widget.
+            if (result.data) storeObjectsFrom(result.data)
             if (result.data?.content) setContent(result.data.content)
         })
     }, [eventId])

+ 7 - 7
neo/packages/app/components/forms/comments.js

@@ -3,7 +3,7 @@ import { useEffect, useMemo } from 'react'
 import { getFormFieldByData } from 'app/lib/form/form-helpers'
 import { Keyboard, Platform } from 'react-native'
 import { useFormContext } from 'react-hook-form'
-import { editorHtmlHasContent, removeEmptyTags, getStoragePickerKind } from 'app/lib/util'
+import { editorHtmlHasContent, editorHtmlIsSubmittable, hasFiles, removeEmptyTags, getStoragePickerKind } from 'app/lib/util'
 import { useCurrentUser } from 'app/context/user'
 import Profile from 'app/ui/molecules/profile/profile'
 import { FileButton } from 'app/lib/form/form-helpers'
@@ -45,10 +45,10 @@ export default function FormComments(props) {
         [rawEditorText]
     );
 
-    const hasSubmittableText = useMemo(() => {
-        const sanitized = removeEmptyTags(rawEditorText || '')
-        return sanitized.trim().length > 0
-    }, [rawEditorText]);
+    const hasSubmittableText = useMemo(
+        () => editorHtmlIsSubmittable(rawEditorText),
+        [rawEditorText]
+    );
 
     function setIsFocus() {
         // setIsExImage(false)
@@ -76,7 +76,7 @@ export default function FormComments(props) {
     const handleSubmitWithSanitization = () => {
         let sanitizedHtml = removeEmptyTags(formContext.getValues('cmt_text') || '');
         const images = formContext.getValues('cmt_image');
-        if (!sanitizedHtml.trim() && !images) return;
+        if (!editorHtmlIsSubmittable(sanitizedHtml) && !hasFiles(images)) return;
 
         formContext.setValue('cmt_text', sanitizedHtml, { shouldValidate: true, shouldDirty: true });
         props.handleSubmit();
@@ -191,7 +191,7 @@ export default function FormComments(props) {
                                                 'custom',
                                                 {
                                                     form_name: props.name,
-                                                    disabled: !hasSubmittableText && !imagesValue,
+                                                    disabled: !hasSubmittableText && !hasFiles(imagesValue),
                                                     className: 'w-full h-full',
                                                     notFullWidth: true,
                                                     noPadding: true,

+ 7 - 7
neo/packages/app/components/forms/messenger.js

@@ -3,7 +3,7 @@ import { useEffect, useMemo } from 'react'
 import { getFormFieldByData } from 'app/lib/form/form-helpers'
 import { Keyboard, Platform } from 'react-native'
 import { useFormContext } from 'react-hook-form'
-import { editorHtmlHasContent, removeEmptyTags } from 'app/lib/util'
+import { editorHtmlHasContent, editorHtmlIsSubmittable, hasFiles, removeEmptyTags } from 'app/lib/util'
 import { useCurrentUser } from 'app/context/user'
 import Profile from 'app/ui/molecules/profile/profile'
 import { FileButton } from 'app/lib/form/form-helpers'
@@ -31,10 +31,10 @@ export default function FormMessenger(props) {
         [rawEditorText]
     )
 
-    const hasSubmittableText = useMemo(() => {
-        const sanitized = removeEmptyTags(rawEditorText || '')
-        return sanitized.trim().length > 0
-    }, [rawEditorText])
+    const hasSubmittableText = useMemo(
+        () => editorHtmlIsSubmittable(rawEditorText),
+        [rawEditorText]
+    )
 
     const playSound = useSound('success')
 
@@ -54,7 +54,7 @@ export default function FormMessenger(props) {
     const handleSubmitWithSanitization = () => {
         let sanitizedHtml = removeEmptyTags(formContext.getValues('message') || '')
         const images = formContext.getValues('files')
-        if (!sanitizedHtml.trim() && !images) return
+        if (!editorHtmlIsSubmittable(sanitizedHtml) && !hasFiles(images)) return
 
         formContext.setValue('message', sanitizedHtml, { shouldValidate: true, shouldDirty: true })
         props.handleSubmit()
@@ -108,7 +108,7 @@ export default function FormMessenger(props) {
                             'custom',
                             {
                                 form_name: props.name,
-                                disabled: !hasSubmittableText && !imagesValue,
+                                disabled: !hasSubmittableText && !hasFiles(imagesValue),
                                 className: 'w-full h-full',
                                 notFullWidth: true,
                                 noPadding: true,

+ 3 - 1
neo/packages/app/lib/cache/clear-page-cache.ts

@@ -2,11 +2,13 @@ import { clearTabPageCache } from 'app/lib/cache/native-tab-page-cache'
 import { clearConductorCache } from 'app/lib/cache/native-conductor-cache'
 import { clearListScrollCache } from 'app/lib/cache/list-scroll-cache'
 import { clearWikiPageCache } from 'app/lib/cache/wiki-page-cache'
+import { clearObjectState } from 'app/lib/object-state'
 
-/** Drop all in-memory page / conductor / scroll / wiki caches (logout, session switch). */
+/** Drop all in-memory page / conductor / scroll / wiki / object caches (logout, session switch). */
 export function clearAllPageCache() {
     clearTabPageCache()
     clearConductorCache()
     clearListScrollCache()
     clearWikiPageCache()
+    clearObjectState()
 }

+ 191 - 0
neo/packages/app/lib/object-state.ts

@@ -0,0 +1,191 @@
+import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
+import { Platform } from 'react-native';
+
+/**
+ * Latest known state of social objects (likes, reactions, comments, reports, ...)
+ * and of comment previews, shared by every widget that shows the same object.
+ *
+ * A widget used to keep the result of its own action or of a socket event in
+ * local state, so the change was lost when the widget unmounted: lists keep
+ * their data in caches (TanStack, Next router, native page cache), and a list
+ * mounted again from a cache showed the object as it was when the list loaded.
+ * Here the change outlives the widget, and every copy of the object (the same
+ * post in two feeds, the action and the counter halves of one menu) shows it.
+ *
+ * Which is newer, the props or the shared state, is decided by `ts`: the server
+ * time (seconds, float) UNA puts in object data, action responses and socket
+ * events. Props win only when their `ts` is newer; without `ts` (an older UNA)
+ * the shared state wins. Values from UNA are absolute, never local increments,
+ * so the next action or event always brings an object up to date.
+ *
+ * Next server: one module instance serves every visitor, so nothing is stored
+ * there and server renders read props only.
+ */
+
+type Entry = {
+    /** Object data from a reload, shaped like the widget props. */
+    props?: any;
+    /** Widget state after an action or a socket event (wins over `props`). */
+    state?: any;
+    /** Server time of the data. */
+    ts?: number;
+};
+
+const MAX_ENTRIES = 500;
+
+const entries = new Map<string, Entry>();
+const listeners = new Map<string, Set<() => void>>();
+
+const isServer = Platform.OS === 'web' && typeof window === 'undefined';
+
+/** Server time of object data (`ts` from UNA), or undefined. */
+export function objectTs(data: any): number | undefined {
+    const ts = Number(data?.ts);
+    return Number.isFinite(ts) && ts > 0 ? ts : undefined;
+}
+
+/** True when `ts` is known to be newer than the entry. */
+function isNewer(ts: number | undefined, entry: Entry | undefined) {
+    return ts !== undefined && entry?.ts !== undefined && ts > entry.ts;
+}
+
+function notify(key: string) {
+    listeners.get(key)?.forEach((listener) => listener());
+}
+
+function put(key: string, entry: Entry) {
+    if (isServer || !key) return;
+    // Re-insert so Map order is least recently written first.
+    entries.delete(key);
+    entries.set(key, entry);
+    while (entries.size > MAX_ENTRIES) {
+        const oldest = entries.keys().next().value as string;
+        entries.delete(oldest);
+        notify(oldest);
+    }
+    notify(key);
+}
+
+function subscribeKey(key: string, listener: () => void) {
+    let set = listeners.get(key);
+    if (!set) {
+        set = new Set();
+        listeners.set(key, set);
+    }
+    set.add(listener);
+    return () => {
+        set.delete(listener);
+        if (set.size === 0) listeners.delete(key);
+    };
+}
+
+/** Key of a social object: `type-system-objectId`, as `objectKey` in molecules/objects. */
+export function socialObjectKey(type: string, system: string, objectId: string | number) {
+    return [type, String(system).replace(/_/g, '-'), objectId].join('-');
+}
+
+/** Key of a comments preview (`cmts` of a timeline item). */
+export function commentsObjectKey(module: string, objectId: string | number) {
+    return 'cmts-' + module + '-' + objectId;
+}
+
+/** Key of a node in an API response, or null when it isn't an object we track. */
+function nodeKey(node: any): string | null {
+    if (node.object_id == null || node.object_id === '') return null;
+    if (node.unit === 'comments' && typeof node.module === 'string') {
+        return commentsObjectKey(node.module, node.object_id);
+    }
+    if (typeof node.type === 'string' && typeof node.system === 'string' && node.system) {
+        return socialObjectKey(node.type, node.system, node.object_id);
+    }
+    return null;
+}
+
+/**
+ * Share the objects of a response that was loaded because it changed (a feed
+ * item reloaded after a socket `edited`), so the change outlives the card.
+ */
+export function storeObjectsFrom(data: unknown) {
+    if (isServer) return;
+    const stack: unknown[] = [data];
+    while (stack.length) {
+        const node = stack.pop();
+        if (!node || typeof node !== 'object') continue;
+        if (Array.isArray(node)) {
+            for (const child of node) stack.push(child);
+            continue;
+        }
+        const key = nodeKey(node);
+        if (key) {
+            const ts = objectTs(node);
+            const entry = entries.get(key);
+            if (!entry || isNewer(ts, entry) || ts === undefined) put(key, { props: node, ts });
+        }
+        for (const name in node) {
+            const child = (node as Record<string, unknown>)[name];
+            if (child && typeof child === 'object') stack.push(child);
+        }
+    }
+}
+
+/** Drop everything (logout, profile switch). */
+export function clearObjectState() {
+    const keys = [...entries.keys()];
+    entries.clear();
+    keys.forEach(notify);
+}
+
+const noopUnsubscribe = () => {};
+const emptySnapshot = (): Entry | undefined => undefined;
+
+type SetState<T> = (next: T | ((prev: T) => T)) => void;
+
+/**
+ * `useState` for a widget's server-side state, shared under `key`. `init` builds
+ * the state from props and should keep their `ts`; responses merged into the
+ * state bring their own `ts`. Without a key the state stays in the component.
+ */
+export function useObjectState<T>(key: string | null | undefined, props: any, init: (props: any) => T): [T, SetState<T>] {
+    const shared = !!key && !isServer;
+    const subscribe = useCallback(
+        (listener: () => void) => (shared ? subscribeKey(key as string, listener) : noopUnsubscribe),
+        [shared, key]
+    );
+    const getSnapshot = useCallback(() => (shared ? entries.get(key as string) : undefined), [shared, key]);
+    const entry = useSyncExternalStore(subscribe, getSnapshot, emptySnapshot);
+    const [local, setLocal] = useState<{ value: T } | null>(null);
+
+    const propsTs = objectTs(props);
+    const propsNewer = isNewer(propsTs, entry);
+    const current = propsNewer ? undefined : entry;
+
+    let value: T;
+    if (!shared && local) value = local.value;
+    else if (current?.state !== undefined) value = current.state as T;
+    else value = init(current?.props ?? props);
+
+    // Props newer than the shared state (a reloaded list): share them, so older
+    // copies of the object catch up too.
+    useEffect(() => {
+        if (shared && propsNewer) put(key as string, { props, ts: propsTs });
+    }, [shared, key, propsNewer, props, propsTs]);
+
+    const setValue = useCallback<SetState<T>>(
+        (next) => {
+            const apply = (prev: T) => (typeof next === 'function' ? (next as (prev: T) => T)(prev) : next);
+            if (shared) {
+                // Read the store, not the render: socket callbacks are bound once on mount.
+                const stored = entries.get(key as string);
+                const base = isNewer(propsTs, stored) ? undefined : stored;
+                const prev = base?.state !== undefined ? (base.state as T) : init(base?.props ?? props);
+                const state = apply(prev);
+                put(key as string, { state, ts: objectTs(state) ?? base?.ts ?? propsTs });
+            } else {
+                setLocal((prev) => ({ value: apply(prev ? prev.value : init(props)) }));
+            }
+        },
+        [shared, key, init, props, propsTs]
+    );
+
+    return [value, setValue];
+}

+ 18 - 0
neo/packages/app/lib/util/html-text.ts

@@ -320,3 +320,21 @@ export function stripTagsWithLinks(s: any, allowed: string[] = ['a', 'p', 'br'])
 export function removeEmptyTags(html: string): string {
     return html.replace(/<p>(?:\s|&nbsp;)*<\/p>/gi, '');
 }
+
+/**
+ * True when rich-editor HTML is worth sending: visible text or embedded media.
+ * Blank lines, `<html>` wrappers, `<br>` and `&nbsp;` alone are not.
+ */
+export function editorHtmlIsSubmittable(html: unknown): boolean {
+    if (!html || typeof html !== 'string') return false
+    if (/<(img|video|audio|iframe)\b/i.test(html)) return true
+    const text = String(stripTags(html))
+        .replace(/&nbsp;|&#160;| |​|/gi, ' ')
+        .trim()
+    return text.length > 0
+}
+
+/** True when a form files value holds at least one file (`[]` after a clear is empty). */
+export function hasFiles(value: unknown): boolean {
+    return Array.isArray(value) ? value.length > 0 : !!value
+}

+ 9 - 2
neo/packages/app/ui/molecules/objects/comments.js

@@ -1,6 +1,7 @@
 import { useState, useMemo, useCallback, useRef } from 'react';
 import { appSetting } from 'app/lib/util';
 import { useActionsData } from 'app/context/actions';
+import { useObjectState } from 'app/lib/object-state';
 import { View } from 'app/design/view';
 import { NeoButton } from 'app/design/controls';
 import Redirect from 'app/ui/atoms/redirect';
@@ -25,6 +26,12 @@ const handleDo = (redirectRef, link, callback, event) => {
     redirectRef.current.redirect(link);
 };
 
+const commentsStateFromProps = (props) => ({
+    action: props.action,
+    counter: props.counter,
+    ts: props.ts,
+});
+
 export default function ElementComments(props) {
     const redirectRef = useRef();
     const settings = useMemo(
@@ -67,8 +74,8 @@ export default function ElementComments(props) {
         [actionsDataState, showBoth, key]
     );
 
-    const action = props.action;
-    const counter = props.counter;
+    // The count changes when the post is reloaded after a socket edit.
+    const [{ action, counter }] = useObjectState(key, props, commentsStateFromProps);
 
     const handlePress = useCallback(
         (event) =>

+ 3 - 5
neo/packages/app/ui/molecules/objects/favorites.js

@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next';
 import { appSetting, FeedbackHaptics } from 'app/lib/util';
 import {
     objectKey,
+    objectStateFromProps,
     mergeState,
     objectRequest,
     openPerformedBy,
@@ -30,6 +31,7 @@ import {
     PerformedByModal,
 } from 'app/ui/molecules/objects/helpers';
 import { View } from 'app/design/view';
+import { useObjectState } from 'app/lib/object-state';
 
 export default function ElementFavorites(props) {
     const { t } = useTranslation();
@@ -37,7 +39,6 @@ export default function ElementFavorites(props) {
     const settings = appSetting('social_actions', 'favorite');
     const params = { ...settings, ...props.params };
     const action = props.action;
-    const counter = props.counter;
 
     const key = objectKey(props.type, props.system, props.object_id);
 
@@ -54,10 +55,7 @@ export default function ElementFavorites(props) {
         ...buildButtonProps(props),
     };
 
-    const [objectData, setObjectData] = useState({
-        ...action,
-        ...{ counter },
-    });
+    const [objectData, setObjectData] = useObjectState(key, props, objectStateFromProps);
     const [popupVisible, setPopupVisible] = useState(false);
     const [performedBy, setPerformedBy] = useState();
 

+ 9 - 2
neo/packages/app/ui/molecules/objects/features.js

@@ -10,11 +10,12 @@
  *   o                        — optional settings key for icons
  */
 
-import { useState } from 'react';
 
 import { appSetting, FeedbackHaptics } from 'app/lib/util';
+import { useObjectState } from 'app/lib/object-state';
 import {
     mergeState,
+    objectKey,
     objectRequest,
     buildButtonProps,
     pickActionButton,
@@ -24,6 +25,8 @@ import {
 import { components } from 'app/components/registry';
 import { NeoButton } from 'app/design/controls';
 
+const featureStateFromProps = (props) => ({ ...props.action, ts: props.ts });
+
 export default function ElementFeatures(props) {
     const settings = appSetting('social_actions', 'feature');
     const params = { ...settings, ...props.params };
@@ -40,7 +43,11 @@ export default function ElementFeatures(props) {
     const showCombined = params?.show_combined === true;
     const buttonProps = { ...buildButtonProps(props) };
 
-    const [objectData, setObjectData] = useState(action);
+    const [objectData, setObjectData] = useObjectState(
+        objectKey(props.type, props.system, props.object_id),
+        props,
+        featureStateFromProps
+    );
 
     const object_params = {
         service: 'TemplFeatureServices',

+ 9 - 4
neo/packages/app/ui/molecules/objects/helpers.js

@@ -7,6 +7,7 @@ import { useEffect } from 'react';
 import { fetcher } from 'app/lib/fetcher';
 import { FeedbackHaptics } from 'app/lib/util';
 import { subscribe } from 'app/ui/atoms/socket';
+import { socialObjectKey } from 'app/lib/object-state';
 import Profile from 'app/ui/molecules/profile/profile';
 import { View, Row } from 'app/design/view';
 import {
@@ -25,9 +26,13 @@ import {
 
 /** Stable DOM/React key: type-system-objectId[-extra] */
 export function objectKey(type, system, objectId, extra) {
-    const parts = [type, system.replace(/_/g, '-'), objectId];
-    if (extra) parts.push(extra);
-    return parts.join('-');
+    const key = socialObjectKey(type, system, objectId);
+    return extra ? key + '-' + extra : key;
+}
+
+/** Shared state of a social object widget: its action fields, `counter` and server `ts`. */
+export function objectStateFromProps(props) {
+    return { ...props.action, counter: props.counter, ts: props.ts };
 }
 
 export function mergeState(prev, next) {
@@ -156,7 +161,7 @@ export function useVotedSubscription({ channel, currentUserId, onUpdate }) {
             const next =
                 payload.api.performer_id == currentUserId
                     ? payload.api
-                    : { counter: payload.api.counter };
+                    : { counter: payload.api.counter, ts: payload.api.ts };
             onUpdate(next);
         });
         return unsubscribe;

+ 3 - 5
neo/packages/app/ui/molecules/objects/likes.js

@@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
 import { appSetting, FeedbackHaptics } from 'app/lib/util';
 import {
     objectKey,
+    objectStateFromProps,
     mergeState,
     objectRequest,
     openPerformedBy,
@@ -31,6 +32,7 @@ import {
     PerformedByModal,
 } from 'app/ui/molecules/objects/helpers';
 import { useCurrentUser } from 'app/context/user';
+import { useObjectState } from 'app/lib/object-state';
 import { View } from 'app/design/view';
 
 export default function ElementLikes(props) {
@@ -40,7 +42,6 @@ export default function ElementLikes(props) {
     const settings = appSetting('social_actions', 'like');
     const params = { ...settings, ...props.params };
     const action = props.action;
-    const counter = props.counter;
 
     const key = objectKey(props.type, props.system, props.object_id);
     const icon = settings[props.system]?.icon
@@ -51,10 +52,7 @@ export default function ElementLikes(props) {
         resolveDisplayFlags(params, props?.displayType);
     const buttonProps = buildButtonProps(props);
 
-    const [objectData, setObjectData] = useState({
-        ...action,
-        ...{ counter },
-    });
+    const [objectData, setObjectData] = useObjectState(key, props, objectStateFromProps);
     const [popupVisible, setPopupVisible] = useState(false);
     const [performedBy, setPerformedBy] = useState();
 

+ 35 - 5
neo/packages/app/ui/molecules/objects/reactions.tsx

@@ -45,6 +45,7 @@ import {
     ActionMenuLayout as ActionMenuLayoutJs,
 } from 'app/ui/molecules/objects/helpers';
 import { useCurrentUser } from 'app/context/user';
+import { objectTs, useObjectState } from 'app/lib/object-state';
 import { useWindowSize } from 'app/context/measure';
 import { useSound } from 'app/lib/hooks/use-sound';
 import { lazyComponent } from 'app/lib/lazy-component';
@@ -106,6 +107,7 @@ type ReactionAction = {
 
 /** Live state from the vote response and the socket; overrides the props. */
 type ReactionState = {
+    ts?: number;
     is_voted?: boolean;
     is_disabled?: boolean;
     reaction?: string;
@@ -132,9 +134,25 @@ type ReactionsProps = {
     params?: Record<string, any>;
     displayType?: string;
     primary?: boolean;
+    /** Server time of the data (UNA `ts`). */
+    ts?: number;
     [key: string]: any;
 };
 
+type ReactionsObjectState = {
+    action?: ReactionAction;
+    counter?: ReactionsProps['counter'];
+    live: ReactionState;
+    ts?: number;
+};
+
+const reactionsStateFromProps = (props: ReactionsProps): ReactionsObjectState => ({
+    action: props.action,
+    counter: props.counter,
+    live: {},
+    ts: props.ts,
+});
+
 const MODES: readonly ReactionMode[] = ['solo', 'few', 'many'];
 const PER_USER_MAX = 5;
 const SMILE_PLUS = 'SmilePlus';
@@ -843,10 +861,17 @@ export default function ElementReactions(props: ReactionsProps) {
 
     const settings = appSetting('social_actions', 'reaction');
     const params = { ...settings, ...props.params };
-    const action = props.action;
-    const counter = props.counter;
-
     const key = objectKey(props.type, props.system, props.object_id);
+
+    // Shared with every widget of this object, so own and socket updates outlive
+    // the widget. `live` holds them over the props' action and counter.
+    const [objectState, setObjectState] = useObjectState(key, props, reactionsStateFromProps);
+    const { action, counter, live: state } = objectState;
+    const setState = (update: (prev: ReactionState) => ReactionState) =>
+        setObjectState((prev) => {
+            const live = update(prev.live);
+            return { ...prev, live, ts: objectTs(live) ?? prev.ts };
+        });
     const channel = votedChannel(props.system, props.type, props.object_id);
 
     const settingsItems = settings[props.system]?.items ? settings[props.system].items : params.items;
@@ -864,7 +889,6 @@ export default function ElementReactions(props: ReactionsProps) {
 
     const buttonProps = buildButtonProps(props);
 
-    const [state, setState] = useState<ReactionState>({});
     // A failed request undoes its optimistic change unless the viewer's reactions changed again since (another tap,
     // their vote from another instance or device). Counts from the server (another member's vote over the socket)
     // don't block the undo; they replace the optimistic counts instead.
@@ -1047,8 +1071,14 @@ export default function ElementReactions(props: ReactionsProps) {
         onUpdate: applyUpdate,
     });
 
+    // The listener stays bound per channel; it calls the latest applyUpdate.
+    const applyUpdateRef = useRef(applyUpdate);
+    useEffect(() => {
+        applyUpdateRef.current = applyUpdate;
+    });
+
     useEffect(() => {
-        const listener = applyUpdate;
+        const listener = (next: ReactionState) => applyUpdateRef.current(next);
         const listeners = voteListeners.get(channel) ?? new Set();
         listeners.add(listener);
         voteListeners.set(channel, listeners);

+ 36 - 21
neo/packages/app/ui/molecules/objects/reports.js

@@ -17,8 +17,10 @@ import { useTranslation } from 'react-i18next';
 
 import { appSetting } from 'app/lib/util';
 import {
+    objectKey,
     objectRequest,
 } from 'app/ui/molecules/objects/helpers';
+import { useObjectState } from 'app/lib/object-state';
 import emitter, { EVENTS } from 'app/context/emitter';
 import Form from 'app/components/form';
 import { components } from 'app/components/registry';
@@ -139,12 +141,24 @@ function resolveActionPayload(response) {
 // Main
 // ---------------------------------------------------------------------------
 
+function reportStateFromProps(props) {
+    const action = props?.action ?? {};
+    return {
+        action: {
+            is_undo: action.is_undo === true,
+            is_reported: action.is_reported === true,
+            is_disabled: action.is_disabled === true,
+            title: action.title ?? '',
+        },
+        counter: { count: Number(props?.counter?.count ?? 0) },
+        ts: props?.ts,
+    };
+}
+
 export default function ElementReports(props) {
     const { t } = useTranslation();
     const DropdownMenuItem = components['menu-item']['dropdown'];
 
-    const initialAction = props?.action ?? {};
-    const initialCounter = props?.counter ?? {};
     const system = props?.system;
     const objectId = props?.object_id;
     const isTextMode = props?.mode === 'text';
@@ -157,15 +171,13 @@ export default function ElementReports(props) {
     const settings = appSetting('social_actions', 'report') || {};
     const params = { ...settings, ...(props?.params || {}) };
 
-    const [actionState, setActionState] = useState({
-        is_undo: initialAction?.is_undo === true,
-        is_reported: initialAction?.is_reported === true,
-        is_disabled: initialAction?.is_disabled === true,
-        title: initialAction?.title ?? '',
-    });
-    const [counterState, setCounterState] = useState({
-        count: Number(initialCounter?.count ?? 0),
-    });
+    const [reportState, setReportState] = useObjectState(
+        system && objectId ? objectKey(props.type, system, objectId) : null,
+        props,
+        reportStateFromProps
+    );
+    const actionState = reportState.action;
+    const counterState = reportState.counter;
     const [reportForm, setReportForm] = useState(null);
     const [reportFormVisible, setReportFormVisible] = useState(false);
     const [performedByVisible, setPerformedByVisible] = useState(false);
@@ -204,22 +216,25 @@ export default function ElementReports(props) {
         (payload) => {
             if (!payload) return;
 
-            setActionState((prev) => ({
-                ...prev,
-                is_reported: payload?.is_reported ?? prev.is_reported,
-                is_disabled: payload?.is_disabled ?? prev.is_disabled,
-                title: payload?.title ?? prev.title,
+            setReportState((prev) => ({
+                action: {
+                    ...prev.action,
+                    is_reported: payload?.is_reported ?? prev.action.is_reported,
+                    is_disabled: payload?.is_disabled ?? prev.action.is_disabled,
+                    title: payload?.title ?? prev.action.title,
+                },
+                counter:
+                    payload?.counter?.count !== undefined
+                        ? { count: Number(payload.counter.count || 0) }
+                        : prev.counter,
+                ts: payload?.ts ?? prev.ts,
             }));
 
-            if (payload?.counter?.count !== undefined) {
-                setCounterState({ count: Number(payload.counter.count || 0) });
-            }
-
             if (typeof props?.onChangeTitle === 'function' && payload?.title) {
                 props.onChangeTitle(payload.title);
             }
         },
-        [props]
+        [props, setReportState]
     );
 
     const closeReportForm = useCallback(() => {

+ 3 - 5
neo/packages/app/ui/molecules/objects/reposts.js

@@ -11,12 +11,13 @@
  *   primary                  — button variant=primary
  */
 
-import { useState } from 'react';
 
 import { appSetting, FeedbackHaptics } from 'app/lib/util';
 import { fetcher } from 'app/lib/fetcher';
+import { useObjectState } from 'app/lib/object-state';
 import {
     objectKey,
+    objectStateFromProps,
     mergeState,
     resolveDisplayFlags,
     buildButtonProps,
@@ -64,10 +65,7 @@ export default function ElementReposts(props) {
     const buttonProps = buildButtonProps(props);
     delete buttonProps.ring;
 
-    const [objectData, setObjectData] = useState({
-        ...action,
-        ...{ counter },
-    });
+    const [objectData, setObjectData] = useObjectState(key, props, objectStateFromProps);
 
     const doRepost = (event) => {
         event.preventDefault();

+ 3 - 4
neo/packages/app/ui/molecules/objects/scores.js

@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next';
 import { appSetting, FeedbackHaptics } from 'app/lib/util';
 import {
     objectKey,
+    objectStateFromProps,
     mergeState,
     objectRequest,
     openPerformedBy,
@@ -33,6 +34,7 @@ import {
     PerformedByModal,
 } from 'app/ui/molecules/objects/helpers';
 import { useCurrentUser } from 'app/context/user';
+import { useObjectState } from 'app/lib/object-state';
 import { Icon } from 'app/ui/atoms/icon';
 import Profile from 'app/ui/molecules/profile/profile';
 import { Text } from 'app/design/typography';
@@ -79,10 +81,7 @@ export default function ElementScore(props) {
         resolveDisplayFlags(params, props.displayType);
     const buttonProps = buildButtonProps(props);
 
-    const [objectData, setObjectData] = useState({
-        ...action,
-        ...{ counter },
-    });
+    const [objectData, setObjectData] = useObjectState(key, props, objectStateFromProps);
     const [counterClass, setCounterClass] = useState('');
     const [popupVisible, setPopupVisible] = useState(false);
     const [performedBy, setPerformedBy] = useState();

+ 3 - 4
neo/packages/app/ui/molecules/objects/stars.js

@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next';
 import { appSetting, FeedbackHaptics } from 'app/lib/util';
 import {
     objectKey,
+    objectStateFromProps,
     mergeState,
     objectRequest,
     openPerformedBy,
@@ -32,6 +33,7 @@ import {
     PerformedByModal,
 } from 'app/ui/molecules/objects/helpers';
 import { useCurrentUser } from 'app/context/user';
+import { useObjectState } from 'app/lib/object-state';
 import DropdownPopup from 'app/ui/atoms/dropdown-popup';
 import { StarsAction } from 'app/ui/atoms/stars';
 import { View, Pressable } from 'app/design/view';
@@ -56,10 +58,7 @@ export default function ElementStars(props) {
         resolveDisplayFlags(params, props?.displayType);
     const buttonProps = buildButtonProps(props);
 
-    const [objectData, setObjectData] = useState({
-        ...action,
-        ...{ counter },
-    });
+    const [objectData, setObjectData] = useObjectState(key, props, objectStateFromProps);
     const [popupVisibleDo, setPopupVisibleDo] = useState(false);
     const [popupVisiblePb, setPopupVisiblePb] = useState(false);
     const [performedBy, setPerformedBy] = useState();

+ 1 - 0
template/scripts/BxBaseCmts.php

@@ -823,6 +823,7 @@ class BxBaseCmts extends BxDolCmts
             'type' => $this->_sType,
             'system' => $this->_sSystem,
             'object_id' => $this->_iId,
+            'ts' => microtime(true),
             'params' => array_intersect_key($aParams, array_flip($this->_aElementParamsApi)),
             'action' => $aDoComment,
             'counter' => $aCounter

+ 1 - 0
template/scripts/BxBaseCmtsServices.php

@@ -724,6 +724,7 @@ class BxBaseCmtsServices extends BxDol
             'view' => $aDp['type'],
             'module' => $oCmts->getSystemName(), 
             'object_id' => $oCmts->getId(),
+            'ts' => microtime(true),
             'max_level' => $oCmts->getSystemInfo()['number_of_levels'],
             'highlighted' => 0,
             'data' => $aCmtsRv,

+ 1 - 0
template/scripts/BxBaseFavorite.php

@@ -215,6 +215,7 @@ class BxBaseFavorite extends BxDolFavorite
             'type' => $this->_sType,
             'system' => $this->_sSystem,
             'object_id' => $this->_iId,
+            'ts' => microtime(true),
             'params' => array_intersect_key($aParams, array_flip($this->_aElementParamsApi)),
             'action' => $aDoFavorite,
             'counter' => $aCounter

+ 1 - 0
template/scripts/BxBaseFeature.php

@@ -152,6 +152,7 @@ class BxBaseFeature extends BxDolFeature
             'type' => $this->_sType,
             'system' => $this->_sSystem,
             'object_id' => $this->_iId,
+            'ts' => microtime(true),
             'params' => array_intersect_key($aParams, array_flip($this->_aElementParamsApi)),
             'action' => $aDoFeature
         ];

+ 2 - 0
template/scripts/BxBaseReport.php

@@ -303,6 +303,7 @@ class BxBaseReport extends BxDolReport
             'type' => 'reports',
             'system' => $this->_sSystem,
             'object_id' => $this->_iId,
+            'ts' => microtime(true),
             'params' => array_intersect_key($aParams, array_flip($this->_aElementParamsApi)),
             'action' => $aDoReport,
             'counter' => $aCounter
@@ -610,6 +611,7 @@ class BxBaseReport extends BxDolReport
         );
 
         $aResult['api'] = [
+            'ts' => microtime(true),
             'performer_id' => $iAuthorId,
             'is_reported' => $aResult['reported'],
             'is_disabled' => $aResult['disabled'],

+ 1 - 0
template/scripts/BxBaseScore.php

@@ -423,6 +423,7 @@ class BxBaseScore extends BxDolScore
             'type' => 'scores',
             'system' => $this->_sSystem,
             'object_id' => $this->_iId,
+            'ts' => microtime(true),
             'params' => array_intersect_key($aParams, array_flip($this->_aElementParamsApi)),
             'action' => [
                 BX_DOL_SCORE_DO_UP => $aDoVoteUp,

+ 1 - 0
template/scripts/BxBaseVote.php

@@ -212,6 +212,7 @@ class BxBaseVote extends BxDolVote
             'type' => $this->_sType,
             'system' => $this->_sSystem,
             'object_id' => $this->_iId,
+            'ts' => microtime(true),
             'params' => array_intersect_key($aParams, array_flip($this->_aElementParamsApi)),
             'action' => $aDoVote,
             'counter' => $aCounter

+ 120 - 0
tests/integration/vote/ObjectApiTimestampTest.php

@@ -0,0 +1,120 @@
+<?php
+
+use PHPUnit\Framework\Attributes\Group;
+
+/**
+ * Object data for the app (element, action response, socket payload, comments
+ * list) carries `ts`, the server time it was built. NEO compares it to tell
+ * whether props or its shared object state is newer.
+ */
+#[Group('integration')]
+class ObjectApiTimestampTest extends BxDolIntegrationTestCase
+{
+    protected $_aGetBackup;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->_aGetBackup = $_GET;
+    }
+
+    protected function tearDown(): void
+    {
+        $_GET = $this->_aGetBackup ?? [];
+        parent::tearDown();
+    }
+
+    public function testLikesApiHasTs(): void
+    {
+        $oVote = BxDolVote::getObjectInstance('sys_cmts', 1, false);
+        $this->assertInstanceOf(BxDolVote::class, $oVote);
+
+        $fBefore = microtime(true);
+        $aResult = $this->bxCallProtected($oVote, '_returnVoteData', 1, 0, '', ['value' => 1], false);
+
+        $this->assertIsFloat($aResult['api']['ts']);
+        $this->assertGreaterThanOrEqual($fBefore, $aResult['api']['ts']);
+    }
+
+    public function testReactionsApiHasTs(): void
+    {
+        $oVote = BxDolVote::getObjectInstance('sys_cmts_reactions', 1, false);
+        $this->assertInstanceOf(BxDolVoteReactions::class, $oVote);
+
+        $fBefore = microtime(true);
+        $aResult = $this->bxCallProtected($oVote, '_returnVoteData', 1, 0, '', ['value' => 1, 'reaction' => $oVote->getDefault()], false);
+
+        $this->assertIsFloat($aResult['api']['ts']);
+        $this->assertGreaterThanOrEqual($fBefore, $aResult['api']['ts']);
+    }
+
+    public function testVoteSocketPayloadKeepsTs(): void
+    {
+        $oVote = BxDolVote::getObjectInstance('sys_cmts_reactions', 1, false);
+        $aResult = $this->bxCallProtected($oVote, '_returnVoteData', 1, 0, '', ['value' => 1, 'reaction' => $oVote->getDefault()], false);
+        $aSocket = $this->bxCallProtected($oVote, '_returnVoteDataForSocket', $aResult);
+
+        $this->assertSame($aResult['api']['ts'], $aSocket['api']['ts']);
+    }
+
+    public function testReportApiHasTs(): void
+    {
+        $oReport = BxDolReport::getObjectInstance('sys_cmts', 1, false);
+        $this->assertInstanceOf(BxDolReport::class, $oReport);
+
+        $fBefore = microtime(true);
+        $aResult = $this->bxCallProtected($oReport, '_returnReportData', 1, 0, 0, [], false);
+
+        $this->assertIsFloat($aResult['api']['ts']);
+        $this->assertGreaterThanOrEqual($fBefore, $aResult['api']['ts']);
+    }
+
+    public function testElementApiHasTs(): void
+    {
+        $this->bxLoginAsAdmin();
+        $_GET['api'] = 1;
+
+        $oVote = BxDolVote::getObjectInstance('sys_cmts', 1);
+        $this->assertInstanceOf(BxDolVote::class, $oVote);
+
+        $fBefore = microtime(true);
+        $aElement = $oVote->getElementAPI();
+        if (!is_array($aElement) || empty($aElement['object_id']))
+            $this->markTestSkipped('The like element is hidden for the admin.');
+
+        $this->assertSame(1, (int)$aElement['object_id']);
+        $this->assertIsFloat($aElement['ts']);
+        $this->assertGreaterThanOrEqual($fBefore, $aElement['ts']);
+    }
+
+    public function testCommentsApiHasTs(): void
+    {
+        if (!BxDolModuleQuery::getInstance()->isEnabledByName('bx_timeline'))
+            $this->markTestSkipped('Timeline is not installed.');
+
+        $iEventId = (int)BxDolDb::getInstance()->getOne("SELECT `id` FROM `bx_timeline_events` WHERE `active` = 1 ORDER BY `id` DESC LIMIT 1");
+        if (!$iEventId)
+            $this->markTestSkipped('There are no timeline events.');
+
+        $this->bxLoginAsAdmin();
+        $_GET['api'] = 1;
+
+        $oCmts = BxDolCmts::getObjectInstance('bx_timeline', $iEventId);
+        if (!$oCmts || !$oCmts->isEnabled())
+            $this->markTestSkipped('Timeline comments are not enabled.');
+
+        $aData = bx_srv('system', 'get_comments_api', [$oCmts, [
+            'mode' => 'feed',
+            'order_way' => 'desc',
+            'start_from' => 0,
+            'per_view' => 1,
+            'is_form' => false,
+        ]], 'TemplCmtsServices');
+        if (!is_array($aData))
+            $this->markTestSkipped('The admin may not view timeline comments.');
+
+        $this->assertSame('comments', $aData['unit']);
+        $this->assertSame($iEventId, (int)$aData['object_id']);
+        $this->assertIsFloat($aData['ts']);
+    }
+}