Skip to content
Open
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
42 changes: 42 additions & 0 deletions examples/kitchen-sink/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files

# dependencies
node_modules/
package-lock.json

# Expo
.expo/
dist/
web-build/
expo-env.d.ts

# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision

# Metro
.metro-health-check*

# debug
npm-debug.*
yarn-debug.*
yarn-error.*

# macOS
.DS_Store
*.pem

# local env files
.env*.local

# typescript
*.tsbuildinfo

# generated native folders
/ios
/android
41 changes: 41 additions & 0 deletions examples/kitchen-sink/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"expo": {
"name": "EasyMatrix SDK Lab",
"slug": "easymatrix-sdk-lab",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"scheme": "easymatrix-sdk-lab",
"experiments": {
"typedRoutes": true
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-secure-store",
"expo-sqlite"
]
}
}
23 changes: 23 additions & 0 deletions examples/kitchen-sink/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Tabs } from "expo-router";

export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: "#13212f",
tabBarInactiveTintColor: "#8c7c6d",
tabBarStyle: {
backgroundColor: "#fff8ef",
borderTopColor: "#ebdcc9",
},
}}
>
<Tabs.Screen name="index" options={{ title: "Inbox" }} />
<Tabs.Screen name="search" options={{ title: "Search" }} />
<Tabs.Screen name="lab" options={{ title: "Lab" }} />
<Tabs.Screen name="settings" options={{ title: "Settings" }} />
</Tabs>
);
}

162 changes: 162 additions & 0 deletions examples/kitchen-sink/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { useEffect, useMemo, useState } from "react";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { Pressable, Text, TextInput, View, useWindowDimensions } from "react-native";

import { ActiveProfileBanner } from "@/components/active-profile-banner";
import { ChatRow } from "@/components/chat-row";
import { EmptyState } from "@/components/empty-state";
import { ScreenShell } from "@/components/screen-shell";
import { SectionCard } from "@/components/section-card";
import { ThreadPanel } from "@/components/thread-panel";
import { useActiveProfile } from "@/hooks/use-active-profile";
import { useRealtimeTransport } from "@/hooks/use-realtime-transport";
import { chatsQueryOptions } from "@/lib/query/queries";

