Skip to content

Commit 6fd09bf

Browse files
committed
RG-T117 PR#123 fixes
1 parent 2dba29a commit 6fd09bf

5 files changed

Lines changed: 104 additions & 12 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"expo-auth-session": "~7.0.11",
128128
"expo-av": "~16.0.8",
129129
"expo-build-properties": "~1.0.10",
130+
"expo-clipboard": "~8.0.8",
130131
"expo-constants": "~18.0.13",
131132
"expo-crypto": "~15.0.9",
132133
"expo-dev-client": "~6.0.21",

src/app/chat/[channelId].tsx

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,35 @@ export default function ChannelConversationScreen() {
5656
const [editText, setEditText] = useState('');
5757
const [imageUri, setImageUri] = useState<string | null>(null);
5858
const [presenceIds, setPresenceIds] = useState<Set<string>>(new Set());
59+
const [resolveAttempted, setResolveAttempted] = useState(false);
5960
const unsubscribeRef = useRef<(() => void) | null>(null);
6061

6162
const isDm = channel?.ChannelType === ChatChannelType.DirectMessage;
6263
const showSender = !isDm;
64+
const isChatbot = channel?.ChannelType === ChatChannelType.Chatbot;
65+
// Deep links (push notifications, cold starts) can arrive before the channel
66+
// list loads; the channel type is unknown until then. Treat a completed fetch
67+
// with no match as resolved so unknown channels keep the generic screen.
68+
const isResolved = !!channel || resolveAttempted;
6369

6470
// Newest-first for the inverted list.
6571
const inverted = useMemo(() => (messages ? messages.slice().reverse() : []), [messages]);
6672

67-
// Mount: activate channel, join hub, load history and members.
73+
// Resolve the channel identity for deep links before mounting the generic view.
74+
useEffect(() => {
75+
if (channel || resolveAttempted || !isChatEnabled) return;
76+
void useChatStore
77+
.getState()
78+
.fetchChannels()
79+
.finally(() => setResolveAttempted(true));
80+
}, [channel, resolveAttempted, isChatEnabled]);
81+
82+
// Mount: activate channel, join hub, load history and members. Assistant
83+
// conversations are handled by the dedicated chatbot screen — never join or
84+
// load them here, and wait for unresolved deep links to identify first.
6885
useFocusEffect(
6986
useCallback(() => {
70-
if (!channelId || !isChatEnabled) return;
87+
if (!channelId || !isChatEnabled || !isResolved || isChatbot) return;
7188
const store = useChatStore.getState();
7289
store.setActiveChannel(channelId);
7390
void store.joinChannel(channelId);
@@ -76,7 +93,7 @@ export default function ChannelConversationScreen() {
7693
return () => {
7794
useChatStore.getState().setActiveChannel(null);
7895
};
79-
}, [channelId, isChatEnabled])
96+
}, [channelId, isChatEnabled, isResolved, isChatbot])
8097
);
8198

8299
// Fetch presence for the channel members (for the header online dot).
@@ -96,10 +113,10 @@ export default function ChannelConversationScreen() {
96113

97114
// Mark read whenever the newest message changes while viewing.
98115
useEffect(() => {
99-
if (channelId && inverted.length > 0) {
116+
if (channelId && isResolved && !isChatbot && inverted.length > 0) {
100117
void useChatStore.getState().markChannelRead(channelId);
101118
}
102-
}, [channelId, inverted.length]);
119+
}, [channelId, inverted.length, isResolved, isChatbot]);
103120

104121
const otherOnline = useMemo(() => {
105122
if (!isDm) return false;
@@ -254,9 +271,20 @@ export default function ChannelConversationScreen() {
254271
return <Redirect href={'/home' as Href} />;
255272
}
256273

274+
// Deep link to a channel that isn't loaded yet: wait for the channel list so
275+
// assistant conversations never mount the full-featured view.
276+
if (!isResolved) {
277+
return (
278+
<Box className="size-full flex-1 items-center justify-center bg-background-0">
279+
<Stack.Screen options={{ title, headerShown: true, headerBackTitle: '' }} />
280+
<Spinner />
281+
</Box>
282+
);
283+
}
284+
257285
// Assistant conversations always use the dedicated restricted screen (text only,
258286
// no reactions/threads/deletes) — catch deep links and stale routes here.
259-
if (channel?.ChannelType === ChatChannelType.Chatbot) {
287+
if (isChatbot) {
260288
return <Redirect href={'/chatbot' as Href} />;
261289
}
262290

src/components/chat/__tests__/chat-utils.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import * as Clipboard from 'expo-clipboard';
12
import { type TFunction } from 'i18next';
23

34
import { ChatChannelType, type ChatChannelResultData } from '@/models/v4/chat';
45

5-
import { getChannelDisplayName, getImageMimeType, hasLink, linkifySegments } from '../chat-utils';
6+
import { copyToClipboard, getChannelDisplayName, getImageMimeType, hasLink, linkifySegments } from '../chat-utils';
7+
8+
jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn() }));
69

