menu-footer.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import { View } from 'app/design/view'
  2. import { appSetting, menuItemsByName, menuItemsByNameNew } from 'app/lib/util'
  3. import { useTranslation } from 'react-i18next';
  4. import { useMemo, memo } from 'react';
  5. import { NeoButtonLink } from 'app/design/controls'
  6. import { useCurrentUserNoCounters } from 'app/context/user';
  7. import { useMenuData } from 'app/context/menu-data';
  8. import { useLayoutSettings } from 'app/context/layout-settings';
  9. import DropdownMenu from 'app/ui/atoms/dropdown-menu';
  10. import { SOUND_LEVEL_ITEMS, useSoundsLevel } from 'app/lib/hooks/use-sounds-level';
  11. export const MENU_FOOTER_CLASSES = 'flex w-full items-center border-t border-border/60 justify-center flex-row flex-wrap gap-1 p-4 min-h-16';
  12. // Settings use the historical misspelling; keep this key aligned with settings/layout.js.
  13. const AVAILABLE_LANGS_SETTING_KEY = 'avaliable_langs';
  14. const FOOTER_THEME_ITEMS = [
  15. { id: 'auto', titleKey: 'theme_footer_auto', labelKey: 'theme_level_auto', icon: 'Eclipse' },
  16. { id: 'light', titleKey: 'theme_footer_light', labelKey: 'theme_level_light', icon: 'Sun' },
  17. { id: 'dark', titleKey: 'theme_footer_dark', labelKey: 'theme_level_dark', icon: 'Moon' },
  18. ];
  19. const linkVariantToNeoStyle = {
  20. default: 'plain',
  21. secondary: 'borderless',
  22. accent: 'link',
  23. primary: 'borderedProminent',
  24. ghost: 'borderless',
  25. link: 'link',
  26. };
  27. const linkSizeToNeoControlSize = {
  28. xs: 'mini',
  29. sm: 'small',
  30. md: 'regular',
  31. base: 'regular',
  32. lg: 'large',
  33. };
  34. const normalizeFooterHref = (href = '') => {
  35. const value = typeof href === 'string' ? href.trim() : '';
  36. if (!value) return '';
  37. if (
  38. value.startsWith('/') ||
  39. value.startsWith('#') ||
  40. value.startsWith('//') ||
  41. /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(value)
  42. ) {
  43. return value;
  44. }
  45. return `/${value}`;
  46. };
  47. const resolveFooterLang = (langs, lang, langCode) => {
  48. if (lang && langs.includes(lang)) return lang;
  49. if (langCode && langs.includes(langCode)) return langCode;
  50. return langs[0] || '';
  51. };
  52. const menuFooterSkeletonWidths = ['w-12', 'w-12', 'w-12', 'w-12', 'w-12'];
  53. function MenuFooterSkeleton({ cntClasses, children, role }) {
  54. return (
  55. <View className={cntClasses} role={role}>
  56. {menuFooterSkeletonWidths.map((width, index) => (
  57. <View
  58. key={`menu-footer-skeleton-${index}`}
  59. className={`h-5 my-2 mx-1 ${width} rounded-full bg-muted animate-pulse`}
  60. />
  61. ))}
  62. {children}
  63. </View>
  64. );
  65. }
  66. function FooterThemeSwitcher({ visualProps }) {
  67. const { t } = useTranslation();
  68. const { themeName, setThemeName } = useLayoutSettings();
  69. const currentThemeName = themeName || 'auto';
  70. const items = useMemo(() => (
  71. FOOTER_THEME_ITEMS.map((theme) => ({
  72. key: theme.id,
  73. id: theme.id,
  74. name: theme.id,
  75. title: t(theme.titleKey),
  76. icon: theme.icon,
  77. selected: currentThemeName === theme.id,
  78. }))
  79. ), [currentThemeName, t]);
  80. const currentThemeItem = FOOTER_THEME_ITEMS.find((theme) => theme.id === currentThemeName) ?? FOOTER_THEME_ITEMS[0];
  81. const themeLabel = t(currentThemeItem.labelKey);
  82. // Icon only: the icon shows the current theme, and the menu names each one.
  83. return (
  84. <DropdownMenu
  85. items={items}
  86. onSelect={(item) => setThemeName(item.id)}
  87. buttonProps={{
  88. image: currentThemeItem.icon,
  89. accessibilityLabel: themeLabel,
  90. tooltip: themeLabel,
  91. style: visualProps.style,
  92. controlSize: visualProps.controlSize,
  93. className: visualProps.className,
  94. textClassName: visualProps.textClassName,
  95. }}
  96. />
  97. );
  98. }
  99. function FooterSoundsLevel({ visualProps }) {
  100. const { t } = useTranslation();
  101. const { level, setLevel } = useSoundsLevel();
  102. // The chosen level answers itself (`setLevel` plays it at the new level), so its option makes no sound of its own.
  103. const items = SOUND_LEVEL_ITEMS.map((option) => ({
  104. key: option.id,
  105. id: option.id,
  106. name: option.id,
  107. title: t(option.titleKey),
  108. icon: option.icon,
  109. selected: level === option.id,
  110. sound: false,
  111. }));
  112. const current = SOUND_LEVEL_ITEMS.find((option) => option.id === level) ?? SOUND_LEVEL_ITEMS[0];
  113. const label = t(current.labelKey);
  114. // Icon only, like the theme switcher: the icon shows the level, and the menu names each one.
  115. return (
  116. <DropdownMenu
  117. items={items}
  118. onSelect={(item) => setLevel(item.id)}
  119. buttonProps={{
  120. image: current.icon,
  121. accessibilityLabel: label,
  122. tooltip: label,
  123. style: visualProps.style,
  124. controlSize: visualProps.controlSize,
  125. className: visualProps.className,
  126. textClassName: visualProps.textClassName,
  127. }}
  128. />
  129. );
  130. }
  131. function FooterLanguageSwitcher({ visualProps }) {
  132. const { t } = useTranslation();
  133. const { lang, langCode, setLang } = useLayoutSettings();
  134. const configuredLangs = appSetting('layout', AVAILABLE_LANGS_SETTING_KEY);
  135. const langs = Array.isArray(configuredLangs) ? configuredLangs : [];
  136. const currentLang = resolveFooterLang(langs, lang, langCode);
  137. const items = useMemo(() => (
  138. langs.map((language) => ({
  139. key: language,
  140. id: language,
  141. name: language,
  142. title: t('lang_' + language),
  143. icon: 'Languages',
  144. selected: currentLang === language,
  145. }))
  146. ), [currentLang, langs, t]);
  147. if (langs.length <= 1 || !currentLang) return null;
  148. const langLabel = t('lang_' + currentLang);
  149. return (
  150. <DropdownMenu
  151. items={items}
  152. onSelect={(item) => setLang(item.id)}
  153. buttonProps={{
  154. label: langLabel,
  155. image: 'Languages',
  156. accessibilityLabel: langLabel,
  157. style: visualProps.style,
  158. controlSize: visualProps.controlSize,
  159. className: visualProps.className,
  160. textClassName: visualProps.textClassName,
  161. }}
  162. />
  163. );
  164. }
  165. function MenuFooterComponent({
  166. cntClasses = MENU_FOOTER_CLASSES,
  167. btnStyle,
  168. menu_items,
  169. variant,
  170. size,
  171. itemClassName,
  172. }) {
  173. const { t } = useTranslation();
  174. const currentUser = useCurrentUserNoCounters();
  175. const { hydrated } = useLayoutSettings();
  176. const usesRemoteFooter = !menu_items && appSetting('layout', 'user_remote_config');
  177. const showThemeSwitcher = appSetting('dashboard', 'switch_theme');
  178. const { available: soundsAvailable } = useSoundsLevel();
  179. const configuredLangs = appSetting('layout', AVAILABLE_LANGS_SETTING_KEY);
  180. const showLanguageSwitcher = hydrated && Array.isArray(configuredLangs) && configuredLangs.length > 1;
  181. const showSoundsLevel = hydrated && soundsAvailable;
  182. const { menuData, isFetched, isLoading } = useMenuData(
  183. (menu_items || !hydrated) ? null : appSetting('menu_items', 'objects', 'footer')
  184. );
  185. const visualProps = useMemo(() => {
  186. const legacy = btnStyle || {};
  187. const legacyVariant = variant ?? legacy.variant ?? 'accent';
  188. const legacySize = size ?? legacy.size ?? 'sm';
  189. return {
  190. style: legacy.style ?? linkVariantToNeoStyle[legacyVariant] ?? 'link',
  191. controlSize: legacy.controlSize ?? linkSizeToNeoControlSize[legacySize] ?? 'small',
  192. className: legacy.containerClassName ?? '',
  193. textClassName: itemClassName ?? legacy.textClassName ?? legacy.className ?? '',
  194. };
  195. }, [btnStyle, variant, size, itemClassName]);
  196. const menu_launcher_items = useMemo(() => (
  197. menu_items || (usesRemoteFooter
  198. ? menuItemsByNameNew('menu_post', menuData, currentUser)
  199. : menuItemsByName('', appSetting('menu_items', 'menu_footer'), currentUser))
  200. ), [menu_items, usesRemoteFooter, menuData, currentUser]);
  201. if (usesRemoteFooter && (!hydrated || isLoading || !isFetched)) {
  202. return (
  203. <MenuFooterSkeleton cntClasses={cntClasses} role="contentinfo">
  204. {showLanguageSwitcher ? <FooterLanguageSwitcher visualProps={visualProps} /> : null}
  205. {showThemeSwitcher ? <FooterThemeSwitcher visualProps={visualProps} /> : null}
  206. {showSoundsLevel ? <FooterSoundsLevel visualProps={visualProps} /> : null}
  207. </MenuFooterSkeleton>
  208. );
  209. }
  210. if (menu_launcher_items.length === 0 && menuData && !showThemeSwitcher && !showLanguageSwitcher && !showSoundsLevel)
  211. return null;
  212. return (
  213. <View className={cntClasses} role="contentinfo">
  214. {menu_launcher_items.map((item, index) => (
  215. <NeoButtonLink
  216. href={normalizeFooterHref(item.link)}
  217. key={item.link || index}
  218. label={t(item.title)}
  219. style={visualProps.style}
  220. controlSize={visualProps.controlSize}
  221. className={visualProps.className}
  222. textClassName={visualProps.textClassName}
  223. />
  224. ))}
  225. {showLanguageSwitcher ? <FooterLanguageSwitcher visualProps={visualProps} /> : null}
  226. {showThemeSwitcher ? <FooterThemeSwitcher visualProps={visualProps} /> : null}
  227. {showSoundsLevel ? <FooterSoundsLevel visualProps={visualProps} /> : null}
  228. </View>
  229. );
  230. }
  231. export default memo(MenuFooterComponent);