layout.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import { Platform } from 'react-native'
  2. import { isComponent } from 'app/components/registry'
  3. import { appSetting } from './settings'
  4. import { strToObj } from './object'
  5. export { cn } from 'cnfast';
  6. export const isWeb = Platform.OS === 'web'
  7. export const isIos = Platform.OS === 'ios'
  8. export const isAndroid = Platform.OS === 'android'
  9. /** Expo UI NeoButton (SwiftUI / Material). Off on web. */
  10. export function isExpoUI() {
  11. return !isWeb && !!appSetting('native', 'expo_ui_buttons');
  12. }
  13. /** expo-router NativeTabs. Needs isExpoUI + expo_native_tabs. */
  14. export function isNativeTabsEnabled() {
  15. return isExpoUI() /*&& isIos*/ && !!appSetting('native', 'expo_native_tabs');
  16. }
  17. /** Text under tab icons (web footer + native JS / NativeTabs). */
  18. export function isTabBarLabelsEnabled() {
  19. return !!appSetting('native', 'tab_labels');
  20. }
  21. /**
  22. * Android NativeTabs bar height (dp) without the system inset it pads itself
  23. * with: Material 3 sizes it to its items once `with-android-tab-bar-height`
  24. * (apps/expo/plugins) lifts the 80dp floor. iOS needs no constant — its bar is
  25. * part of the tab screen's safe-area inset.
  26. */
  27. export function androidTabBarHeight(labelsHidden: boolean) {
  28. return labelsHidden ? 56 : 80;
  29. }
  30. /** iOS 26+: Liquid Glass (SwiftUI `glassEffect`) and the floating tab bar; the app still supports iOS 16.4. */
  31. export const hasLiquidGlass = isIos && parseInt(String(Platform.Version), 10) >= 26
  32. /** Side margin of the iOS 26 floating tab bar on iPhone (measured on a 15 Pro). */
  33. export const IOS_TAB_BAR_MARGIN = 21
  34. export const LAYOUT_BREAKPOINTS = {
  35. '2xl': 1536,
  36. xl: 1280,
  37. lg: 1024,
  38. md: 768,
  39. sm: 640
  40. };
  41. const LAYOUT_BREAKPOINTS_DESC = Object.entries(LAYOUT_BREAKPOINTS).sort((a, b) => b[1] - a[1]);
  42. /** Largest breakpoint label (`2xl`…`sm`) whose min width fits; `''` below all. */
  43. export function getBreakpoint(width: number): string {
  44. for (const [label, min] of LAYOUT_BREAKPOINTS_DESC) {
  45. if (width >= min) return label;
  46. }
  47. return '';
  48. }
  49. export function normalizeClasses(a: string): string;
  50. export function normalizeClasses(a: string | null | undefined): string | null | undefined;
  51. export function normalizeClasses(a: string | null | undefined): string | null | undefined {
  52. if (!a) return a
  53. // Notes:
  54. // - `group` is a special Tailwind marker (not a style utility).
  55. // - `web:group` won't create a `.group` ancestor on web unless we rewrite it.
  56. // - On native, we strip web-only and pseudo-class variants to reduce parsing work.
  57. if (isWeb) {
  58. return a.replace(/\bweb:group\b/g, 'group');
  59. }
  60. return a
  61. .replace(/\bweb:group\b/g, '')
  62. .replace(/\b\S*(hover|focus|active|group|duration|group-hover):\S*\b/g, "")
  63. .replace(/\s{2,}/g, " ")
  64. .trim();
  65. }
  66. export const roundedClassToRadius = (roundedClass = '') => {
  67. const c = roundedClass.trim();
  68. if (c === 'rounded-full') return 9999;
  69. if (c === 'rounded-md') return 6;
  70. if (c === 'rounded-lg') return 8;
  71. if (c === 'rounded-xl') return 12;
  72. return 0;
  73. };
  74. /** Cover visibility setting: 1 all, 2 visitors only, 3 members only (default 1; always on home). */
  75. export function isShowCover(cover: number | null | undefined, currentUser: unknown, url: string): boolean {
  76. if (cover === null || cover === undefined || url == 'home')
  77. cover = 1;
  78. if (cover === 1) // for all
  79. return true;
  80. if (cover === 2 && !currentUser) // for visitors
  81. return true;
  82. if (cover === 3 && currentUser) // for logged
  83. return true;
  84. return false;
  85. }
  86. /** Header toolbar NeoButton defaults from `header_toolbar.neoButton` settings (desktop/mobile). */
  87. export function getHeaderToolbarNeoButtonDefaults(isDesktop: boolean, options?: { primary?: boolean }): { style: string; controlSize: string } {
  88. const primary = options?.primary === true;
  89. const config = appSetting('header_toolbar', 'neoButton') || {};
  90. const platform = isDesktop ? 'desktop' : 'mobile';
  91. const platformConfig = config[platform] || {};
  92. const fallbackStyle = isDesktop ? 'bordered' : 'borderless';
  93. const fallbackPrimaryStyle = isDesktop ? 'borderedProminent' : 'glassProminent';
  94. return {
  95. style: primary
  96. ? (platformConfig.primaryStyle ?? fallbackPrimaryStyle)
  97. : (platformConfig.style ?? fallbackStyle),
  98. controlSize: platformConfig.controlSize ?? 'regular',
  99. };
  100. }
  101. export function getPageWidth(uri: string, config?: unknown): any {
  102. //let settings = appSetting('l-ayouts', uri)
  103. const settings = getPageSettings(config, uri);
  104. if (settings?.max_width)
  105. return settings.max_width;
  106. return appSetting('layout', 'max_width');
  107. }
  108. function normalizeContentWidthKey(value: unknown): string {
  109. if (typeof value !== 'string' || !value) return '';
  110. return value.split('?')[0]!.replace(/^\/+|\/+$/g, '');
  111. }
  112. /** `chat` is the shared list | conversation layout; `messenger` is its alias. */
  113. export function isChatLayout(layoutName: unknown): boolean {
  114. return layoutName === 'chat' || layoutName === 'messenger';
  115. }
  116. /** Layout name used for settings lookups: the alias shares the `chat` entries. */
  117. function layoutSettingsKey(layoutKey: string): string {
  118. return isChatLayout(layoutKey) ? 'chat' : layoutKey;
  119. }
  120. /** Layout name or page URI from `layout.page_content_width`. URI wins so a page can opt out of its layout cap. */
  121. export function getPageContentWidth(layoutName: string, uri?: string): any {
  122. const layoutKey = layoutSettingsKey(layoutName);
  123. const pageLayouts = appSetting('layout', 'page_content_width') || {};
  124. const uriKey = normalizeContentWidthKey(uri);
  125. if (uriKey && Object.prototype.hasOwnProperty.call(pageLayouts, uriKey)) {
  126. return pageLayouts[uriKey];
  127. }
  128. const uriWithSlash = uriKey ? `/${uriKey}` : '';
  129. if (uriWithSlash && Object.prototype.hasOwnProperty.call(pageLayouts, uriWithSlash)) {
  130. return pageLayouts[uriWithSlash];
  131. }
  132. if (layoutKey && Object.prototype.hasOwnProperty.call(pageLayouts, layoutKey)) {
  133. return pageLayouts[layoutKey];
  134. }
  135. return appSetting('layout', 'page_content_width_default');
  136. }
  137. export function getPageContentPaddingTiers(layoutName: string): any {
  138. if (!layoutName) return undefined;
  139. const layoutKey = layoutSettingsKey(layoutName);
  140. const pageLayouts = appSetting('layout', 'page_content_padding_layouts') || {};
  141. return Object.prototype.hasOwnProperty.call(pageLayouts, layoutKey)
  142. ? pageLayouts[layoutKey]
  143. : undefined;
  144. }
  145. function normalizeLayoutPath(uri: unknown): string {
  146. return String(uri || '').split(/[?#]/)[0]!.replace(/^\/+|\/+$/g, '');
  147. }
  148. function isTasksModule(data: { module?: string } | null | undefined): boolean {
  149. const module = String(data?.module || '');
  150. return module === 'bx_tasks' || module.startsWith('bx_tasks');
  151. }
  152. function isTaskViewPath(path: string): boolean {
  153. return path === 'view-task' || path.startsWith('view-task/');
  154. }
  155. /** Item view for a task (`/view-task/...` or the list-open `item` fetch). Not tasks-home. */
  156. export function isTaskItemPage(data: any, uri: string): boolean {
  157. const path = normalizeLayoutPath(uri);
  158. const dataPath = normalizeLayoutPath(data?.uri);
  159. if (path === 'tasks-home' || dataPath === 'tasks-home') return false;
  160. if (isTaskViewPath(path) || isTaskViewPath(dataPath)) return true;
  161. return path === 'item' && isTasksModule(data);
  162. }
  163. export type LayoutNameResult = { layoutName: string; layoutBlocks: any; isCustomLayout: boolean; columnLayout: string };
  164. export function getLayoutName(data: any, uri: string): LayoutNameResult {
  165. if (data?.page_status) {
  166. return { layoutName: 'default', layoutBlocks: '', isCustomLayout: false, columnLayout: '' };
  167. }
  168. const pageSettings = getPageSettings(data?.config, uri) || {};
  169. const customLayout = pageSettings.layout || '';
  170. const customBlocks = pageSettings.blocks || '';
  171. const isCustom = Boolean(customLayout);
  172. const checks: Array<{ cond: boolean; name: string; blocks?: any; custom?: boolean; columnLayout?: string }> = [
  173. {
  174. cond: uri === 'login',
  175. name: 'login',
  176. blocks: customBlocks,
  177. custom: true,
  178. },
  179. {
  180. // Before UNA `config.layout: 'post'` so list-open and direct URLs share `task`.
  181. cond: isTaskItemPage(data, uri),
  182. name: 'task',
  183. blocks: customBlocks,
  184. custom: true,
  185. },
  186. {
  187. cond: isCustom,
  188. name: customLayout,
  189. blocks: customBlocks,
  190. custom: true,
  191. },
  192. {
  193. cond: Boolean(data?.cover_block?.profile),
  194. name: 'profile',
  195. },
  196. {
  197. cond: uri === 'home',
  198. name: 'home',
  199. custom: true,
  200. },
  201. {
  202. cond: uri === 'create-account',
  203. name: 'create-account',
  204. custom: true,
  205. },
  206. {
  207. cond: data?.menu?.items?.length > 0 && !uri.includes('create-'),
  208. name: 'navigator',
  209. },
  210. {
  211. cond: data?.layout && isComponent('layout', data.layout),
  212. name: data.layout,
  213. blocks: customBlocks,
  214. },
  215. ];
  216. for (const { cond, name, blocks = customBlocks, custom = isCustom, columnLayout = '' } of checks) {
  217. if (cond) {
  218. return { layoutName: name, layoutBlocks: blocks, isCustomLayout: custom, columnLayout };
  219. }
  220. }
  221. return { layoutName: 'default', layoutBlocks: customBlocks, isCustomLayout: isCustom, columnLayout: '' };
  222. }
  223. export function getHeaderSettings(uri: string, isDesktop: boolean, layout: string, config?: unknown): any {
  224. let settings = getPageSettings(config, uri);
  225. if (!settings?.headerSettings) {
  226. if (layout == 'navigator') {
  227. settings = { headerSettings: { offset: isDesktop ? true : false, header: false, backButton: false, menu: true, footer: true } }
  228. }
  229. if (isChatLayout(layout)) {
  230. settings = { headerSettings: { offset: false, header: false, backButton: false, menu: true, footer: true } }
  231. }
  232. if (layout == 'profile') {
  233. settings = { headerSettings: { offset: false, header: false, footer: true } }
  234. }
  235. }
  236. const bBackButton = typeof settings?.headerSettings?.backButton !== 'undefined' ? settings.headerSettings.backButton : true;
  237. let bHeader = typeof settings?.headerSettings?.header !== 'undefined' ? settings.headerSettings.header : true;
  238. let bFooter = typeof settings?.headerSettings?.footer !== 'undefined' ? settings.headerSettings.footer : true;
  239. if (isDesktop) {
  240. bHeader = true;
  241. }
  242. const bMenu = typeof settings?.headerSettings?.menu !== 'undefined' ? settings.headerSettings.menu : true;
  243. const bTitle = typeof settings?.headerSettings?.title !== 'undefined' ? settings.headerSettings.title : true;
  244. let bOffset = typeof settings?.headerSettings?.offset !== 'undefined' ? settings.headerSettings.offset : false;
  245. let sCover = typeof settings?.headerSettings?.cover !== 'undefined' ? settings.headerSettings.cover : 'full';
  246. let sColumns = typeof settings?.headerSettings?.columns !== 'undefined' ? settings.headerSettings.columns : '';
  247. let bHideLeftmenu = typeof settings?.headerSettings?.hideLeftmenu !== 'undefined' ? settings.headerSettings.hideLeftmenu : false;
  248. // Apply offset on all viewports, not just larger ones PLAESE DONT CHANGE IT
  249. if (isDesktop)
  250. bOffset = true;
  251. return {
  252. backButton: bBackButton,
  253. header: bHeader,
  254. menu: bMenu,
  255. title: bTitle,
  256. offset: bOffset,
  257. footer: bFooter,
  258. cover: sCover,
  259. columns: sColumns,
  260. hideLeftmenu: bHideLeftmenu,
  261. }
  262. }
  263. /**
  264. * Per-page layout settings: remote `config` when enabled, else `layouts[uri]` from settings.
  265. * Page override point: `layout` picks a page layout (components/page-layout/_map.js) and
  266. * `blocks` sets its block list, which can name static components as `static:<name>`.
  267. * Forks add `layouts[uri]` in customization/settings.js.
  268. */
  269. export function getPageSettings(config: unknown, uri: string): any {
  270. if (!appSetting('layout', 'user_remote_config')) {
  271. return appSetting('layouts', uri);
  272. }
  273. if (!config) return appSetting('layouts', uri);
  274. if (typeof config === 'object') return config;
  275. return strToObj(config as string) ?? appSetting('layouts', uri);
  276. }