710
const mockT = ((key: string) => key) as TFunction;
811

@@ -74,6 +77,56 @@ describe('chat-utils', () => {
7477
});
7578
});
7679

80+
describe('copyToClipboard', () => {
81+
const globalWithNavigator = globalThis as unknown as { navigator?: { clipboard?: { writeText?: (value: string) => Promise<void> } } };
82+
let originalNavigator: unknown;
83+
84+
beforeEach(() => {
85+
originalNavigator = globalWithNavigator.navigator;
86+
jest.mocked(Clipboard.setStringAsync).mockReset();
87+
});
88+
89+
afterEach(() => {
90+
if (originalNavigator === undefined) {
91+
delete globalWithNavigator.navigator;
92+
} else {
93+
globalWithNavigator.navigator = originalNavigator as typeof globalWithNavigator.navigator;
94+
}
95+
});
96+
97+
it('uses the web clipboard API when available', async () => {
98+
const writeText = jest.fn().mockResolvedValue(undefined);
99+
globalWithNavigator.navigator = { clipboard: { writeText } };
100+
101+
await expect(copyToClipboard('hello')).resolves.toBe(true);
102+
expect(writeText).toHaveBeenCalledWith('hello');
103+
expect(Clipboard.setStringAsync).not.toHaveBeenCalled();
104+
});
105+
106+
it('falls back to the native module when the web API is unavailable', async () => {
107+
delete globalWithNavigator.navigator;
108+
jest.mocked(Clipboard.setStringAsync).mockResolvedValue(true);
109+
110+
await expect(copyToClipboard('hello')).resolves.toBe(true);
111+
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('hello');
112+
});
113+
114+
it('falls back to the native module when the web API write fails', async () => {
115+
globalWithNavigator.navigator = { clipboard: { writeText: jest.fn().mockRejectedValue(new Error('denied')) } };
116+
jest.mocked(Clipboard.setStringAsync).mockResolvedValue(true);
117+
118+
await expect(copyToClipboard('hello')).resolves.toBe(true);
119+
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('hello');
120+
});
121+
122+
it('returns false when the native write fails', async () => {
123+
delete globalWithNavigator.navigator;
124+
jest.mocked(Clipboard.setStringAsync).mockRejectedValue(new Error('unavailable'));
125+
126+
await expect(copyToClipboard('hello')).resolves.toBe(false);
127+
});
128+
});
129+
77130
describe('linkifySegments', () => {
78131
it('splits multiple links and surrounding text', () => {
79132
expect(linkifySegments('go to https://a.com or http://b.com now')).toEqual([

src/components/chat/chat-utils.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as Clipboard from 'expo-clipboard';
12
import { type TFunction } from 'i18next';
23

34
import { getAvatarUrl } from '@/lib/utils';
@@ -105,9 +106,9 @@ export function hasLink(body?: string | null): boolean {
105106
}
106107

107108
/**
108-
* Copies text to the clipboard. Works on web/Electron via the async Clipboard
109-
* API; native returns false (no clipboard native module is installed) so callers
110-
* can surface an appropriate message.
109+
* Copies text to the clipboard. Uses the async Clipboard API on web/Electron
110+
* and expo-clipboard on native; returns false only when both are unavailable
111+
* or the write fails, so callers can surface an appropriate message.
111112
*/
112113
export async function copyToClipboard(text: string): Promise<boolean> {
113114
try {
@@ -117,9 +118,13 @@ export async function copyToClipboard(text: string): Promise<boolean> {
117118
return true;
118119
}
119120
} catch {
120-
// ignore and fall through
121+
// ignore and fall through to the native module
122+
}
123+
try {
124+
return await Clipboard.setStringAsync(text);
125+
} catch {
126+
return false;
121127
}
122-
return false;
123128
}
124129

125130
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8047,6 +8047,11 @@ expo-build-properties@~1.0.10:
80478047
ajv "^8.11.0"
80488048
semver "^7.6.0"
80498049

8050+
expo-clipboard@~8.0.8:
8051+
version "8.0.8"
8052+
resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-8.0.8.tgz#5e52054a4bbaebef090ec6fe5eaa200072ff94f7"
8053+
integrity sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==
8054+
80508055
expo-constants@~18.0.13:
80518056
version "18.0.13"
80528057
resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.13.tgz#0117f1f3d43be7b645192c0f4f431fb4efc4803d"

0 commit comments

Comments
 (0)