@controleonline/ui-common 1.2.98 → 1.2.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ # Homepage ADMIN/MANAGER — app-community#1056
2
+
3
+ Soft appearance is opt-in for HomePage only. Existing menu routes/parameters, permissions, stores and translations are preserved. App-provided homepage colors and components are used only in this appearance. Default consumers retain their current behavior.
4
+
5
+ Source: https://github.com/ControleOnline/app-community/issues/1056
6
+ Local evidence: 40 homepage tests and 34 runtime footer/zoom tests passed. No production authorization.
7
+
8
+ ## Web login translation bootstrap
9
+
10
+ Keep the navigation tree mounted while translations initialize. Hide the shell and disable pointer/accessibility interaction until the existing translation readiness contract succeeds. Unmounting navigation during login resets its route and repeatedly mounts/registers the bottom bar, causing React maximum update depth. No route, permission, translation source, theme or zoom changes. Native behavior is unchanged.
11
+
12
+ Regression evidence: app-community homepage bootstrapNavigation.test.js reproduced maximum update depth before the fix, then passed after it. 44 tests across 9 homepage suites passed. Runtime Dev reproduced the unfixed bug; new runtime verification is required after consuming ui-common 1.2.95.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@controleonline/ui-common",
3
- "version": "1.2.98",
3
+ "version": "1.2.100",
4
4
  "author": "ControleOnline",
5
5
  "description": "ControleOnline Common Quasar UI Component",
6
6
  "license": "MIT",
package/src/api/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import {readStoredSessionToken} from '@controleonline/ui-common/src/utils/storedSessionToken';
1
2
  import myFetch from '@controleonline/ui-common/src/api/fetch';
2
3
  import axios from 'axios';
3
4
  import {env as APP_ENV} from '@env';
