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
140 changes: 140 additions & 0 deletions src/app/(app)/__tests__/init-session-generation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Signing out while app initialization is still awaiting must retire that run: a stale
* invocation may not mark the app initialized, connect the chat hub, or restart location
* tracking that the sign-out cleanup just stopped.
*
* The layout itself pulls in Mapbox, Novu, push notifications and the whole store graph,
* so the guard protocol is exercised through the same generation-token shape the layout
* uses rather than by rendering it.
*/
import { act, renderHook } from '@testing-library/react-native';
import React from 'react';

interface Deferred {
promise: Promise<void>;
resolve: () => void;
}

function deferred(): Deferred {
let resolve: () => void = () => undefined;
const promise = new Promise<void>((res) => {
resolve = res;
});
return { promise, resolve };
}

/** Mirrors the layout's initializeApp guard: generation captured at start, checked after each await. */
function useInitGuard(gate: Deferred, effects: { connectHub: jest.Mock; startLocation: jest.Mock; markInitialized: jest.Mock }) {
const initGeneration = React.useRef(0);
const isInitializing = React.useRef(false);

const initialize = React.useCallback(async () => {
if (isInitializing.current) return;
isInitializing.current = true;
const generation = (initGeneration.current += 1);
const isCurrentRun = () => initGeneration.current === generation;

try {
await gate.promise;
if (!isCurrentRun()) return;

effects.connectHub();
if (!isCurrentRun()) return;

effects.markInitialized();
if (!isCurrentRun()) return;

effects.startLocation();
} finally {
if (isCurrentRun()) {
isInitializing.current = false;
}
}
}, [gate, effects]);

const signOut = React.useCallback(() => {
initGeneration.current += 1;
isInitializing.current = false;
}, []);

return { initialize, signOut, isInitializing };
}

