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
78 changes: 78 additions & 0 deletions app/lib/methods/canOpenRoom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@ import { canOpenRoom } from './canOpenRoom';
import sdk from '../services/sdk';
import { getRoomByTypeAndName } from '../services/restApi';

const mockFind = jest.fn();
const mockQuery = jest.fn();

jest.mock('../database', () => ({
__esModule: true,
default: {
active: {
get: jest.fn((collection: string) => {
if (collection === 'subscriptions') {
return {
find: mockFind,
query: mockQuery
};
}
return null;
})
}
}
}));

jest.mock('../services/sdk', () => ({
__esModule: true,
default: {
Expand All @@ -18,6 +38,10 @@ const mockedGetRoomByTypeAndName = getRoomByTypeAndName as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
mockFind.mockRejectedValue(new Error('not found'));
mockQuery.mockReturnValue({
fetch: jest.fn().mockResolvedValue([])
});
});

describe('canOpenRoom — GROUP deeplink', () => {
Expand Down Expand Up @@ -138,7 +162,61 @@ describe('canOpenRoom — CHANNEL deeplink', () => {
});
});

describe('canOpenRoom — local subscription fast-path', () => {
const localSubFixture = {
rid: 'local-room-1',
t: 'c',
name: 'general',
fname: 'General Room',
prid: '',
uids: ['u1', 'u2'],
usernames: ['user1', 'user2']
};

it('returns room immediately from local DB when rid matches without calling REST API', async () => {
mockFind.mockResolvedValueOnce(localSubFixture);

const result = await canOpenRoom({ rid: 'local-room-1', path: '' });

expect(mockFind).toHaveBeenCalledWith('local-room-1');
expect(mockedGetRoomByTypeAndName).not.toHaveBeenCalled();
expect(mockedSdkPost).not.toHaveBeenCalled();
expect(result).toEqual(localSubFixture);
});

it('returns room immediately from local DB when path matches channel name without calling REST API', async () => {
mockQuery.mockReturnValueOnce({
fetch: jest.fn().mockResolvedValueOnce([localSubFixture])
});

const result = await canOpenRoom({ rid: '', path: 'channel/general' });

expect(mockedGetRoomByTypeAndName).not.toHaveBeenCalled();
expect(mockedSdkPost).not.toHaveBeenCalled();
expect(result).toEqual(localSubFixture);
});

it('returns room from local DB when model has asPlain() method', async () => {
const modelWithAsPlain = {
...localSubFixture,
asPlain: () => localSubFixture
};
mockFind.mockResolvedValueOnce(modelWithAsPlain);

const result = await canOpenRoom({ rid: 'local-room-1', path: '' });

expect(result).toEqual(localSubFixture);
});
});

describe('canOpenRoom — other paths', () => {
it('returns { rid } fallback when rid is provided but path is empty and not found locally', async () => {
mockFind.mockRejectedValueOnce(new Error('not found'));

const result = await canOpenRoom({ rid: 'remote-rid-123', path: '' });
expect(result).toEqual({ rid: 'remote-rid-123' });
});

it('returns false when no path and no rid', async () => {
const result = await canOpenRoom({ rid: '', path: '' });
expect(result).toBe(false);
Expand Down
135 changes: 113 additions & 22 deletions app/lib/methods/canOpenRoom.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { ERoomTypes } from '../../definitions';
import type { Collection } from '@nozbe/watermelondb';
import { Q } from '@nozbe/watermelondb';

import { ERoomTypes, type ISubscription, type TSubscriptionModel } from '../../definitions';
import database from '../database';
import sdk from '../services/sdk';
import { createDirectMessage } from './createDirectMessage';
import { getRoomByTypeAndName } from '../services/restApi';

export interface ICanOpenRoomResult {
rid: string;
t?: string;
name?: string;
fname?: string;
prid?: string;
uids?: string[];
usernames?: string[];
[key: string]: any;
}

async function openGroup(roomId: string) {
try {
// RC 0.61.0
Expand Down Expand Up @@ -72,36 +86,113 @@ async function open({ type, rid, name }: { type: ERoomTypes; rid: string; name:
}
}

export async function canOpenRoom({ rid, path }: { rid: string; path: string }): Promise<any> {
/**
* WatermelonDB models are lazy proxies that can't be passed across the saga/navigation
* boundary. Normalize to a plain object so downstream consumers (goRoom, navigate) receive
* serializable data.
*/
function formatRoom(room: TSubscriptionModel | ISubscription | any, rid?: string): ICanOpenRoomResult | ISubscription {
return (
room?.asPlain?.() ?? {
rid: rid ?? room.rid,
t: room.t,
name: room.name,
fname: room.fname,
prid: room.prid,
uids: room.uids,
usernames: room.usernames
}
);
}

/**
* Push notifications carry the room ID. Resolving by rid first avoids a network round trip
* and succeeds even when the socket is still reconnecting after a background-to-foreground
* transition.
*/
async function findSubscriptionByRid(
subsCollection: Collection<TSubscriptionModel>,
rid: string
): Promise<ICanOpenRoomResult | ISubscription | null> {
try {
const room = await subsCollection.find(rid);
return formatRoom(room, rid);
} catch {
return null;
}
}

/**
* Deep-link path segments may hold either a room name or a room ID depending on how the
* link was generated. Querying by both with a room-type filter avoids a false-negative
* when the path contains an ID instead of a name.
*/
async function findSubscriptionByName(
subsCollection: Collection<TSubscriptionModel>,
name: string,
roomType: string
): Promise<ICanOpenRoomResult | ISubscription | null> {
try {
const rows = await subsCollection
.query(Q.or(Q.where('name', name), Q.where('rid', name)), Q.where('t', roomType), Q.take(1))
.fetch();
if (rows.length && rows[0]) {
return formatRoom(rows[0]);
}
} catch {
// Do nothing
}
return null;
}

/**
* Local resolution is attempted before the REST fallback so that notification taps succeed
* instantly even when the network or DDP socket is temporarily unavailable (e.g. Android
* transitioning from a doze/background state).
*/
export async function canOpenRoom({
rid,
path
}: {
rid: string;
path: string;
}): Promise<ICanOpenRoomResult | ISubscription | { rid: string } | boolean> {
try {
const db = database.active;
const subsCollection = db.get('subscriptions');
const subsCollection = (db?.get ? db.get('subscriptions') : null) as Collection<TSubscriptionModel> | null;

if (subsCollection && rid) {
const room = await findSubscriptionByRid(subsCollection, rid);
if (room) {
return room;
}
}

if (path) {
const [type, name] = path.split('/');
const t = type as ERoomTypes;
const roomType = t === ERoomTypes.GROUP ? 'p' : t === ERoomTypes.DIRECT ? 'd' : (t as string) === 'channels' ? 'l' : 'c';

if (subsCollection && name) {
const room = await findSubscriptionByName(subsCollection, name, roomType);
if (room) {
return room;
}
}

if (rid) {
try {
const room = await subsCollection.find(rid);
return {
rid,
t: room.t,
name: room.name,
fname: room.fname,
prid: room.prid,
uids: room.uids,
usernames: room.usernames
};
const result = await open({ type: t, rid, name });
return result;
} catch (e) {
// Do nothing
return false;
}
}

const [type, name] = path.split('/');
const t = type as ERoomTypes;
try {
const result = await open({ type: t, rid, name });
return result;
} catch (e) {
return false;
if (rid) {
return { rid };
}

return false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (e) {
return false;
}
Expand Down
37 changes: 37 additions & 0 deletions app/sagas/__tests__/deepLinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ jest.mock('../../lib/services/voip/resetVoipState', () => ({
resetVoipState: jest.fn()
}));

jest.mock('../../lib/services/socketHealth', () => ({
recoverSocket: jest.fn(() => Promise.resolve('confirmed-alive'))
}));

jest.mock('../../lib/navigation/appNavigation', () => ({
__esModule: true,
default: {
Expand Down Expand Up @@ -109,6 +113,7 @@ import { getServerInfo } from '../../lib/methods/getServerInfo';
import { goRoom, navigateToRoom } from '../../lib/methods/helpers/goRoom';
import { waitForNavigationReady } from '../../lib/navigation/appNavigation';
import { loginOAuthOrSso } from '../../lib/services/connect';
import { recoverSocket } from '../../lib/services/socketHealth';
import sdk from '../../lib/services/sdk';
import database from '../../lib/database';
import EventEmitter from '../../lib/methods/helpers/events';
Expand Down Expand Up @@ -236,6 +241,38 @@ describe('deepLinking saga — Regression race (new server + token + room path)'
expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1);
});

it('retries canOpenRoom after calling recoverSocket when the first canOpenRoom attempt fails', async () => {
const store = setupStore();
const params = makeParamsWithToken();

// Simulate a dormant socket: the first canOpenRoom fails because the REST
// fallback can't reach the server; after recoverSocket restores the connection,
// the retry succeeds.
jest
.mocked(canOpenRoom)
.mockResolvedValueOnce(false as any)
.mockResolvedValueOnce({ rid: 'room-1', name: 'general', t: 'c' } as any);

store.dispatch(deepLinkingOpen(params));
await flushSagaMicrotasks();
await jest.advanceTimersByTimeAsync(1000);
await flushSagaMicrotasks();

store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
await flushSagaMicrotasks();

store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any));
await flushSagaMicrotasks();

store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE }));
await flushSagaMicrotasks();
await flushSagaMicrotasks();

expect(jest.mocked(recoverSocket)).toHaveBeenCalled();
expect(jest.mocked(canOpenRoom)).toHaveBeenCalledTimes(2);
expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1);
});

/**
* Regression negative: dispatch SERVER.SELECT_SUCCESS, LOGIN.SUCCESS.
* Flush microtasks. Assert goRoom NOT yet called.
Expand Down
11 changes: 10 additions & 1 deletion app/sagas/deepLinking.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { notifyUser } from '../lib/services/restApi';
import sdk from '../lib/services/sdk';
import Navigation, { waitForNavigationReady } from '../lib/navigation/appNavigation';
import { resetVoipState } from '../lib/services/voip/resetVoipState';
import { recoverSocket } from '../lib/services/socketHealth';

const roomTypes = {
channel: 'c',
Expand Down Expand Up @@ -58,7 +59,15 @@ const navigate = function* navigate({ params }) {
[type, name, , jumpToThreadId] = params.path.split('/');
}
if (type !== 'invite' || params.rid) {
const room = yield canOpenRoom(params);
let room = yield canOpenRoom(params);
if (!room) {
try {
yield call(recoverSocket);
} catch (e) {
log(e);
}
room = yield canOpenRoom(params);
}
if (room) {
const item = {
name,
Expand Down
15 changes: 10 additions & 5 deletions app/sagas/state.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { select, takeLatest } from 'redux-saga/effects';
import { call, select, takeLatest } from 'redux-saga/effects';

import log from '../lib/methods/helpers/log';
import { localAuthenticate, saveLastLocalAuthenticationSession } from '../lib/methods/helpers/localAuthentication';
Expand Down Expand Up @@ -29,12 +29,17 @@ const appHasComeBackToForeground = function* appHasComeBackToForeground() {
const server = yield select(state => state.server.server);
yield localAuthenticate(server);

recoverSocket().catch(e => log(e));
try {
yield call(recoverSocket);
} catch (e) {
log(e);
}

// Check for pending notification when app comes to foreground (Android - notification tap while in background)
checkPendingNotification().catch(e => {
try {
yield call(checkPendingNotification);
} catch (e) {
log('[state.js] Error checking pending notification:', e);
});
}
return yield setUserPresenceOnline();
} catch (e) {
log(e);
Expand Down