export default function InboxScreen() {
const { activeProfile } = useActiveProfile();
const transport = useRealtimeTransport(activeProfile, ["*"]);
const [filter, setFilter] = useState("");
const [selectedChatID, setSelectedChatID] = useState<string | null>(null);
const { width } = useWindowDimensions();
const wideLayout = width >= 1080;

const chatsQuery = useQuery(
activeProfile
? {
...chatsQueryOptions(activeProfile, { limit: 80 }),
enabled: Boolean(activeProfile.baseURL && activeProfile.accessToken),
refetchInterval: transport.mode === "polling" ? 15_000 : false,
}
: {
queryKey: ["profile", "none", "chats", { limit: 80 }],
queryFn: async () => [],
enabled: false,
},
);

const filteredChats = useMemo(() => {
const items = chatsQuery.data ?? [];
const token = filter.trim().toLowerCase();
if (!token) {
return items;
}
return items.filter(
(item) =>
item.title.toLowerCase().includes(token) ||
item.subtitle.toLowerCase().includes(token) ||
item.accountID.toLowerCase().includes(token),
);
}, [chatsQuery.data, filter]);

useEffect(() => {
if (wideLayout && !selectedChatID && filteredChats[0]?.id) {
setSelectedChatID(filteredChats[0].id);
}
}, [filteredChats, selectedChatID, wideLayout]);
Comment on lines +52 to +56
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep selectedChatID in sync with the visible list.

After a filter change or profile switch, selectedChatID can point at a chat that is no longer in filteredChats, so the wide layout keeps rendering a stale thread. Re-select the first visible chat or clear the selection when the current one drops out.

Suggested fix
   useEffect(() => {
-    if (wideLayout && !selectedChatID && filteredChats[0]?.id) {
-      setSelectedChatID(filteredChats[0].id);
+    if (!wideLayout) {
+      return;
+    }
+
+    const firstVisibleChatID = filteredChats[0]?.id ?? null;
+    if (!firstVisibleChatID) {
+      if (selectedChatID !== null) {
+        setSelectedChatID(null);
+      }
+      return;
+    }
+
+    if (!selectedChatID || !filteredChats.some((chat) => chat.id === selectedChatID)) {
+      setSelectedChatID(firstVisibleChatID);
     }
   }, [filteredChats, selectedChatID, wideLayout]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (wideLayout && !selectedChatID && filteredChats[0]?.id) {
setSelectedChatID(filteredChats[0].id);
}
}, [filteredChats, selectedChatID, wideLayout]);
useEffect(() => {
if (!wideLayout) {
return;
}
const firstVisibleChatID = filteredChats[0]?.id ?? null;
if (!firstVisibleChatID) {
if (selectedChatID !== null) {
setSelectedChatID(null);
}
return;
}
if (!selectedChatID || !filteredChats.some((chat) => chat.id === selectedChatID)) {
setSelectedChatID(firstVisibleChatID);
}
}, [filteredChats, selectedChatID, wideLayout]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/kitchen-sink/app/`(tabs)/index.tsx around lines 52 - 56, The effect
that syncs selection to the visible list (useEffect referencing wideLayout,
selectedChatID, filteredChats, setSelectedChatID) must check whether
selectedChatID still exists in filteredChats and update accordingly: if
wideLayout is true and selectedChatID is missing from filteredChats,
setSelectedChatID to filteredChats[0]?.id (or undefined/null to clear if
filteredChats is empty); keep the existing behavior that picks the first visible
chat when there is no selection, but add the missing-membership check so the
wide layout never renders a stale thread.


return (
<ScreenShell
eyebrow="SDK Inbox"
title="Chat list and thread state"
description="This screen exercises `chats.list`, `messages.list`, and `messages.send` against the active Desktop API target."
>
{activeProfile ? <ActiveProfileBanner profile={activeProfile} transportMode={transport.mode} /> : null}

{!activeProfile ? (
<EmptyState title="No profile selected" description="Create a connection profile in Settings before loading chats." />
) : null}

{activeProfile && !activeProfile.accessToken ? (
<EmptyState title="Token missing" description="Save an access token in Settings to start querying chats and messages." />
) : null}

{activeProfile ? (
<SectionCard
title="Inbox"
description="The list is cached per profile and invalidated from websocket events or polling."
>
<TextInput
placeholder="Filter loaded chats"
value={filter}
onChangeText={setFilter}
style={{
padding: 14,
borderRadius: 16,
backgroundColor: "#fff",
borderWidth: 1,
borderColor: "#dfd1bc",
}}
/>

{chatsQuery.isPending ? (
<EmptyState title="Loading chats" description="Fetching the latest conversation list." />
) : null}

{chatsQuery.isError ? (
<EmptyState
title="Inbox failed to load"
description={chatsQuery.error instanceof Error ? chatsQuery.error.message : "Unknown inbox error"}
/>
) : null}

{!wideLayout && filteredChats.length > 0 ? (
<View style={{ gap: 10 }}>
{filteredChats.map((chat) => (
<ChatRow
key={chat.id}
chat={chat}
selected={false}
onPress={() => router.push({ pathname: "/chat/[chatID]", params: { chatID: chat.id } })}
/>
))}
</View>
) : null}

{wideLayout ? (
<View style={{ flexDirection: "row", gap: 16, alignItems: "stretch" }}>
<View style={{ flex: 0.95, gap: 10 }}>
{filteredChats.map((chat) => (
<ChatRow
key={chat.id}
chat={chat}
selected={chat.id === selectedChatID}
onPress={() => setSelectedChatID(chat.id)}
/>
))}
</View>
<View style={{ flex: 1.25 }}>
{selectedChatID ? (
<ThreadPanel chatID={selectedChatID} profile={activeProfile} transportMode={transport.mode} />
) : (
<EmptyState title="Pick a chat" description="Select one of the loaded chats to render the thread panel." />
)}
</View>
</View>
) : null}

{!chatsQuery.isPending && filteredChats.length === 0 ? (
<EmptyState title="No chats matched" description="Try another filter or verify the current profile against `/v1/info` in Lab or Settings." />
) : null}
</SectionCard>
) : null}

{wideLayout ? null : (
<Pressable
onPress={() => router.push({ pathname: "/settings" })}
style={{
paddingVertical: 14,
paddingHorizontal: 18,
borderRadius: 18,
backgroundColor: "#efe4d5",
alignSelf: "flex-start",
}}
>
<Text selectable style={{ color: "#583d28", fontWeight: "700" }}>
Open Settings
</Text>
</Pressable>
)}
</ScreenShell>
);
}
Loading