@@ -215,8 +216,8 @@ async function requestSmokeUrl(url, options) {
215
216
  export const api = {
216
217
  device: JSON.parse(localStorage.getItem('device') || '{}'),
217
218
  masterDevice: JSON.parse(localStorage.getItem('master-device') || '{}'),
218
-
219
-
219
+
220
+
220
221
  upload: async function (uri, formData) {
221
222
  const token = await this.getToken();
222
223
  const appDomain = resolveAppDomain(APP_ENV.DOMAIN);
@@ -242,8 +243,8 @@ export const api = {
242
243
  fetch: async function (uri, options = {}) {
243
244
  if (typeof options.headers === 'undefined')
244
245
  Object.assign(options, { headers: new Headers() });
245
-
246
- // Always refresh device from localStorage in case it was set after module load
246
+
247
+ // Always refresh device from localStorage in case it was set after module load
247
248
  try {
248
249
  this.device = JSON.parse(localStorage.getItem('device') || '{}');
249
250
  } catch (e) {
@@ -270,16 +271,16 @@ export const api = {
270
271
  if (options.body) {
271
272
  if (typeof options.body === 'object') {
272
273
  bodyDeviceId = options.body.device || null;
273
- } else if (typeof options.body === 'string') {
274
- try {
275
- const parsedBody = JSON.parse(options.body);
276
- bodyDeviceId = parsedBody?.device || null;
277
- } catch (e) {
278
- bodyDeviceId = null;
279
- }
280
- }
281
- }
282
-
274
+ } else if (typeof options.body === 'string') {
275
+ try {
276
+ const parsedBody = JSON.parse(options.body);
277
+ bodyDeviceId = parsedBody?.device || null;
278
+ } catch (e) {
279
+ bodyDeviceId = null;
280
+ }
281
+ }
282
+ }
283
+
283
284
  const requestDevice = resolveRequestDevice(
284
285
  this.masterDevice,
285
286
  this.device,
@@ -296,10 +297,10 @@ export const api = {
296
297
  options.headers.set('DEVICE-TYPE', requestDeviceType);
297
298
  }
298
299
  }
299
- if (options.responseType != 'text') {
300
- options.headers.set('Content-Type', MIME_TYPE);
301
- options.headers.set('Accept', MIME_TYPE);
302
- }
300
+ if (options.responseType != 'text') {
301
+ options.headers.set('Content-Type', MIME_TYPE);
302
+ options.headers.set('Accept', MIME_TYPE);
303
+ }
303
304
  options.headers.set('App-Domain', resolveAppDomain(APP_ENV.DOMAIN));
304
305
 
305
306
  if (options.body && typeof options.body != 'string') {
@@ -310,28 +311,13 @@ export const api = {
310
311
  if (options.params) {
311
312
  uri = this.buildQueryString(uri, options);
312
313
  }
313
- return myFetch(uri, options).catch(e => {
314
- throw e;
315
- });
316
- },
317
- async getToken() {
318
- const sessionString = localStorage.getItem('session');
319
- let session = null;
320
- if (sessionString) {
321
- try {
322
- const cleanString =
323
- typeof sessionString == 'string' &&
324
- sessionString.startsWith('__q_objt|')
325
- ? sessionString.substring('__q_objt|'.length)
326
- : sessionString;
327
- session = JSON.parse(cleanString); // Transforma a string em objeto
328
- } catch (e) {
329
- console.error('Failed to parse session from localStorage', e);
330
- }
331
- }
332
-
333
- return session?.token || session?.api_key;
334
- },
314
+ return myFetch(uri, options).catch(e => {
315
+ throw e;
316
+ });
317
+ },
318
+ async getToken() {
319
+ return readStoredSessionToken();
320
+ },
335
321
  serialize(obj, prefix = '') {
336
322
  const pairs = [];
337
323
 
@@ -344,12 +330,12 @@ export const api = {
344
330
 
345
331
  const value = obj[key];
346
332
  let fullKey = prefix ? `${prefix}.${key}` : key; // Usa ponto para objetos
347
-
348
- if (value === null || value === undefined) {
349
- pairs.push(`${fullKey}=`);
350
- } else if (typeof value === 'object' && !Array.isArray(value)) {
351
- // Objeto aninhado: chama serialize recursivamente com ponto
352
- pairs.push(...this.serialize(value, fullKey));
333
+
334
+ if (value === null || value === undefined) {
335
+ pairs.push(`${fullKey}=`);
336
+ } else if (typeof value === 'object' && !Array.isArray(value)) {
337
+ // Objeto aninhado: chama serialize recursivamente com ponto
338
+ pairs.push(...this.serialize(value, fullKey));
353
339
  } else if (Array.isArray(value)) {
354
340
  // Array: adiciona [] e itera sobre os valores
355
341
  if (value.length === 0) {
@@ -364,14 +350,14 @@ export const api = {
364
350
  });
365
351
  }
366
352
  } else {
367
- // Valor simples
368
- pairs.push(`${fullKey}=${encodeURIComponent(value)}`);
369
- }
370
- }
371
- }
372
- return pairs;
373
- },
374
-
353
+ // Valor simples
354
+ pairs.push(`${fullKey}=${encodeURIComponent(value)}`);
355
+ }
356
+ }
357
+ }
358
+ return pairs;
359
+ },
360
+
375
361
  buildQueryString(uri, options) {
376
362
  const safeUri = stripDomainQueryParams(uri);
377
363
 
@@ -38,6 +38,8 @@ export default function AnimatedModal({
38
38
  onRequestClose,
39
39
  children,
40
40
  style,
41
+ overlayColor,
42
+ contentStyle,
41
43
  }) {
42
44
  const [showing, setShowing] = useState(visible);
43
45
  const [keyboardHeight, setKeyboardHeight] = useState(0);
@@ -125,6 +127,8 @@ export default function AnimatedModal({
125
127
  const overlayStyle = [
126
128
  styles.overlay,
127
129
  style,
130
+ overlayColor,
131
+ contentStyle,
128
132
  Platform.OS === 'android' ? {paddingBottom: keyboardHeight} : null,
129
133
  ];
130
134
 
@@ -152,11 +156,12 @@ export default function AnimatedModal({
152
156
  style={[
153
157
  StyleSheet.absoluteFillObject,
154
158
  styles.overlayBg,
159
+ overlayColor ? {backgroundColor: overlayColor} : null,
155
160
  {opacity: overlayOpacity},
156
161
  ]}
157
162
  />
158
163
  <Animated.View
159
- style={[styles.content, {transform: [{translateY: slideY}]}]}>
164
+ style={[styles.content, contentStyle, {transform: [{translateY: slideY}]}]}>
160
165
  {normalizedChildren}
161
166
  </Animated.View>
162
167
  <Toasts
@@ -1,10 +1,13 @@
1
1
  import React, {useLayoutEffect, useMemo} from 'react';
2
2
  import {Pressable, Text, View} from 'react-native';
3
- import Icon from 'react-native-vector-icons/Feather';
3
+ import LegacyIcon from 'react-native-vector-icons/Feather';
4
+ import HomeIcon from '@controleonline/../../src/components/HomeIcon';
4
5
  import {useTheme} from './DefaultProvider';
5
6
  import RuntimeInfoFooter from './RuntimeInfoFooter';
6
7
  import {resolveMenuRouteParams} from '../utils/menuNavigation';
7
8
  import createStyles from './BottomNavigationBar.styles';
9
+ import softenStyles from './BottomNavigationBar.soft.styles';
10
+ import {resolveHomepagePalette} from '@controleonline/../../src/styles/homepagePalette';
8
11
 
9
12
  const BottomNavigationBar = ({
10
13
  navigation,
@@ -14,7 +17,11 @@ const BottomNavigationBar = ({
14
17
  colors = {},
15
18
  testID = 'bottom-navigation',
16
19
  useModernWebChromeProps = false,
20
+ appearance = 'soft',
21
+ placement = 'overlay',
17
22
  }) => {
23
+ const Icon = appearance === 'soft' ? HomeIcon : LegacyIcon;
24
+ colors = appearance === 'soft' ? resolveHomepagePalette(colors) : colors;
18
25
  const theme = useTheme?.() || {};
19
26
  const runtimeFooter = theme?.runtimeFooter || null;
20
27
  const registerBottomNavigation =
@@ -31,14 +38,14 @@ const BottomNavigationBar = ({
31
38
 
32
39
  const styles = useMemo(
33
40
  () =>
34
- createStyles({
41
+ softenStyles(createStyles({
35
42
  activeBackground,
36
43
  dockBackground,
37
44
  dockBorder,
38
45
  dockShadow,
39
46
  activeBorder,
40
47
  useModernWebChromeProps,
41
- }),
48
+ }), colors, appearance),
42
49
  [
43
50
  activeBackground,
44
51
  activeBorder,
@@ -46,6 +53,7 @@ const BottomNavigationBar = ({
46
53
  dockBorder,
47
54
  dockShadow,
48
55
  useModernWebChromeProps,
56
+ appearance,
49
57
  ],
50
58
  );
51
59
 
@@ -97,9 +105,7 @@ const BottomNavigationBar = ({
97
105
 
98
106
  const routeItems = Array.isArray(items) ? items : [];
99
107
  const knownRoute = routeItems.some(item => item?.route === activeRouteName);
100
- const effectiveActiveRoute = knownRoute
101
- ? activeRouteName
102
- : routeItems[0]?.route || '';
108
+ const effectiveActiveRoute = knownRoute ? activeRouteName : '';
103
109
 
104
110
  const navigateTo = item => {
105
111
  try {
@@ -116,15 +122,18 @@ const BottomNavigationBar = ({
116
122
  ? [styles.host, styles.hostPointerEventsBoxNone]
117
123
  : styles.host;
118
124
  const footerProps = {
125
+ appearance,
119
126
  appVersion: runtimeFooter?.appVersion,
120
- colors: runtimeFooter?.colors || {},
127
+ colors: appearance === 'soft'
128
+ ? {...runtimeFooter?.colors, footerBackground: colors.footerBackground, footerText: colors.footerText}
129
+ : runtimeFooter?.colors || {},
121
130
  mainCompany: runtimeFooter?.mainCompany,
122
131
  device: runtimeFooter?.device,
123
132
  useModernWebChromeProps,
124
133
  };
125
134
 
126
135
  return (
127
- <View {...hostProps} style={hostStyle}>
136
+ <View {...hostProps} style={[hostStyle, placement === 'inline' ? {position: 'relative', flexShrink: 0, zIndex: 0} : null]}>
128
137
  <View style={styles.stack}>
129
138
  <View style={styles.dock} testID={testID}>
130
139
  <View style={styles.itemsRow}>
@@ -141,6 +150,8 @@ const BottomNavigationBar = ({
141
150
  <Pressable
142
151
  key={item.route}
143
152
  accessibilityRole="button"
153
+ accessibilityLabel={item.label}
154
+ accessibilityState={{selected: isActive, disabled: Boolean(isDisabled)}}
144
155
  disabled={isDisabled}
145
156
  onPress={() => navigateTo(item)}
146
157
  style={({pressed}) => [
@@ -0,0 +1,29 @@
1
+ import {Platform} from 'react-native';
2
+
3
+ // Shared compact chrome on every route; explicit legacy consumers can opt out.
4
+ export default (styles, colors, appearance) => appearance !== 'soft' ? styles : {
5
+ ...styles,
6
+ dock: [styles.dock, {
7
+ paddingHorizontal: 0,
8
+ paddingTop: 0,
9
+ paddingBottom: 0,
10
+ borderWidth: 0,
11
+ borderTopWidth: 1,
12
+ borderColor: colors.navigationBorder,
13
+ borderTopLeftRadius: 0,
14
+ borderTopRightRadius: 0,
15
+ elevation: 0,
16
+ shadowOpacity: 0,
17
+ ...(Platform.OS === 'web' ? {boxShadow: 'none'} : {}),
18
+ }],
19
+ itemsRow: [styles.itemsRow, {minHeight: 56, paddingHorizontal: 8, paddingVertical: 4}],
20
+ item: [styles.item, {minHeight: 46, paddingVertical: 3, borderRadius: 10}],
21
+ iconWrap: [styles.iconWrap, {width: 22, height: 22, marginBottom: 2}],
22
+ itemLabel: [styles.itemLabel, {fontSize: 11, lineHeight: 14, fontWeight: '400'}],
23
+ footerSlot: [styles.footerSlot, {
24
+ borderTopWidth: 1,
25
+ borderColor: colors.navigationBorder,
26
+ paddingHorizontal: 0,
27
+ minHeight: 28,
28
+ }],
29
+ };
@@ -12,6 +12,10 @@ const styles = StyleSheet.create({
12
12
  shell: {
13
13
  flex: 1,
14
14
  },
15
+ translationPending: {
16
+ visibility: 'hidden',
17
+ opacity: 0,
18
+ },
15
19
  content: {
16
20
  flex: 1,
17
21
  minHeight: 0,
@@ -395,15 +395,18 @@ export const DefaultProvider = ({
395
395
 
396
396
  useWebThemePalette({resolveDomainThemeColors, baseThemeColors, mainCompany, resolveThemePalette, runtimeColors, applyPaletteToRuntimeColors, applyThemeCssVariables, actions, currentCompany, isShopClientApp});
397
397
 
398
- if (requiresTranslateBootstrap && !isTranslateBootstrapReady) {
399
- return <View style={providerStyles.loadingContainer} />;
400
- }
398
+ const translationPending = requiresTranslateBootstrap && !isTranslateBootstrapReady;
401
399
 
402
400
  return (
403
401
  <ThemeContext.Provider value={themeContextValue}>
404
402
  <View
403
+ pointerEvents={translationPending ? 'none' : 'auto'}
404
+ aria-hidden={translationPending}
405
405
  style={[
406
406
  providerStyles.shell,
407
+ // Keep navigation mounted while translations load. Unmounting it
408
+ // resets the login route and repeatedly registers the bottom bar.
409
+ translationPending && providerStyles.translationPending,
407
410
  {
408
411
  backgroundColor: colors?.background || runtimeColors.background,
409
412
  },
@@ -1,45 +1,53 @@
1
- import React, {useEffect} from 'react';
2
- import {useStore} from '@store';
3
-
4
- const ProductCatalogCacheService = () => {
5
- const categoriesStore = useStore('categories');
6
- const productsStore = useStore('products');
7
-
8
- const categoryActions = categoriesStore.actions;
9
- const productActions = productsStore.actions;
10
-
11
- const {messages, message} = categoriesStore.getters;
12
-
13
- useEffect(() => {
14
- if (
15
- messages &&
16
- messages.length > 0 &&
17
- (!message || Object.keys(message).length === 0)
18
- ) {
19
- const nextMessages = [...messages];
20
- categoryActions.setMessage(nextMessages.pop());
21
- categoryActions.setMessages(nextMessages);
22
- }
23
- }, [categoryActions, message, messages]);
24
-
25
- useEffect(() => {
26
- if (!message || Object.keys(message).length === 0) {
27
- return;
28
- }
29
-
30
- if (message?.command !== 'clear-product-cache') {
31
- categoryActions.setMessage(null);
32
- return;
33
- }
34
-
35
- localStorage.setItem('categories', JSON.stringify([]));
36
- localStorage.removeItem('categories-company');
37
- categoryActions.setItems([]);
38
- productActions.setItems([]);
39
- categoryActions.setMessage(null);
40
- }, [categoryActions, message, productActions]);
41
-
42
- return null;
43
- };
44
-
45
- export default ProductCatalogCacheService;
1
+ import React, {useEffect} from 'react';
2
+ import {useStore} from '@store';
3
+ import {invalidateProductCatalogCache} from '../utils/productCatalogCacheRevision';
4
+
5
+ const ProductCatalogCacheService = () => {
6
+ const categoriesStore = useStore('categories');
7
+ const productsStore = useStore('products');
8
+
9
+ const categoryActions = categoriesStore.actions;
10
+ const productActions = productsStore.actions;
11
+
12
+ const {messages, message} = categoriesStore.getters;
13
+
14
+ useEffect(() => {
15
+ if (
16
+ messages &&
17
+ messages.length > 0 &&
18
+ (!message || Object.keys(message).length === 0)
19
+ ) {
20
+ const nextMessages = [...messages];
21
+ categoryActions.setMessage(nextMessages.pop());
22
+ categoryActions.setMessages(nextMessages);
23
+ }
24
+ }, [categoryActions, message, messages]);
25
+
26
+ useEffect(() => {
27
+ if (!message || Object.keys(message).length === 0) {
28
+ return;
29
+ }
30
+
31
+ if (message?.command !== 'clear-product-cache') {
32
+ categoryActions.setMessage(null);
33
+ return;
34
+ }
35
+
36
+ invalidateProductCatalogCache();
37
+ for (const context of ['products', 'supplies']) {
38
+ localStorage.removeItem(`categories:${context}`);
39
+ localStorage.removeItem(`categories-company:${context}`);
40
+ }
41
+ localStorage.setItem('categories', JSON.stringify([]));
42
+ localStorage.removeItem('categories-company');
43
+ categoryActions.setItems([]);
44
+ productActions.setItems([]);
45
+ categoryActions.setReload?.(true);
46
+ productActions.setReload?.(true);
47
+ categoryActions.setMessage(null);
48
+ }, [categoryActions, message, productActions]);
49
+
50
+ return null;
51
+ };
52
+
53
+ export default ProductCatalogCacheService;
@@ -19,6 +19,8 @@ const {
19
19
  } = require('@controleonline/ui-common/src/react/utils/bottomNavigationToolbar');
20
20
 
21
21
  const RuntimeBottomNavigationBar = ({
22
+ appearance = 'soft',
23
+ placement = 'overlay',
22
24
  activeRouteName: activeRouteNameProp,
23
25
  colors: colorsOverride,
24
26
  disabled: disabledOverride,
@@ -41,13 +43,16 @@ const RuntimeBottomNavigationBar = ({
41
43
  const peopleGetters = peopleStore?.getters || {};
42
44
  const currentCompany = peopleGetters.currentCompany || {};
43
45
  const themeColors = themeGetters.colors || {};
44
- const [runtimeMenus, setRuntimeMenus] = useState([]);
46
+ const [menuState, setMenuState] = useState({scope: '', menus: []});
45
47
  const preset = presetKey ? getBottomNavigationPreset(presetKey) : null;
46
48
  const routeAliases = routeAliasesProp || preset?.routeAliases || {};
47
49
  const appType = normalizeAppType(app_type);
50
+ const menuScope = JSON.stringify([appType, currentCompany?.id, menuType, presetKey]);
51
+ const runtimeMenus = menuState.scope === menuScope ? menuState.menus : [];
48
52
 
49
53
  // Always consult menus-people for role-aware toolbar; preset is fallback only.
50
54
  useEffect(() => {
55
+ const setRuntimeMenus = menus => setMenuState({scope: menuScope, menus});
51
56
  if (!currentCompany?.id || !menuType) {
52
57
  setRuntimeMenus(
53
58
  resolveToolbarRuntimeMenus({
@@ -104,7 +109,7 @@ const RuntimeBottomNavigationBar = ({
104
109
  return () => {
105
110
  cancelled = true;
106
111
  };
107
- }, [appType, currentCompany?.id, menuType, preset, presetKey]);
112
+ }, [appType, currentCompany?.id, menuType, preset, presetKey, menuScope]);
108
113
 
109
114
  const colors = useMemo(() => {
110
115
  if (colorsOverride) {
@@ -150,15 +155,19 @@ const RuntimeBottomNavigationBar = ({
150
155
  const knownRoute = navItems.some(item => item.route === resolvedActiveRoute);
151
156
  const effectiveActiveRoute = knownRoute
152
157
  ? resolvedActiveRoute
153
- : navItems[0]?.route || activeRouteName;
158
+ : activeRouteName;
154
159
 
155
160
  const isDisabled =
156
161
  typeof disabledOverride === 'boolean'
157
162
  ? disabledOverride
158
163
  : !currentCompany || Object.keys(currentCompany).length === 0;
159
164
 
165
+ if (!navItems.length) return null;
166
+
160
167
  return (
161
168
  <BottomNavigationBar
169
+ appearance={appearance}
170
+ placement={placement}
162
171
  activeRouteName={effectiveActiveRoute}
163
172
  colors={colors}
164
173
  disabled={isDisabled}
@@ -0,0 +1,5 @@
1
+ // Keep the native safe area outside the centered content row.
2
+ export default (appearance, platform, bottomInset) => appearance === 'soft' ? {
3
+ contentPadding: 4,
4
+ safeAreaBottom: platform === 'web' ? 0 : Math.max(Number(bottomInset) || 0, 0),
5
+ } : null;
@@ -22,6 +22,7 @@ import {
22
22
  } from '@controleonline/ui-common/src/react/utils/runtimeFooter';
23
23
  import styles from './RuntimeInfoFooter.styles';
24
24
  import RuntimeFooterMarqueeText from './RuntimeFooterMarqueeText';
25
+ import resolveHomeFooterSpacing from './RuntimeInfoFooter.home.spacing';
25
26
 
26
27
  const ROTATION_INTERVAL_MS = 4000;
27
28
  const COMPACT_BREAKPOINT = 720;
@@ -66,6 +67,7 @@ const RuntimeInfoFooter = ({
66
67
  device,
67
68
  colors,
68
69
  useModernWebChromeProps = false,
70
+ appearance = 'legacy',
69
71
  }) => {
70
72
  const {width} = useWindowDimensions();
71
73
  const insets = useSafeAreaInsets();
@@ -258,6 +260,7 @@ const RuntimeInfoFooter = ({
258
260
  : inlineText
259
261
  ) || primaryText || device?.id || '';
260
262
  const backgroundColor = colors?.footerBackground;
263
+ const homeSpacing = resolveHomeFooterSpacing(appearance, Platform.OS, insets.bottom);
261
264
  const borderColor = colors?.footerBorder;
262
265
  const textColor = resolveVisibleFooterTextColor(colors);
263
266
  const loadingColor = colors?.footerLink || colors?.primary || textColor;
@@ -267,7 +270,9 @@ const RuntimeInfoFooter = ({
267
270
  : styles.shell;
268
271
 
269
272
  return (
270
- <View {...shellProps} style={shellStyle}>
273
+ <View {...shellProps} style={[shellStyle, homeSpacing && {
274
+ paddingBottom: homeSpacing.safeAreaBottom, backgroundColor,
275
+ }]}>
271
276
  <View
272
277
  testID="runtime-info-footer"
273
278
  style={[
@@ -276,7 +281,8 @@ const RuntimeInfoFooter = ({
276
281
  {
277
282
  backgroundColor,
278
283
  borderTopColor: borderColor,
279
- paddingBottom: bottomInset,
284
+ paddingBottom: homeSpacing ? homeSpacing.contentPadding : bottomInset,
285
+ ...(homeSpacing && !showDebugInfo ? {paddingTop: homeSpacing.contentPadding} : {}),
280
286
  },
281
287
  ]}>
282
288
  <View style={styles.primaryRow}>
@@ -1,3 +1,4 @@
1
+ import {readStoredSessionToken} from '../../utils/storedSessionToken';
1
2
  import React, {useEffect, useMemo, useState} from 'react';
2
3
  import {Image, Platform, StyleSheet, Text, View} from 'react-native';
3
4
  import {env as APP_ENV} from '@env';
@@ -14,18 +15,6 @@ const isBackendDownloadUrl = uri =>
14
15
  /** Session cache: gravatar URL → true (ok) | false (missing). Avoids repeat probes. */
15
16
  const gravatarProbeCache = new Map();
16
17
 
17
- const readSessionToken = () => {
18
- if (typeof localStorage === 'undefined' || !localStorage?.getItem) {
19
- return '';
20
- }
21
-
22
- try {
23
- const session = JSON.parse(localStorage.getItem('session') || '{}');
24
- return normalizeUrl(session?.api_key || session?.token);
25
- } catch {
26
- return '';
27
- }
28
- };
29
18
 
30
19
  /**
31
20
  * Probe Gravatar (d=404) via fetch BEFORE exposing URL to <Image>.
@@ -86,7 +75,7 @@ const useDisplayUri = uri => {
86
75
  return;
87
76
  }
88
77
 
89
- const token = readSessionToken();
78
+ const token = readStoredSessionToken();
90
79
 
91
80
  // Authenticated backend download on web
92
81
  if (isBackendDownloadUrl(next) && Platform.OS === 'web' && token) {
@@ -156,6 +145,7 @@ const useDisplayUri = uri => {
156
145
 
157
146
  const UserAvatar = ({
158
147
  imageUrl,
148
+ imageHeaders,
159
149
  email,
160
150
  name,
161
151
  size = 40,
@@ -215,7 +205,8 @@ const UserAvatar = ({
215
205
  <View style={containerStyle}>
216
206
  {displayUri ? (
217
207
  <Image
218
- source={{uri: displayUri}}
208
+ source={imageHeaders && Platform.OS !== 'web' && displayUri === imageUrl
209
+ ? {uri: displayUri, headers: imageHeaders} : {uri: displayUri}}
219
210
  style={styles.image}
220
211
  onError={() => setSourceIndex(index => index + 1)}
221
212
  />
@@ -0,0 +1,49 @@
1
+ import {useEffect, useRef, useState} from 'react';
2
+ import {Platform} from 'react-native';
3
+
4
+ const nativeStorage = () => {
5
+ const module = require('@react-native-async-storage/async-storage');
6
+ return module.default || module;
7
+ };
8
+ const deviceStorage = {
9
+ getItem: key => Platform.OS === 'web' ? globalThis.localStorage?.getItem(key) : nativeStorage().getItem(key),
10
+ setItem: (key, value) => Platform.OS === 'web' ? globalThis.localStorage.setItem(key, value) : nativeStorage().setItem(key, value),
11
+ };
12
+
13
+ // Visibility is personal device state. It never changes server menu permissions.
14
+ export default function useBottomNavigationPreference({enabled, principal, appType, storage = deviceStorage}) {
15
+ const key = enabled && principal && appType
16
+ ? `co.bottom-navigation.v1:${encodeURIComponent(appType)}:${encodeURIComponent(principal)}` : '';
17
+ const current = useRef(key);
18
+ current.current = key;
19
+ const [state, setState] = useState({key: '', ready: false, remembered: true, session: null, error: ''});
20
+ useEffect(() => {
21
+ current.current = key;
22
+ let cancelled = false;
23
+ setState({key, ready: false, remembered: true, session: null, error: ''});
24
+ if (key) Promise.resolve().then(() => storage.getItem(key)).then(value => {
25
+ if (!cancelled) setState({key, ready: true, remembered: value !== 'hidden', session: null, error: ''});
26
+ }).catch(() => {
27
+ if (!cancelled) setState({key, ready: true, remembered: true, session: null, error: 'Não foi possível recuperar a preferência da barra.'});
28
+ });
29
+ return () => {cancelled = true; if (current.current === key) current.current = null;};
30
+ }, [key, storage]);
31
+ const ready = Boolean(key && state.key === key && state.ready);
32
+ const setSessionVisible = visible => {
33
+ if (ready) setState(previous => ({...previous, session: Boolean(visible), error: ''}));
34
+ };
35
+ const setRememberedVisible = async visible => {
36
+ if (!ready) return false;
37
+ try {
38
+ await storage.setItem(key, visible ? 'visible' : 'hidden');
39
+ if (current.current !== key) return false;
40
+ setState(previous => ({...previous, remembered: Boolean(visible), session: null, error: ''}));
41
+ return true;
42
+ } catch {
43
+ if (current.current === key) setState(previous => ({...previous, error: 'Não foi possível lembrar a preferência neste aparelho.'}));
44
+ return false;
45
+ }
46
+ };
47
+ return {ready, visible: ready && (state.session ?? state.remembered), rememberedVisible: ready ? state.remembered : true,
48
+ error: state.key === key ? state.error : '', setSessionVisible, setRememberedVisible};
49
+ }