describe('app initialization session generation', () => {
const effects = { connectHub: jest.fn(), startLocation: jest.fn(), markInitialized: jest.fn() };

beforeEach(() => {
jest.clearAllMocks();
});

it('abandons an in-flight run when the session ends mid-initialization', async () => {
const gate = deferred();
const { result } = renderHook(() => useInitGuard(gate, effects));

let pending: Promise<void> = Promise.resolve();
act(() => {
pending = result.current.initialize();
});

// Sign-out lands while initialization is still awaiting its first step.
act(() => {
result.current.signOut();
});

await act(async () => {
gate.resolve();
await pending;
});

expect(effects.connectHub).not.toHaveBeenCalled();
expect(effects.markInitialized).not.toHaveBeenCalled();
expect(effects.startLocation).not.toHaveBeenCalled();
});
Comment on lines +70 to +92

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add a stale-session test for each initialization stage.

This helper has one await before connectHub. The layout has four awaits before its first generation check. The test therefore stays green when a stale layout run continues from core initialization into calls, rights, or feature-flag initialization.

Use deferred gates for each stage. Sign out after each gate resolves. Assert that the next stage and all later effects do not run.

As per coding guidelines, “Generate tests for all components, services and logic generated. Ensure tests run without errors and fix any issues.”

🤖 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/app/`(app)/__tests__/init-session-generation.test.tsx around lines 70 -
92, Add stale-session coverage for every await boundary in the initialization
flow exercised by useInitGuard and the layout: gate each stage with deferred
promises, sign out immediately after each gate resolves, then assert the next
stage and all subsequent effects are not called. Extend the existing “abandons
an in-flight run” test structure without changing the valid active-session path.

Source: Coding guidelines


it('completes normally when the session survives', async () => {
const gate = deferred();
const { result } = renderHook(() => useInitGuard(gate, effects));

let pending: Promise<void> = Promise.resolve();
act(() => {
pending = result.current.initialize();
});

await act(async () => {
gate.resolve();
await pending;
});

expect(effects.connectHub).toHaveBeenCalledTimes(1);
expect(effects.markInitialized).toHaveBeenCalledTimes(1);
expect(effects.startLocation).toHaveBeenCalledTimes(1);
});

it('frees the in-progress guard so the next sign-in can initialize', async () => {
const first = deferred();
const { result } = renderHook(() => useInitGuard(first, effects));

let pending: Promise<void> = Promise.resolve();
act(() => {
pending = result.current.initialize();
});
act(() => {
result.current.signOut();
});

// The new session starts before the retired run has settled.
let second: Promise<void> = Promise.resolve();
act(() => {
second = result.current.initialize();
});

await act(async () => {
first.resolve();
await Promise.all([pending, second]);

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 low

Batch operation failure risk: await Promise.all([pending, second]) causes a single rejection to fail the entire batch. Replace it with Promise.allSettled to properly handle per-item results for independent tasks.

Kody rule violation: Use Promise.allSettled for batch operations with partial failures

Prompt for LLM

File src/app/(app)/__tests__/init-session-generation.test.tsx:

Line 133:

Batch operation failure risk: `await Promise.all([pending, second])` causes a single rejection to fail the entire batch. Replace it with `Promise.allSettled` to properly handle per-item results for independent tasks.

Talk to Kody by mentioning @kody

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

});

// Exactly one run reached the effects: the current one.
expect(effects.markInitialized).toHaveBeenCalledTimes(1);
expect(effects.startLocation).toHaveBeenCalledTimes(1);
});
});
30 changes: 29 additions & 1 deletion src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export default function TabLayout() {
// Refs to track initialization state
const hasInitialized = useRef(false);
const isInitializing = useRef(false);
// Bumped on every initialization start and on sign-out. An in-flight run compares its
// captured value after each await, so a run belonging to a session that ended can no
// longer connect hubs or mark the app initialized.
const initGeneration = useRef(0);
const initTimedOut = useRef(false);
const hasHiddenSplash = useRef(false);
const lastSignedInStatus = useRef<string | null>(null);
Expand Down Expand Up @@ -116,6 +120,8 @@ export default function TabLayout() {

isInitializing.current = true;
initTimedOut.current = false;
const generation = (initGeneration.current += 1);
const isCurrentRun = () => initGeneration.current === generation;
logger.info({
message: 'Starting app initialization',
context: {
Expand Down Expand Up @@ -162,6 +168,8 @@ export default function TabLayout() {
context: { platform: Platform.OS },
});

if (!isCurrentRun()) return;

Comment on lines +171 to +172

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Guard and test every initialization stage. The first generation check occurs after several awaited operations, and the test model has only one awaited operation. A sign-out between production stages can therefore run stale work without a failing test.

  • src/app/(app)/_layout.tsx#L171-L172: check isCurrentRun() after each awaited initialization stage and before the next session-scoped operation.
  • src/app/(app)/__tests__/init-session-generation.test.tsx#L70-L92: add deferred stage gates and assert that sign-out prevents every subsequent stage.
📍 Affects 2 files
  • src/app/(app)/_layout.tsx#L171-L172 (this comment)
  • src/app/(app)/__tests__/init-session-generation.test.tsx#L70-L92
🤖 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/app/`(app)/_layout.tsx around lines 171 - 172, In
src/app/(app)/_layout.tsx#L171-L172, update the session initialization flow to
check isCurrentRun() after every awaited initialization stage and before each
subsequent session-scoped operation, returning immediately when the run is
stale. In src/app/(app)/__tests__/init-session-generation.test.tsx#L70-L92, add
deferred gates for each initialization stage and assert that signing out
prevents all later stages from executing.

Source: Coding guidelines

// Connect to SignalR after core initialization is complete
try {
await useSignalRStore.getState().connectUpdateHub();
Expand All @@ -177,6 +185,8 @@ export default function TabLayout() {
// Don't fail initialization if SignalR connection fails
}

if (!isCurrentRun()) return;

// Connect the realtime chat hub only when the Chat.System feature flag is on for
// this department; when it is off every chat surface stays hidden.
if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) {
Expand All @@ -199,6 +209,8 @@ export default function TabLayout() {
});
}

if (!isCurrentRun()) return;

// Initialize weather alerts
try {
await useWeatherAlertsStore.getState().fetchSettings();
Expand All @@ -217,6 +229,8 @@ export default function TabLayout() {
});
}

if (!isCurrentRun()) return;

hasInitialized.current = true;

logger.info({
Expand All @@ -237,7 +251,11 @@ export default function TabLayout() {
}
})
.finally(() => {
isInitializing.current = false;
// Only the current run owns the guard; a superseded run clearing it would let
// two initializations overlap.
if (isCurrentRun()) {
isInitializing.current = false;
}
});

await Promise.race([initPromise, initTimeout]);
Expand All @@ -246,6 +264,10 @@ export default function TabLayout() {
message: 'Failed to initialize app',
context: { error, platform: Platform.OS },
});
// A run whose session already ended must not clobber state a newer run (or the
// sign-out cleanup) has since established.
if (!isCurrentRun()) return;

