Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
import '@testing-library/react-native/extend-expect';

// Mock react-native-safe-area-context — its source build reads StyleSheet at import time,
// which explodes in suites that stub react-native with a minimal factory (e.g. navigation tests).
jest.mock('react-native-safe-area-context', () => {
const React = require('react');

const SafeAreaView = ({ children }: any) => React.createElement(React.Fragment, null, children);

return {
SafeAreaView,
SafeAreaProvider: ({ children }: any) => children,
Comment on lines +8 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use a typed interface for mock children.

The two mock components use any. This removes type checking for children. Define one MockChildrenProps interface with children?: ReactNode and use it for both components.

As per coding guidelines, **/*.{ts,tsx} requires precise types and interfaces and forbids any.

Proposed fix
+import type { ReactNode } from 'react';
+
+interface MockChildrenProps {
+  children?: ReactNode;
+}
+
 const SafeAreaView = ({ children }: any) => React.createElement(React.Fragment, null, children);
+const SafeAreaView = ({ children }: MockChildrenProps) => React.createElement(React.Fragment, null, children);

-    SafeAreaProvider: ({ children }: any) => children,
+    SafeAreaProvider: ({ children }: MockChildrenProps) => children,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jest-setup.ts` around lines 8 - 12, Replace the any-typed props in
SafeAreaView and SafeAreaProvider with a shared MockChildrenProps interface
defining optional children as ReactNode, and use that interface for both mock
component parameters.

Source: Coding guidelines

useSafeAreaInsets: jest.fn(() => ({ top: 0, bottom: 0, left: 0, right: 0 })),
useSafeAreaFrame: jest.fn(() => ({ x: 0, y: 0, width: 375, height: 667 })),
initialWindowMetrics: {
insets: { top: 0, bottom: 0, left: 0, right: 0 },
frame: { x: 0, y: 0, width: 375, height: 667 },
},
Comment on lines +5 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Expect: every runtime import is provided by the mock.
rg -n -C 2 "react-native-safe-area-context" \
  --glob '*.ts' --glob '*.tsx' --glob '*.js' .

Repository: Resgrid/Unit

Length of output: 7338


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- jest-setup.ts ---'
cat -n jest-setup.ts | sed -n '1,35p'

printf '%s\n' '--- named imports from react-native-safe-area-context ---'
python3 - <<'PY'
import re
from pathlib import Path

for path in Path('.').rglob('*'):
    if not path.is_file() or path.suffix not in {'.ts', '.tsx', '.js'}:
        continue
    try:
        text = path.read_text()
    except UnicodeDecodeError:
        continue
    for match in re.finditer(
        r"""(?:import\s+(?:type\s+)?\{([^}]+)\}\s+from|require\()\s*['"]react-native-safe-area-context['"]""",
        text,
    ):
        if match.group(1) is not None:
            names = [
                re.sub(r'\s+', ' ', item.strip())
                for item in match.group(1).split(',')
                if item.strip()
            ]
            print(f'{path}: {", ".join(names)}')
        else:
            print(f'{path}: require(...)')
PY

printf '%s\n' '--- module export references in source and tests ---'
rg -n -C 1 \
  "SafeArea(View|Provider)|useSafeArea(Insets|Frame)|initialWindowMetrics|SafeAreaListener|useSafeArea" \
  --glob '*.ts' --glob '*.tsx' --glob '*.js' .

Repository: Resgrid/Unit

Length of output: 20016


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- non-type safe-area imports outside the repository source ---'
rg -n -C 2 \
  "from ['\"]react-native-safe-area-context['\"]|require\(['\"]react-native-safe-area-context['\"]\)" \
  node_modules \
  --glob '!**/*.d.ts' \
  --glob '!**/example/**' \
  | sed -n '1,240p'

printf '%s\n' '--- package resolution and Jest configuration ---'
rg -n -C 3 \
  "jest-setup|setupFiles|setupFilesAfterEnv|react-native-screens|expo-router|jest-expo" \
  package.json jest.config.* app.json app.config.* \
  --glob '!node_modules/**' 2>/dev/null || true

Repository: Resgrid/Unit

Length of output: 50369


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
import re
from pathlib import Path

pattern = re.compile(
    r"""(?:import\s+(?:(?:type\s+)?\{([^}]+)\}|([^;\n]+?))\s+from|require\()\s*['"]react-native-safe-area-context['"]"""
)
seen = set()

for path in Path('node_modules').rglob('*'):
    if not path.is_file() or path.suffix in {'.map', '.d.ts'}:
        continue
    try:
        text = path.read_text(errors='ignore')
    except OSError:
        continue
    for line_no, line in enumerate(text.splitlines(), 1):
        if 'react-native-safe-area-context' not in line:
            continue
        match = pattern.search(line)
        if match:
            names = (match.group(1) or match.group(2) or 'require(...)').strip()
            item = (str(path), line_no, names, line.strip())
            if item not in seen:
                seen.add(item)
                print(f'{item[0]}:{item[1]}: {item[3]}')
PY

printf '%s\n' '--- Jest configuration ---'
for file in package.json jest.config.js jest.config.ts jest.config.cjs jest.config.mjs; do
  if [ -f "$file" ]; then
    printf '%s\n' "--- $file ---"
    rg -n -C 4 \
      "jest-setup|setupFiles|setupFilesAfterEnv|preset|react-native-screens|expo-router" \
      "$file" || true
  fi
done

Repository: Resgrid/Unit

Length of output: 8749


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
import re
from pathlib import Path
from collections import defaultdict

uses = defaultdict(set)
for path in Path('node_modules').rglob('*'):
    if not path.is_file() or path.suffix in {'.map', '.d.ts'}:
        continue
    if 'example' in path.parts:
        continue
    try:
        text = path.read_text(errors='ignore')
    except OSError:
        continue
    if 'react-native-safe-area-context' not in text:
        continue

    for name in re.findall(r'_reactNativeSafeAreaContext\.([A-Za-z_$][\w$]*)', text):
        uses[str(path)].add(name)

    for line in text.splitlines():
        if 'react-native-safe-area-context' in line and (
            'import ' in line or 'require(' in line
        ):
            uses[str(path)].add(f'IMPORT: {line.strip()}')

for path, names in sorted(uses.items()):
    print(path)
    for name in sorted(names):
        print(f'  {name}')
PY

printf '%s\n' '--- direct references to missing candidate exports ---'
rg -n \
  "SafeAreaInsetsContext|SafeAreaFrameContext|useSafeAreaFrame|initialWindowMetrics|SafeAreaProvider|SafeAreaView|useSafeAreaInsets" \
  node_modules/expo-router/build node_modules/react-native-css/dist node_modules/@gluestack-ui/core/lib node_modules/react-native-screens/lib \
  --glob '!**/*.map' \
  --glob '!**/*.d.ts' \
  | sed -n '1,240p'

Repository: Resgrid/Unit

Length of output: 25314


Add SafeAreaInsetsContext to the global safe-area mock. Expo Router accesses SafeAreaInsetsContext.Consumer; the mock currently returns undefined, which can fail navigation suites during rendering.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jest-setup.ts` around lines 5 - 18, Add SafeAreaInsetsContext to the
react-native-safe-area-context mock in jest.mock, exposing a Consumer compatible
with the zero-inset mock values so Expo Router can render safely. Preserve the
existing SafeAreaView, SafeAreaProvider, hook, and initialWindowMetrics
behavior.

};
});

// Mock @sentry/react-native — native module (RNSentry) is unavailable in Jest
jest.mock('@sentry/react-native', () => ({
captureException: jest.fn(),
Expand Down
5 changes: 3 additions & 2 deletions src/components/chat/new-conversation-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,10 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo
);

const createGroup = useCallback(async () => {
if (!groupName.trim() || selected.size === 0) return;
if (selected.size === 0) return;
setSubmitting(true);
try {
// Name is optional — the server auto-names the group after its members when empty.
const response = await createAdHocChannel({ Name: groupName.trim(), MemberUserIds: Array.from(selected) });
if (response.Data?.ChatChannelId) {
onCreated(response.Data.ChatChannelId);
Expand Down Expand Up @@ -191,7 +192,7 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo
)}

{mode === 'group' ? (
<Button className="mb-2 w-full bg-primary-600" onPress={createGroup} isDisabled={submitting || !groupName.trim() || selected.size === 0}>
<Button className="mb-2 w-full bg-primary-600" onPress={createGroup} isDisabled={submitting || selected.size === 0}>
<Users size={18} color="#ffffff" />
<ButtonText className="ml-2">{t('chat.create_group_with', { count: selected.size })}</ButtonText>
</Button>
Expand Down
11 changes: 11 additions & 0 deletions src/hooks/__tests__/use-signalr-lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ describe('useSignalRLifecycle', () => {
const mockDisconnectUpdateHub = jest.fn();
const mockConnectGeolocationHub = jest.fn();
const mockDisconnectGeolocationHub = jest.fn();
const mockConnectChatHub = jest.fn();
const mockDisconnectChatHub = jest.fn();
Comment on lines +20 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 6 '\b(connectChatHub|disconnectChatHub)\b' src/hooks src/stores

Repository: Resgrid/Unit

Length of output: 13383


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- test file ---'
cat -n src/hooks/__tests__/use-signalr-lifecycle.test.tsx | sed -n '1,190p'

printf '%s\n' '--- hook implementation ---'
cat -n src/hooks/use-signalr-lifecycle.ts | sed -n '1,155p'

printf '%s\n' '--- relevant test assertions ---'
rg -n -C 4 'mockConnectChatHub|mockDisconnectChatHub|Promise\.allSettled|reject|resolve|connectChatHub|disconnectChatHub' \
  src/hooks/__tests__/use-signalr-lifecycle.test.tsx

Repository: Resgrid/Unit

Length of output: 16623


🏁 Script executed:

node - <<'JS'
const plainMockResult = undefined;
const promiseMockResult = Promise.resolve(undefined);

Promise.allSettled([plainMockResult, promiseMockResult]).then((results) => {
  console.log(JSON.stringify(results));
});
JS

Repository: Resgrid/Unit

Length of output: 198


Make the Chat hub mocks Promise-compatible.

The hook currently handles plain undefined values with Promise.allSettled, so existing tests do not fail. However, the production methods return Promise<void>. Use jest.fn().mockResolvedValue(undefined) to preserve the async contract and support completion or rejection tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/__tests__/use-signalr-lifecycle.test.tsx` around lines 20 - 21,
Update the mockConnectChatHub and mockDisconnectChatHub Jest mocks to use
mockResolvedValue(undefined), preserving the production Promise<void> contract
and enabling async completion or rejection tests.


// Create shared state for app lifecycle that can be updated
let appLifecycleState = {
Expand Down Expand Up @@ -48,15 +50,21 @@ describe('useSignalRLifecycle', () => {
disconnectUpdateHub: mockDisconnectUpdateHub,
connectGeolocationHub: mockConnectGeolocationHub,
disconnectGeolocationHub: mockDisconnectGeolocationHub,
connectChatHub: mockConnectChatHub,
disconnectChatHub: mockDisconnectChatHub,
isUpdateHubConnected: false,
isGeolocationHubConnected: false,
isChatHubConnected: false,
} as any) : {
connectUpdateHub: mockConnectUpdateHub,
disconnectUpdateHub: mockDisconnectUpdateHub,
connectGeolocationHub: mockConnectGeolocationHub,
disconnectGeolocationHub: mockDisconnectGeolocationHub,
connectChatHub: mockConnectChatHub,
disconnectChatHub: mockDisconnectChatHub,
isUpdateHubConnected: false,
isGeolocationHubConnected: false,
isChatHubConnected: false,
} as any);

// Also mock getState for direct store access
Expand All @@ -65,8 +73,11 @@ describe('useSignalRLifecycle', () => {
disconnectUpdateHub: mockDisconnectUpdateHub,
connectGeolocationHub: mockConnectGeolocationHub,
disconnectGeolocationHub: mockDisconnectGeolocationHub,
connectChatHub: mockConnectChatHub,
disconnectChatHub: mockDisconnectChatHub,
isUpdateHubConnected: false,
isGeolocationHubConnected: false,
isChatHubConnected: false,
});

// Mock useAppLifecycle to return shared state
Expand Down
10 changes: 10 additions & 0 deletions src/lib/__tests__/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import { Platform, Linking } from 'react-native';
import { describe, expect, it, beforeEach, afterEach } from '@jest/globals';
import { openMapsWithDirections, openMapsWithAddress } from '../navigation';

// Mock expo-router — the real module pulls in its vendored react-navigation tree, which
// needs far more of react-native than the minimal stub below provides.
jest.mock('expo-router', () => ({
router: {
push: jest.fn(),
replace: jest.fn(),
navigate: jest.fn(),
},
}));

// Mock React Native modules
jest.mock('react-native', () => ({
Platform: {
Expand Down
6 changes: 6 additions & 0 deletions src/services/__tests__/push-notification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ jest.mock('react-native', () => ({
},
}));

// Mock the navigation lib — the real module imports expo-router, whose import chain
// needs far more of react-native/expo than the minimal stubs above provide.
jest.mock('@/lib/navigation', () => ({
routerPushWithRetry: jest.fn().mockResolvedValue(undefined),
}));

// Mock other dependencies
jest.mock('@/lib/logging', () => ({
logger: {
Expand Down
62 changes: 62 additions & 0 deletions src/stores/chat/__tests__/hub-invoke-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* JoinChannel(string channelId, int? asUnitId)
* Typing(string channelId, string displayName, bool isTyping, int? asUnitId)
* MarkRead(string channelId, long seq, int? asUnitId)
* SetActiveChannel(string channelId, int? asUnitId)
*/
const mockInvoke = jest.fn().mockResolvedValue(undefined);

Expand Down Expand Up @@ -75,6 +76,18 @@ describe('chat hub invocations', () => {
expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'JoinChannel', 'channel-1', 42);
});

it('sends both SetActiveChannel arguments', () => {
useChatStore.getState().setActiveChannel('channel-1');

expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', 'channel-1', 42);
});

it('clears the active channel marker with a null channelId and the unit id', () => {
useChatStore.getState().setActiveChannel(null);

expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', null, 42);
});

it('sends all four Typing arguments in hub order', () => {
useChatStore.getState().sendTyping('channel-1', true);

Expand Down Expand Up @@ -114,6 +127,55 @@ describe('chat hub invocations', () => {
});
});

describe('active-channel marker resynchronization', () => {
// syncActiveChannelMarker settles on the microtask queue; two ticks drain it.
const flush = async () => {
await Promise.resolve();
await Promise.resolve();
};

beforeEach(async () => {
mockInvoke.mockClear();
mockInvoke.mockResolvedValue(undefined);
useChatStore.getState().reset();
await flush();
mockInvoke.mockClear();
});

it('re-asserts a non-null marker on reconnect', async () => {
useChatStore.getState().setActiveChannel('channel-1');
await flush();
mockInvoke.mockClear();

useChatStore.getState().handleChatConnected();

expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', 'channel-1', 42);
});

it('retries a null marker that failed to send once reconnected', async () => {
mockInvoke.mockRejectedValue(new Error('disconnected'));
useChatStore.getState().setActiveChannel(null);
await flush();
mockInvoke.mockClear();
mockInvoke.mockResolvedValue(undefined);

useChatStore.getState().handleChatConnected();

expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', null, 42);
});

it('does not resend a null marker the hub already confirmed', async () => {
useChatStore.getState().setActiveChannel(null);
await flush();
mockInvoke.mockClear();

useChatStore.getState().handleChatConnected();
await flush();

expect(mockInvoke).not.toHaveBeenCalledWith('chatHub', 'SetActiveChannel', expect.anything(), expect.anything());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
file="src/stores/chat/__tests__/hub-invoke-args.test.ts"
printf '%s\n' "== file context =="
sed -n '130,185p' "$file"
printf '%s\n' "== relevant symbols and calls =="
rg -n -C 3 "SetActiveChannel|mockInvoke|expect\\.anything|reconnect|disconnect|activeChannel" "$file" src/stores/chat src --glob '*.ts' --glob '*.tsx' | head -n 240
printf '%s\n' "== Jest matcher probe =="
node - <<'JS'
const matchers = require('expect/build/matchers');
const equals = require('`@jest/expect-utils`').equals;
const matcher = matchers.toHaveBeenCalledWith;
function check(actualArgs, expectedArgs) {
  const result = matcher.call({ equals, isNot: false, promise: '' }, { mock: { calls: [actualArgs] } }, expectedArgs);
  return result.pass;
}
for (const args of [[null, 42], ['channel', 42], [undefined, 42]]) {
  console.log(JSON.stringify(args), {
    negativeAnything: !check(args, ['chatHub', 'SetActiveChannel', expectAnything(), expectAnything()]),
    negativeNull42: !check(args, ['chatHub', 'SetActiveChannel', null, 42]),
  });
}
function expectAnything() {
  return {
    asymmetricMatch(value) {
      return value !== null && value !== undefined;
    },
    toString() { return 'Anything'; },
    getExpectedType() { return 'anything'; },
  };
}
JS

Repository: Resgrid/Unit

Length of output: 21777


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' "== active-channel synchronization implementation =="
sed -n '235,330p' src/stores/chat/store.ts
printf '%s\n' "== reconnect handler references =="
rg -n -C 8 "handleChatConnected|pendingActiveChannelSync|syncActiveChannelMarker" src/stores/chat/store.ts src/stores/chat --glob '*.ts' --glob '*.tsx'

Repository: Resgrid/Unit

Length of output: 22712


🌐 Web query:

Jest expect.anything null undefined toHaveBeenCalledWith documentation

💡 Result:

In Jest, the asymmetric matcher expect.anything is explicitly designed to match any value except for null or undefined [1][2]. Because null and undefined are considered "nothing" in this context, expect.anything acts as a validator that an argument is present and defined [1][3]. Regarding toHaveBeenCalledWith (and its alias toBeenCalledWith): 1. Null and Undefined Behavior: When using toHaveBeenCalledWith, these matchers utilize equality logic similar to toEqual rather than strict equality (toStrictEqual) [4][5][6]. As a result, certain comparisons may behave in ways that seem non-strict regarding undefined properties or arguments [4][5][7]. 2. Limitations: Because expect.anything specifically excludes null and undefined, it cannot be used to match those values [1][3]. If you need to match a parameter that could be null, undefined, or any other value, you cannot use expect.anything for that position [3]. 3. Intended Usage: The primary purpose of expect.anything is to assert that a mock function was called with a valid, non-null, and non-undefined argument [1][8]. If you find that your assertions are failing or passing unexpectedly due to the handling of null or undefined, consider using toStrictEqual if testing values directly, or creating a custom matcher if you require specific behavior that handles these values differently than the built-in Jest matchers [4][5][3].

Citations:


Match the null marker explicitly

expect.anything() excludes null, so this assertion passes when SetActiveChannel(null, 42) is called. Replace the broad matcher with null and 42.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/chat/__tests__/hub-invoke-args.test.ts` at line 175, Update the
SetActiveChannel assertion in the hub invocation test to explicitly match null
and 42 instead of using broad expect.anything() matchers, ensuring the
not-called check covers the null marker case.

Source: Coding guidelines

});
});

describe('incoming message normalization', () => {
beforeEach(() => {
useChatStore.setState({ messagesByChannel: {}, channels: [] });
Expand Down
28 changes: 28 additions & 0 deletions src/stores/chat/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,23 @@ async function safeInvoke(method: string, ...args: unknown[]): Promise<void> {
}
}

/** Active-channel marker the hub has not confirmed yet. Kept — null included, since
* null means "clear the marker" — until an invoke succeeds, so a send that failed
* while offline can be replayed on reconnect. */
let pendingActiveChannelSync: { channelId: string | null } | null = null;

async function syncActiveChannelMarker(channelId: string | null): Promise<void> {
const marker = { channelId };
pendingActiveChannelSync = marker;
try {
await signalRService.invoke(Env.CHAT_HUB_NAME, 'SetActiveChannel', channelId, activeUnitIdNumber() ?? null);
// Only clear if no newer marker superseded this one while in flight.
if (pendingActiveChannelSync === marker) pendingActiveChannelSync = null;
} catch (error) {
logger.debug({ message: 'chat: invoke SetActiveChannel skipped', context: { error } });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Non-compliant error log in the catch block embeds the operation name in the message string and omits relevant identifiers (channelId, unitId). Rule [3] requires error logs to include the operation name and relevant identifiers as structured fields for searchable telemetry; restructure to logger.error('SetActiveChannel failed', { op: 'SetActiveChannel', channelId, error }); or include channelId in the context object.

Kody rule violation: Include error context in structured logs

Prompt for LLM

File src/stores/chat/store.ts:

Line 261:

Non-compliant error log in the catch block embeds the operation name in the message string and omits relevant identifiers (channelId, unitId). Rule [3] requires error logs to include the operation name and relevant identifiers as structured fields for searchable telemetry; restructure to logger.error('SetActiveChannel failed', { op: 'SetActiveChannel', channelId, error }); or include channelId in the context object.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

}
}

export const useChatStore = create<ChatState>()(
persist(
(set, get) => ({
Expand Down Expand Up @@ -280,6 +297,10 @@ export const useChatStore = create<ChatState>()(

setActiveChannel: (channelId: string | null) => {
set({ activeChannelId: channelId });
// Tell the hub which conversation is on screen so it suppresses chat push
// notifications for that channel — including the unit-device push when the
// active unit id is supplied. A null channelId clears the marker.
void syncActiveChannelMarker(channelId ?? null);
},

// ------------------------------------------------------------------
Expand Down Expand Up @@ -854,6 +875,12 @@ export const useChatStore = create<ChatState>()(
void get().joinChannel(activeChannelId);
void get().loadNewerMessages(activeChannelId);
}
// Re-assert the active-channel marker so push suppression survives reconnects.
// A pending null (screen closed while offline) is flushed too, so the server
// stops suppressing push for a channel no longer on screen.
if (activeChannelId !== null || pendingActiveChannelSync !== null) {
void syncActiveChannelMarker(activeChannelId);
}
},

reset: () => {
Expand All @@ -862,6 +889,7 @@ export const useChatStore = create<ChatState>()(
lastTypingSentAt.clear();
lastMarkedSeq.clear();
pendingChatbotMessages.clear();
pendingActiveChannelSync = null;
clearChatbotTypingTimeout();
if (outboxDrainTimer) {
clearTimeout(outboxDrainTimer);
Expand Down
2 changes: 1 addition & 1 deletion src/translations/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "لماذا تبلغ عن هذا؟",
"flag_sensitive": "معلومات حساسة",
"flag_spam": "بريد عشوائي",
"group_name": "اسم المجموعة",
"group_name": "اسم المجموعة (اختياري)",
"is_typing": "{{name}} يكتب...",
"load_people_failed": "تعذر تحميل الأشخاص",
"message_deleted": "تم حذف هذه الرسالة",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Warum melden Sie dies?",
"flag_sensitive": "Sensible Informationen",
"flag_spam": "Spam",
"group_name": "Gruppenname",
"group_name": "Gruppenname (optional)",
"is_typing": "{{name}} schreibt...",
"load_people_failed": "Personen konnten nicht geladen werden",
"message_deleted": "Diese Nachricht wurde gelöscht",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Why are you reporting this?",
"flag_sensitive": "Sensitive information",
"flag_spam": "Spam",
"group_name": "Group name",
"group_name": "Group name (optional)",
"is_typing": "{{name}} is typing...",
"load_people_failed": "Could not load people",
"message_deleted": "This message was deleted",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "¿Por qué informas de esto?",
"flag_sensitive": "Información sensible",
"flag_spam": "Spam",
"group_name": "Nombre del grupo",
"group_name": "Nombre del grupo (opcional)",
"is_typing": "{{name}} está escribiendo...",
"load_people_failed": "No se pudieron cargar las personas",
"message_deleted": "Este mensaje fue eliminado",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Pourquoi le signalez-vous ?",
"flag_sensitive": "Informations sensibles",
"flag_spam": "Spam",
"group_name": "Nom du groupe",
"group_name": "Nom du groupe (optionnel)",
"is_typing": "{{name}} est en train d'écrire...",
"load_people_failed": "Impossible de charger les personnes",
"message_deleted": "Ce message a été supprimé",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Perché lo stai segnalando?",
"flag_sensitive": "Informazioni sensibili",
"flag_spam": "Spam",
"group_name": "Nome del gruppo",
"group_name": "Nome del gruppo (opzionale)",
"is_typing": "{{name}} sta scrivendo...",
"load_people_failed": "Impossibile caricare le persone",
"message_deleted": "Questo messaggio è stato eliminato",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Dlaczego to zgłaszasz?",
"flag_sensitive": "Informacje poufne",
"flag_spam": "Spam",
"group_name": "Nazwa grupy",
"group_name": "Nazwa grupy (opcjonalnie)",
"is_typing": "{{name}} pisze...",
"load_people_failed": "Nie można załadować osób",
"message_deleted": "Ta wiadomość została usunięta",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Varför rapporterar du detta?",
"flag_sensitive": "Känslig information",
"flag_spam": "Skräppost",
"group_name": "Gruppnamn",
"group_name": "Gruppnamn (valfritt)",
"is_typing": "{{name}} skriver...",
"load_people_failed": "Det gick inte att ladda personer",
"message_deleted": "Detta meddelande har raderats",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/uk.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Чому ви це повідомляєте?",
"flag_sensitive": "Конфіденційна інформація",
"flag_spam": "Спам",
"group_name": "Назва групи",
"group_name": "Назва групи (необов'язково)",
"is_typing": "{{name}} пише...",
"load_people_failed": "Не вдалося завантажити людей",
"message_deleted": "Це повідомлення було видалено",
Expand Down
Loading