Forráskód Böngészése

Neo: decode HTML entities in entity titles (#5889)

UNA sends titles and names HTML-escaped (`Rock & Roll`). Text decoded
its children, but headings, image alt, link and button labels, native
SwiftUI text and the Next metadata printed the escaped string, so an entity
text block's title, the browser tab and the iOS list card label read
"&".

Add decodeTextNode next to decodeText and use it at the shared sinks:
H1-H6 and H1C, the Image atom's alt, Link's accessible label, NeoButton's
text props (JS and Expo UI), Letter and Profile initials, generateMetadata
title and description, the entity text block's editable title, the native
More sheet name, and the title-based accessibility labels set directly on
View/Pressable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko 5 napja
szülő
commit
87342aac32

+ 4 - 2
neo/apps/next/app/[...path]/page.js

@@ -1,4 +1,5 @@
 import { UNA_URL, UNA_API_KEY, MULTITENANT, PREVIEW_DOMAIN, appSetting } from 'app/config';
+import { decodeTextNode } from 'app/lib/util/html-text';
 import { cache } from 'react'
 import Root from 'app/root-client'
 import { Suspense } from 'react'
@@ -133,8 +134,9 @@ export const viewport = {
 export async function generateMetadata(props) {
     
     const data = await loadPage(props);
-    const description = data?.data?.description || SITE_TITLE;
-    const name = data?.data?.title || SITE_TITLE;
+    // UNA sends titles HTML-escaped; Next escapes metadata again, so the tab would read `&amp;`.
+    const description = decodeTextNode(data?.data?.description || SITE_TITLE);
+    const name = decodeTextNode(data?.data?.title || SITE_TITLE);
     const image = data?.data?.image;
     const isClientProject = UNA_URL != 'https://api.neo.so';
 

+ 5 - 3
neo/packages/app/components/elements/entity-text.js

@@ -8,7 +8,7 @@ import Html from 'app/ui/atoms/html';
 import { splitInlineMedia } from 'app/lib/editor/inline-video';
 import { Text, H1C } from 'app/design/typography';
 import { Input } from 'app/design/controls';
-import { appSetting, clearLinks, getYouTubeVideoId, cn } from 'app/lib/util'
+import { appSetting, clearLinks, getYouTubeVideoId, cn, decodeText } from 'app/lib/util'
 import { useEditableRequest } from 'app/lib/form/form-helpers'
 import { ContentMore } from 'app/ui/molecules/content/content-more';
 import EntityAttachments from './entity-attachments';
@@ -60,6 +60,8 @@ function EditableTitleField({
     inputClassName,
     children,
 }) {
+    // `initialValue` is plain text: callers decode UNA's HTML-escaped title,
+    // or an edit would save a literal `&amp;`.
     const { value, commit } = useEditableRequest({
         initialValue: initialValue ?? '',
         requestUrl,
@@ -205,7 +207,7 @@ function Small({ data, editable, afterTitle }) {
                 <EditableTitleField
                     editable={editable}
                     requestUrl={requestUrl}
-                    initialValue={data.entry_title}
+                    initialValue={decodeText(data.entry_title)}
                     inputClassName="font-bold text-popover-foreground text-base sm:text-xl"
                 >
                     {(title) => (
@@ -293,7 +295,7 @@ function Default({ data, showPad, sidebar, block, blockWrapperProps, editable, a
                     <EditableTitleField
                         editable={editable}
                         requestUrl={requestUrl}
-                        initialValue={data.entry_title}
+                        initialValue={decodeText(data.entry_title)}
                         inputClassName="text-2xl sm:text-3xl font-bold tracking-tight text-foreground font-title"
                     >
                         {(title) => (

+ 2 - 2
neo/packages/app/components/elements/tasks/tasklists-panel.js

@@ -6,7 +6,7 @@ import { Text } from 'app/design/typography';
 import { Card } from 'app/ui/molecules/page/card';
 import { Icon } from 'app/ui/atoms/icon';
 import { useIconClassColor } from 'app/ui/atoms/icon-class-color';
-import { cn } from 'app/lib/util';
+import { cn, decodeTextNode } from 'app/lib/util';
 import { TaskActionsDropdown } from './actions';
 import { isSameListId, isTasklistGroup, listIdOrNull, tasklistProgress } from './helpers';
 
@@ -127,7 +127,7 @@ function TasklistGroup({ group, selectedId, selectedContextId, onSelect, actionH
                     onPress={() => onSelect(group)}
                     className="min-w-0 flex-1 flex-row items-center gap-2 py-2 px-2 web:hover:bg-muted/50 rounded-lg"
                     accessibilityRole="button"
-                    accessibilityLabel={group.title}
+                    accessibilityLabel={decodeTextNode(group.title)}
                 >
                     <Text className="min-w-0 flex-1 text-sm font-semibold text-foreground" numberOfLines={1}>
                         {group.title}

+ 2 - 1
neo/packages/app/components/elements/wiki/nav.js

@@ -1,6 +1,7 @@
 import { useEffect, useState } from 'react'
 import { View, Row, Pressable } from 'app/design/view'
 import { Text } from 'app/design/typography'
+import { decodeTextNode } from 'app/lib/util'
 import { Icon } from 'app/ui/atoms/icon'
 import { components } from 'app/components/registry'
 import { getConductorDepthClassName } from 'app/ui/molecules/sections/conductor/helpers'
@@ -54,7 +55,7 @@ export function WikiBreadcrumb({ items, onNavigate, compact = false }) {
                             onPress={() => onNavigate(item.path)}
                             className="min-w-0 rounded-md px-1 py-0.5 web:hover:bg-muted/50"
                             accessibilityRole="link"
-                            accessibilityLabel={item.title}
+                            accessibilityLabel={decodeTextNode(item.title)}
                         >
                             <Text
                                 numberOfLines={1}

+ 3 - 2
neo/packages/app/components/nav/tabs/expoui-tabs.js

@@ -10,7 +10,7 @@ import { useEffect, useMemo, useState } from 'react';
 import { PixelRatio, Platform, Pressable, useWindowDimensions } from 'react-native';
 import { Image } from 'expo-image';
 import { View } from 'app/design/view';
-import { androidTabBarHeight, appSetting, findIconFromRemote, isAndroid, isIos, IOS_TAB_BAR_MARGIN } from 'app/lib/util';
+import { androidTabBarHeight, appSetting, decodeTextNode, findIconFromRemote, isAndroid, isIos, IOS_TAB_BAR_MARGIN } from 'app/lib/util';
 import { useTranslation } from 'react-i18next';
 import { TabSlideHost } from 'app/components/nav/tab-slide';
 import { playTabFeedback } from 'app/components/nav/tab-feedback';
@@ -547,7 +547,8 @@ export default function ExpoUITabNavigator({
                     onSelect={handleOverflowSelect}
                     profile={sheetHeader ? {
                         item: sheetHeader,
-                        name: currentUser?.display_name,
+                        // SwiftUI / Compose text: decode UNA's escaped name here.
+                        name: decodeTextNode(currentUser?.display_name),
                         avatar: currentUser?.avatar ? avatarImageUri(currentUser.avatar, SHEET_AVATAR_PT) : null,
                     } : null}
                     onAgentPress={agentData ? toggleAgent : undefined}

+ 2 - 2
neo/packages/app/components/units/content-list/bx-tasks.js

@@ -5,7 +5,7 @@ import { Modal } from 'app/design/controls';
 import { Icon } from 'app/ui/atoms/icon';
 import Menu from 'app/components/menu';
 import { BlockByDataInt as BlockByData } from 'app/components/block';
-import { appSetting, cn } from 'app/lib/util';
+import { appSetting, cn, decodeTextNode } from 'app/lib/util';
 import { iconForTaskStatus, iconForTaskPriority, normalizeTaskLabel } from 'app/lib/tasks-meta';
 import { UnitTitle } from 'app/components/units/helpers';
 import { runTaskAction } from 'app/components/elements/tasks/actions';
@@ -171,7 +171,7 @@ export default function Unit({ data, module }) {
         <Pressable
             onPress={openTask}
             accessibilityRole="button"
-            accessibilityLabel={data.title}
+            accessibilityLabel={decodeTextNode(data.title)}
             className={cn(appSetting('tasks', 'item_wrapper'), 'w-full group/task')}
         >
             {formBlock ? (

+ 27 - 1
neo/packages/app/design/controls/neo-button/neo-button.tsx

@@ -56,6 +56,7 @@ import {
     sanitazeUrl,
     isExternalUrl,
     openExternalLink,
+    decodeTextNode,
 } from 'app/lib/util';
 import {
     useResolvedNeoButton,
@@ -430,7 +431,32 @@ function canRenderExpoUIButton(props: NeoButtonProps, native: { config: Record<s
 
 /* -------------------------- main Button component ------------------------- */
 
-export const NeoButton = (props: NeoButtonProps) => {
+const TEXT_PROPS = ['label', 'title', 'loadingLabel', 'accessibilityLabel', 'alt', 'tooltip'] as const;
+
+/**
+ * UNA sends titles HTML-escaped (`&amp;`). `Text` decodes the JS label, but the
+ * same strings also become accessible names, tooltips and SwiftUI / Compose
+ * labels, so decode them once here. Props without an entity come back as is.
+ */
+const decodeTextProps = (props: NeoButtonProps): NeoButtonProps => {
+    const hasEntity = TEXT_PROPS.some((key) => {
+        const value = props[key];
+        return typeof value === 'string' && value.includes('&');
+    });
+    if (!hasEntity) return props;
+    return {
+        ...props,
+        label: decodeTextNode(props.label),
+        title: decodeTextNode(props.title),
+        loadingLabel: decodeTextNode(props.loadingLabel),
+        accessibilityLabel: decodeTextNode(props.accessibilityLabel),
+        alt: decodeTextNode(props.alt),
+        tooltip: decodeTextNode(props.tooltip),
+    };
+};
+
+export const NeoButton = (rawProps: NeoButtonProps) => {
+    const props = decodeTextProps(rawProps);
     const native = useNeoButtonExpoUI(props);
     const titleHidden = useTitleHidden(props.showTitleFromSize);
 

+ 9 - 9
neo/packages/app/design/typography.tsx

@@ -1,6 +1,6 @@
 import { Text as NativeText, Platform, StyleSheet, type TextProps as RNTextProps } from 'react-native'
 import type { ReactNode } from 'react'
-import { decodeText } from 'app/lib/util'
+import { decodeTextNode } from 'app/lib/util'
 import { appSetting } from 'app/lib/util'
 const isWeb = Platform.OS === 'web'
 // Use DOM element on web so Tailwind classes apply without NativeWind babel
@@ -132,7 +132,7 @@ export const Text = ({
     const baseClassName = className || '  '
     let finalClassName = `${baseClassName} ${fontFamily || 'font-main'}`.trim()
     const spreadProps = isWeb ? sanitizeWebTextProps(rest) : rest
-    const content = typeof children === "string" ? decodeText(children) : children;
+    const content = decodeTextNode(children);
 
     if (numberOfLines && isWeb)
         finalClassName += ' line-clamp-' + numberOfLines;
@@ -153,7 +153,7 @@ export const H1 = ({ children, className, fontFamily = '', isfirst, islast, ...r
 
     return (
         <HeadingComponent {...noScale} className={`pt-2 text-4xl xl:text-5xl  tracking-tight leading-tight font-bold text-foreground text-balance ${spacing} ${className || ''} ${fontFamily || 'font-title'}`} {...(isWeb ? sanitizeWebTextProps(rest) : rest)}>
-            {children}
+            {decodeTextNode(children)}
         </HeadingComponent>
     )
 }
@@ -164,7 +164,7 @@ export const H1C = ({ children, className, fontFamily = '', isfirst, islast, ...
 
     return (
         <HeadingComponent {...noScale} className={`text-2xl sm:text-3xl font-bold tracking-tight text-foreground ${spacing} ${className || ''} ${fontFamily || 'font-title'}`} {...(isWeb ? sanitizeWebTextProps(rest) : rest)}>
-            {children}
+            {decodeTextNode(children)}
         </HeadingComponent>
     )
 }
@@ -175,7 +175,7 @@ export const H2 = ({ children, className, fontFamily = '', isfirst, islast, ...r
 
     return (
         <HeadingComponent {...noScale} className={`text-2xl font-semibold tracking-tight text-foreground ${spacing} ${className || ''} ${fontFamily || 'font-title'}`} {...(isWeb ? sanitizeWebTextProps(rest) : rest)}>
-           {children}
+            {decodeTextNode(children)}
         </HeadingComponent>
     )
 }
@@ -186,7 +186,7 @@ export const H3 = ({ children, className, fontFamily = '', isfirst, islast, ...r
 
     return (
         <HeadingComponent {...noScale} className={`text-lg lg:text-xl font-bold tracking-tight text-foreground ${spacing} ${className || ''} ${fontFamily || 'font-title'}`} {...(isWeb ? sanitizeWebTextProps(rest) : rest)}>
-            {children}
+            {decodeTextNode(children)}
         </HeadingComponent>
     )
 }
@@ -197,7 +197,7 @@ export const H4 = ({ children, className, fontFamily = '', isfirst, islast, ...r
 
     return (
         <HeadingComponent {...noScale} className={`text-base lg:text-lg font-semibold tracking-tight text-foreground ${spacing} ${className || ''} ${fontFamily || 'font-title'}`} {...(isWeb ? sanitizeWebTextProps(rest) : rest)}>
-            {children}
+            {decodeTextNode(children)}
         </HeadingComponent>
     )
 }
@@ -208,7 +208,7 @@ export const H5 = ({ children, className, fontFamily = '', isfirst, islast, ...r
 
     return (
         <HeadingComponent {...noScale} className={`text-lg sm:text-xl font-semibold tracking-tight text-foreground ${spacing} ${className || ''} ${fontFamily || 'font-title'}`} {...(isWeb ? sanitizeWebTextProps(rest) : rest)}>
-            {children}
+            {decodeTextNode(children)}
         </HeadingComponent>
     )
 }
@@ -219,7 +219,7 @@ export const H6 = ({ children, className, fontFamily = '', isfirst, islast, ...r
 
     return (
         <HeadingComponent {...noScale} className={` text-base sm:text-lg font-semibold tracking-tight text-foreground ${spacing} ${className || ''} ${fontFamily || 'font-title'}`} {...(isWeb ? sanitizeWebTextProps(rest) : rest)}>
-            {children}
+            {decodeTextNode(children)}
         </HeadingComponent>
     )
 }

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

@@ -80,6 +80,16 @@ export function decodeText(str: string): string {
 
 export const htmlDecode = decodeText; // Alias for backward compatibility
 
+/**
+ * `decodeText` for a value that may not be a string: React children, optional
+ * labels, `alt`. UNA sends titles and names HTML-escaped (`Rock &amp; Roll`),
+ * so every plain-text sink that shows one (headings, alt, accessible names,
+ * native buttons, document titles) decodes it. Anything but a string passes through.
+ */
+export function decodeTextNode<T>(value: T): T {
+    return (typeof value === 'string' ? decodeText(value) : value) as T;
+}
+
 export function truncateString(str: string, num: number): string {
     if (str.length <= num) {
         return str;

+ 2 - 2
neo/packages/app/ui/atoms/dropdown-grid-item.tsx

@@ -6,7 +6,7 @@ import { cueAttributes } from 'app/lib/sound';
 import { Text } from 'app/design/typography';
 import { Icon } from 'app/ui/atoms/icon';
 import Image from 'app/ui/atoms/image';
-import { isEmoji, appSetting } from 'app/lib/util';
+import { isEmoji, appSetting, decodeTextNode } from 'app/lib/util';
 import Link from 'app/ui/atoms/link';
 
 const menuSettings = appSetting('theme', 'dropdown_menu');
@@ -91,7 +91,7 @@ export default function DropdownGridItem({ item, index, link, handleSelect, clas
             className={`${className || ''} ${menuSettings.item_grid || ''}`}
             key={key}
             accessibilityRole={handleSelect ? 'button' : undefined}
-            accessibilityLabel={title || undefined}
+            accessibilityLabel={decodeTextNode(title) || undefined}
             {...pressProps}
             {...rowHoverProps}
             {...(handleSelect || link ? cueAttributes('select') : {})}

+ 2 - 2
neo/packages/app/ui/atoms/image.tsx

@@ -4,7 +4,7 @@ import type { ViewStyle } from 'react-native';
 import type { ImageInnerProps, ImageProps } from './image.types';
 import { Image as ExpoImage } from 'expo-image';
 import { View } from 'app/design/view';
-import { appSetting, cn } from 'app/lib/util';
+import { appSetting, cn, decodeText } from 'app/lib/util';
 import {
     getNativeOptimizedImageUrl,
     hasSizeClass,
@@ -84,7 +84,7 @@ function ElementImageInner(props: ImageInnerProps) {
                 cachePolicy="memory-disk"
                 recyclingKey={normalizedSrc}
                 priority={priority === true ? 'high' : (priority || undefined)}
-                alt={alt}
+                alt={decodeText(alt)}
                 onError={handleError}
                 onLoad={onLoad}
             />

+ 2 - 2
neo/packages/app/ui/atoms/image.web.tsx

@@ -3,7 +3,7 @@
 import type { CSSProperties } from 'react';
 import NextImage from 'next/image';
 import type { ImageInnerProps, ImageProps } from './image.types';
-import { cn } from 'app/lib/util';
+import { cn, decodeText } from 'app/lib/util';
 import {
     canOptimizeSrc,
     createOptimizedLoader,
@@ -57,7 +57,7 @@ function ElementImageInner(props: ImageInnerProps) {
         <NextImage
             key={imageKey}
             src={normalizedSrc}
-            alt={alt}
+            alt={decodeText(alt)}
             sizes={sizesAttr}
             className={className}
             onError={handleError}

+ 3 - 2
neo/packages/app/ui/atoms/letter.tsx

@@ -1,5 +1,5 @@
 import { View } from 'app/design/view'
-import { cn, getRandomColor } from 'app/lib/util'
+import { cn, decodeText, getRandomColor } from 'app/lib/util'
 import { Text } from 'app/design/typography'
 
 type LetterProps = {
@@ -10,7 +10,8 @@ type LetterProps = {
 };
 
 export default function Letter({ title, id, className, textClassName }: LetterProps) {
-    const letter = title ? title.substr(0, 1) : ''
+    // Decoded first, or `&amp; Co` would show `&`.
+    const letter = title ? decodeText(title).substr(0, 1) : ''
     const color = id || title ? `${getRandomColor(id ? id : title)}-500` : 'muted'
     return (
         <View

+ 2 - 2
neo/packages/app/ui/atoms/link.tsx

@@ -1,6 +1,6 @@
 import { Pressable } from 'app/design/view';
 import { useCurrentTabPath, Link } from 'app/lib/hooks/router'
-import { FeedbackHaptics, isExternalUrl, openExternalLink } from 'app/lib/util';
+import { FeedbackHaptics, isExternalUrl, openExternalLink, decodeTextNode } from 'app/lib/util';
 import { nativeTabPageHref } from 'app/lib/navigation/tab-history';
 import { findTabPathForUrl } from 'app/components/nav/tabs/tab-menu';
 import { useCurrentUser } from 'app/context/user';
@@ -96,7 +96,7 @@ export default function ElementLink({
         return false;
     };
 
-    const accessibleLabel = alt || (typeof children === 'string' ? children : undefined);
+    const accessibleLabel = decodeTextNode(alt || (typeof children === 'string' ? children : undefined));
     const isTextMode = mode === 'text';
     const isPlainMode = mode === 'plain';
     const isExternal = isExternalUrl(finalHref) || asExternal === true;

+ 2 - 2
neo/packages/app/ui/atoms/link.web.tsx

@@ -2,7 +2,7 @@ import Link from 'next/link'
 import { Pressable } from 'app/design/view'
 import { useRouter } from 'app/lib/hooks/router'
 import { useCallback, type CSSProperties } from 'react';
-import { appSetting, cn, sanitazeUrl, isExternalUrl } from 'app/lib/util'
+import { appSetting, cn, sanitazeUrl, isExternalUrl, decodeTextNode } from 'app/lib/util'
 import emitter, { EVENTS } from 'app/context/emitter';
 import type { LinkProps } from './link.types';
 
@@ -38,7 +38,7 @@ export default function ElementLink({
     const rel = resolvedTarget === '_blank' ? 'noopener noreferrer' : undefined;
 
     // Convert alt to aria-label (alt is not valid for <a> elements)
-    const accessibleLabel = alt || (typeof children === 'string' ? children : undefined);
+    const accessibleLabel = decodeTextNode(alt || (typeof children === 'string' ? children : undefined));
 
     const handlePress = useCallback((event?: { preventDefault?: () => void }) => {
         emitter.emit(EVENTS.link, { action: 'pressed', href });

+ 2 - 2
neo/packages/app/ui/molecules/ai-agent/chat-threads.js

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { View, Row, ScrollView, Pressable } from 'app/design/view';
 import { Text } from 'app/design/typography';
 import { Input, NeoButton } from 'app/design/controls';
-import { cn } from 'app/lib/util';
+import { cn, decodeTextNode } from 'app/lib/util';
 import { formatDate } from 'app/lib/util/datetime';
 import { fetchAgentChatThreads } from './helper';
 
@@ -81,7 +81,7 @@ function ThreadRowImpl({ thread, active, onSelect, t }) {
             onPress={() => onSelect(thread)}
             accessibilityRole="button"
             accessibilityState={{ selected: active }}
-            accessibilityLabel={title}
+            accessibilityLabel={decodeTextNode(title)}
             className={cn(
                 'rounded-lg px-2.5 py-2 gap-0.5 web:cursor-pointer web:hover:bg-muted/60 web:transition-colors',
                 active ? 'bg-muted' : ''

+ 2 - 2
neo/packages/app/ui/molecules/ai-agents-admin/history.js

@@ -8,7 +8,7 @@ import { NeoButton } from 'app/design/controls';
 import Loading from 'app/ui/atoms/loading';
 import Link from 'app/ui/atoms/link';
 import { Icon } from 'app/ui/atoms/icon';
-import { cn } from 'app/lib/util';
+import { cn, decodeTextNode } from 'app/lib/util';
 import { formatDate } from 'app/lib/util/datetime';
 import { ChatBubble } from 'app/ui/molecules/ai-agent/chat-bubble';
 import {
@@ -53,7 +53,7 @@ function ActivityRowImpl({ item, t }) {
                 <Row className="items-start justify-between gap-2">
                     <View className="flex-1 min-w-0">
                         {item.url ? (
-                            <Link href={item.url} target="_blank" accessibilityLabel={item.title || item.text}>
+                            <Link href={item.url} target="_blank" accessibilityLabel={decodeTextNode(item.title || item.text)}>
                                 {text}
                             </Link>
                         ) : text}

+ 2 - 2
neo/packages/app/ui/molecules/profile/profile.js

@@ -2,7 +2,7 @@ import { Text } from 'app/design/typography'
 import { View, Row } from 'app/design/view'
 import Image from 'app/ui/atoms/image'
 import Link from 'app/ui/atoms/link'
-import { getRandomColor, appSetting, cn } from 'app/lib/util';
+import { getRandomColor, appSetting, cn, decodeText } from 'app/lib/util';
 import { memo } from 'react';
 import { components } from 'app/components/registry';
 import { useTranslation } from 'react-i18next';
@@ -67,7 +67,7 @@ function DisplayInfo(oProps) {
 }
 
 function UnitWoInfo({ oProps, sSize, sSizeFontLetter, iSizeWidth, bShowLinks, emulate, hoverCardWrapper }) {
-    let name = oProps.display_name ? oProps.display_name.substr(0, 1) : ''
+    let name = oProps.display_name ? decodeText(oProps.display_name).substr(0, 1) : ''
     const isLinked = !!(oProps.url && bShowLinks && !hoverCardWrapper)
     const avatarContent =
         <View className={cn(`${sSize} relative overflow-hidden bg-muted rounded-full`, oProps.avatarClassName, !isLinked && oProps.className)}>

+ 2 - 2
neo/packages/app/ui/molecules/sections/conductor/shared-ui.js

@@ -5,7 +5,7 @@ import { Icon } from 'app/ui/atoms/icon'
 import DropdownMenu, { DropdownMenuOpenContext } from 'app/ui/atoms/dropdown-menu'
 import UniList from 'app/ui/atoms/unilist'
 import { BlockByName, BlockByData } from 'app/components/block'
-import { findElementById, hasNonEmptyContent, isWeb } from 'app/lib/util'
+import { decodeTextNode, findElementById, hasNonEmptyContent, isWeb } from 'app/lib/util'
 import { ItemRenderer } from 'app/components/item-renderer'
 import { NeoButton } from 'app/design/controls'
 import { cueAttributes, PressCueContext } from 'app/lib/sound'
@@ -345,7 +345,7 @@ export function TabItem({ item, selectedIndex, onPress }) {
             <View className={`${item?.menu_settings?.class || ''}`}>
                 <Pressable
                     accessibilityRole="link"
-                    accessibilityLabel={item.title}
+                    accessibilityLabel={decodeTextNode(item.title)}
                     onPress={handlePress}
                     {...cueAttributes('select')}
                 >

+ 2 - 1
neo/packages/app/ui/molecules/sections/conductor/web-ui.js

@@ -4,6 +4,7 @@ import { View, Row, Pressable } from 'app/design/view'
 import { Icon } from 'app/ui/atoms/icon'
 import {
     appSetting,
+    decodeTextNode,
     getMenuSettings,
     menuItemsFilter,
 } from 'app/lib/util'
@@ -657,7 +658,7 @@ const TabMenuItemEx = memo(function TabMenuItemEx({ item }) {
             <Pressable
                 className={itemClassName}
                 accessibilityRole="link"
-                accessibilityLabel={title}
+                accessibilityLabel={decodeTextNode(title)}
                 onPress={handlePress}
             >
                 <Row className="justify-between items-center min-w-200">