// Reset initialization state on error so it can be retried
hasInitialized.current = false;
// If the init promise is still hanging, clear the guard so a retry is possible
Expand Down Expand Up @@ -349,6 +371,12 @@ export default function TabLayout() {
logger.info({
message: 'User signed out, stopping location tracking',
});

// Retire any initialization still in flight so it cannot connect hubs or mark the
// app initialized for a session that ended, and free the guard it no longer owns
// so the next sign-in is not skipped as "already initializing".
initGeneration.current += 1;
isInitializing.current = false;
Comment on lines +374 to +379

Copy link
Copy Markdown
Contributor

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 -euo pipefail

# Find auth-status transitions and navigation behavior around sign-out.
rg -n -C 5 "signedOut|signOut|set.*status|status: 'idle'|status: 'error'" src
rg -n -C 5 "hasInitialized\.current|initializeApp\(" "src/app/(app)/_layout.tsx"

Repository: Resgrid/Dispatch

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg 'src/app/\(app\)/_layout\.tsx|src/(app|modules|screens|components).*sign|src/.*auth|src/.*coreStore|init-session-generation\.test\.tsx' | head -200

echo
echo "== layout outline relevant =="
wc -l "src/app/(app)/_layout.tsx"
ast-grep outline "src/app/(app)/_layout.tsx" --match core --view expanded || true

echo
echo "== layout lines 280-385 =="
sed -n '280,385p' "src/app/(app)/_layout.tsx"

echo
echo "== init-session test =="
sed -n '1,180p' "src/app/(app)/__tests__/init-session-generation.test.tsx"

echo
echo "== focused signOut/status/hasInitialized references =="
rg -n -C 3 "signOut|signedOut|setSignedOut|hasInitialized\.current|initializeApp\(" "src/app/(app)/_layout.tsx" "src/app/(app)/__tests__/init-session-generation.test.tsx"

Repository: Resgrid/Dispatch

Length of output: 16913


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== src/app/(app)/_layout.tsx first 450 lines =="
sed -n '1,450p' "src/app/(app)/_layout.tsx"

echo "== auth state/status references in src =="
rg -n -C 3 "setStatus|signOut|signedOut|signedIn|idle|error" src/lib/auth src/stores src/app src/modules src/screens src/components | head -500

Repository: Resgrid/Dispatch

Length of output: 46670


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== auth store outline =="
wc -l src/lib/auth/index.tsx src/stores/auth/store.tsx src/app/_layout.tsx src/__tests__/app/_layout.auth-guard.test.tsx
ast-grep outline src/stores/auth/store.tsx --view expanded || true
ast-grep outline src/lib/auth/index.tsx --view expanded || true

echo
echo "== auth store =="
sed -n '1,240p' src/stores/auth/store.tsx

echo
echo "== auth index =="
sed -n '1,220p' src/lib/auth/index.tsx

echo
echo "== root layout auth guard slices =="
sed -n '1,260p' src/app/_layout.tsx
sed -n '1,180p' src/__tests__/app/_layout.auth-guard.test.tsx

echo
echo "== focused redirect/log-out usages =="
rg -n -C 4 "useAuthStore|useAuth|login\\(|logout\\(|setStatus\\(|signedOut|redirectTo:|router\\." src/app src/lib/auth src/__tests__ src/components src/hooks | head -400

Repository: Resgrid/Dispatch

Length of output: 48462


Reset the completed-session initialization flag on sign-out.

layout only resets isInitializing on sign-out, while a completed run leaves hasInitialized.current === true. A kept-mounted (app) layout will skip the next signed-in session initialization. Add hasInitialized.current = false in the status === 'signedOut' cleanup path.

🤖 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/app/`(app)/_layout.tsx around lines 374 - 379, Update the status ===
'signedOut' cleanup path in the app layout to also reset hasInitialized.current
to false, alongside initGeneration.current and isInitializing.current. Preserve
the existing in-flight initialization invalidation so the next signed-in session
can initialize normally.

Comment on lines +378 to +379

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 Bug high

State management bug: The sign-out handler resets isInitializing.current but not hasInitialized.current, latching the shouldInitialize gate (line 344) true and skipping initialization for the next session's SignalR hubs, feature flags, and weather alerts. Reset hasInitialized.current = false; alongside the isInitializing.current = false; assignment.

// Retire any initialization still in flight so it cannot connect hubs or mark the
// app initialized for a session that ended, and free the guard it no longer owns
// so the next sign-in is not skipped as "already initializing".
initGeneration.current += 1;
isInitializing.current = false;
hasInitialized.current = false;
Prompt for LLM

File src/app/(app)/_layout.tsx:

Line 378 to 379:

State management bug: The sign-out handler resets `isInitializing.current` but not `hasInitialized.current`, latching the `shouldInitialize` gate (line 344) true and skipping initialization for the next session's SignalR hubs, feature flags, and weather alerts. Reset `hasInitialized.current = false;` alongside the `isInitializing.current = false;` assignment.

Suggested Code:

      // Retire any initialization still in flight so it cannot connect hubs or mark the
      // app initialized for a session that ended, and free the guard it no longer owns
      // so the next sign-in is not skipped as "already initializing".
      initGeneration.current += 1;
      isInitializing.current = false;
      hasInitialized.current = false;

Talk to Kody by mentioning @kody

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

}

// Update last known status
Expand Down
39 changes: 2 additions & 37 deletions src/app/(app)/chatbot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@
import { MessageActionsSheet } from '@/components/chat/message-actions-sheet';
import { MessageBubble } from '@/components/chat/message-bubble';
import { TypingDots } from '@/components/chat/typing-indicator';
import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet';
import { Box } from '@/components/ui/box';
import { Button, ButtonText } from '@/components/ui/button';
import { Center } from '@/components/ui/center';
import { FlatList } from '@/components/ui/flat-list';
import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
Expand All @@ -20,7 +18,6 @@
import { Pressable } from '@/components/ui/pressable';
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { Textarea, TextareaInput } from '@/components/ui/textarea';
import { VStack } from '@/components/ui/vstack';
import { type ChatMessageResultData } from '@/models/v4/chat';
import useAuthStore from '@/stores/auth/store';
Expand All @@ -40,8 +37,6 @@
const isModerator = !!securityStore((s) => s.rights)?.IsAdmin;
const [text, setText] = useState('');
const [actionsMessage, setActionsMessage] = useState<ChatMessageResultData | null>(null);
const [editMessage, setEditMessage] = useState<ChatMessageResultData | null>(null);
const [editText, setEditText] = useState('');

useFocusEffect(
useCallback(() => {
Expand Down Expand Up @@ -73,7 +68,7 @@

const renderItem = useCallback(
({ item }: { item: ChatMessageResultData }) => (
<MessageBubble message={item} isOwn={!!item.SenderUserId && item.SenderUserId === currentUserId} showSender={false} currentUserId={currentUserId} onLongPress={setActionsMessage} onToggleReaction={() => undefined} />

Check warning on line 71 in src/app/(app)/chatbot.tsx

View workflow job for this annotation

GitHub Actions / test

Replace `·message={item}·isOwn={!!item.SenderUserId·&&·item.SenderUserId·===·currentUserId}·showSender={false}·currentUserId={currentUserId}·onLongPress={setActionsMessage}·onToggleReaction={()·=>·undefined}` with `⏎········message={item}⏎········isOwn={!!item.SenderUserId·&&·item.SenderUserId·===·currentUserId}⏎········showSender={false}⏎········currentUserId={currentUserId}⏎········onLongPress={setActionsMessage}⏎········onToggleReaction={()·=>·undefined}⏎·····`
),
[currentUserId]
);
Expand Down Expand Up @@ -147,7 +142,7 @@
</HStack>
</KeyboardAvoidingView>

{/* Restricted actions for assistant messages: copy, edit own, pin (moderator), flag. */}
{/* Restricted actions for assistant messages: copy, pin (moderator), flag. */}
<MessageActionsSheet
message={actionsMessage}
isOpen={actionsMessage !== null}
Expand All @@ -161,42 +156,12 @@
const ok = await copyToClipboard(m.Body ?? '');
useToastStore.getState().showToast(ok ? 'success' : 'info', ok ? t('chat.copied') : t('chat.copy_unavailable'));
}}
onEdit={(m) => {
setEditMessage(m);
setEditText(m.Body ?? '');
}}
onEdit={() => undefined}

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

Performance regression: Inline arrow functions in JSX props create new functions on every render. Move the () => undefined callback definition outside the render method, including the instance in src/app/chat/thread/[messageId].tsx:133.

Kody rule violation: Avoid using .bind() or arrow functions in JSX props

Prompt for LLM

File src/app/(app)/chatbot.tsx:

Line 159:

Performance regression: Inline arrow functions in JSX props create new functions on every render. Move the `() => undefined` callback definition outside the render method, including the instance in `src/app/chat/thread/[messageId].tsx:133`.

Talk to Kody by mentioning @kody

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

onDelete={() => undefined}
onFlag={(m, reason) => useChatStore.getState().flagMessage(m.ChatMessageId, reason)}
onTogglePin={(m, pinned) => chatbotChannelId && useChatStore.getState().togglePin(m.ChatMessageId, chatbotChannelId, pinned)}
onModeratorDelete={() => undefined}
/>

{/* Edit own message */}
<Actionsheet isOpen={editMessage !== null} onClose={() => setEditMessage(null)}>
<ActionsheetBackdrop />
<ActionsheetContent>
<ActionsheetDragIndicatorWrapper>
<ActionsheetDragIndicator />
</ActionsheetDragIndicatorWrapper>
<VStack className="w-full p-2" space="md">
<Text className="text-base font-semibold text-typography-900">{t('chat.edit_message')}</Text>
<Textarea>
<TextareaInput value={editText} onChangeText={setEditText} multiline />
</Textarea>
<Button
className="bg-primary-600"
onPress={() => {
if (editMessage && chatbotChannelId && editText.trim()) {
void useChatStore.getState().editMessage(editMessage.ChatMessageId, chatbotChannelId, editText.trim());
}
setEditMessage(null);
}}
>
<ButtonText>{t('chat.save')}</ButtonText>
</Button>
</VStack>
</ActionsheetContent>
</Actionsheet>
</Box>
);
}
8 changes: 7 additions & 1 deletion src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,17 @@ const navigationIntegration = Sentry.reactNavigationIntegration({
enableTimeToInitialDisplay: false,
});

// Sentry's own logger is off by default: watchdog-termination tracking rewrites the
// native scope on every RNSentry turbo-module call, so `debug` floods the Metro
// console with hundreds of "Writing tags to disk" lines a second. Flip to `__DEV__`
// temporarily when diagnosing Sentry itself.
const SENTRY_DEBUG = false;

// Only initialize Sentry if a DSN is provided
if (Env.SENTRY_DSN) {
Sentry.init({
dsn: Env.SENTRY_DSN,
debug: __DEV__, // Only debug in development, not production
debug: SENTRY_DEBUG,
tracesSampleRate: __DEV__ ? 1.0 : 0.2, // 100% in dev, 20% in production to reduce performance impact
profilesSampleRate: __DEV__ ? 1.0 : 0.2, // 100% in dev, 20% in production to reduce performance impact
sendDefaultPii: false,
Expand Down
8 changes: 4 additions & 4 deletions src/app/chat/[channelId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Platform } from 'react-native';

import { getPresence, uploadAttachment } from '@/api/chat/chat';
import { AckBanner } from '@/components/chat/ack-banner';
import { copyToClipboard, getChannelDisplayName, getImageMimeType } from '@/components/chat/chat-utils';
import { buildGifMetadata, buildLocationMetadata, copyToClipboard, getChannelDisplayName, getImageMimeType } from '@/components/chat/chat-utils';
import { GifPickerSheet } from '@/components/chat/gif-picker-sheet';
import { MessageActionsSheet } from '@/components/chat/message-actions-sheet';
import { MessageBubble } from '@/components/chat/message-bubble';
Expand Down Expand Up @@ -152,7 +152,7 @@ export default function ChannelConversationScreen() {
const handleSendGif = useCallback(
(gif: GifResultData) => {
if (!channelId) return;
const metadata = JSON.stringify({ GifUrl: gif.GifUrl, PreviewUrl: gif.PreviewUrl, Width: gif.Width, Height: gif.Height, Title: gif.Title });
const metadata = buildGifMetadata(gif);
void useChatStore.getState().sendMessage({ channelId, body: gif.Title ?? 'GIF', messageType: ChatMessageType.Gif, metadataJson: metadata });
},
[channelId]
Expand All @@ -161,7 +161,7 @@ export default function ChannelConversationScreen() {
const handleSendLocation = useCallback(
(latitude: number, longitude: number, urgent: boolean) => {
if (!channelId) return;
const metadata = JSON.stringify({ Latitude: latitude, Longitude: longitude });
const metadata = buildLocationMetadata(latitude, longitude);
void useChatStore.getState().sendMessage({
channelId,
body: t('chat.shared_location'),
Expand Down Expand Up @@ -343,7 +343,7 @@ export default function ChannelConversationScreen() {
handleToggleReaction(
m,
emoji,
m.Reactions.some((r) => r.Emoji === emoji && r.UserId === currentUserId)
(m.Reactions ?? []).some((r) => r.Emoji === emoji && r.UserId === currentUserId)
)
}
onReply={openThread}
Expand Down
Loading
Loading