Skip to content

notification: Use user_id to find account if multiple on same realm #5108

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Nov 24, 2021
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
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ data class MessageFcmMessage(
*/
fun dataForOpen(): Bundle = Bundle().apply {
// NOTE: Keep the JS-side type definition in sync with this code.
identity.realmUri.let { putString("realm_uri", it.toString()) }
putString("realm_uri", identity.realmUri.toString())
identity.userId?.let { putInt("user_id", it) }
when (recipient) {
is Recipient.Stream -> {
putString("recipient_type", "stream")
Expand Down Expand Up @@ -190,6 +191,8 @@ private fun extractIdentity(data: Map<String, String>): Identity =
Identity(
serverHost = data.require("server"),
realmId = data.require("realm_id").parseInt("realm_id"),

// `realm_uri` was added in server version 1.9.0
realmUri = data.require("realm_uri").parseUrl("realm_uri"),

// Server versions from 1.6.0 through 2.0.0 (and possibly earlier
Expand Down
18 changes: 17 additions & 1 deletion src/notification/__tests__/notification-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { fromAPNsImpl as extractIosNotificationData } from '../extract';
import objectEntries from '../../utils/objectEntries';

const realm_uri = eg.realm.toString();
const user_id = eg.selfUser.user_id;

describe('getNarrowFromNotificationData', () => {
const ownUserId = eg.selfUser.user_id;
Expand Down Expand Up @@ -81,6 +82,14 @@ describe('extract iOS notification data', () => {
const msg = data;
expect(verify(msg)).toEqual(msg);

// new(-ish) optional user_id is accepted and copied
// TODO: Rewrite so modern-style payloads are the baseline, e.g.,
// with a `modern` variable instead of `barebones`. Write
// individual tests for supporting older-style payloads, and mark
// those for future deletion, like with `TODO(1.9.0)`.
const msg1 = { ...msg, user_id };
expect(verify(msg1)).toEqual(msg1);

// unused fields are not copied
const msg2 = { ...msg, realm_id: 8675309 };
expect(verify(msg2)).toEqual(msg);
Expand All @@ -107,7 +116,7 @@ describe('extract iOS notification data', () => {
test('very-old-style messages', () => {
const sender_email = '[email protected]';
// baseline
expect(make({ realm_uri, recipient_type: 'private', sender_email })).toBeTruthy();
expect(make({ realm_uri, recipient_type: 'private', sender_email })()).toBeTruthy();
// missing recipient_type
expect(make({ realm_uri, sender_email })).toThrow(/archaic/);
// missing realm_uri
Expand Down Expand Up @@ -155,6 +164,13 @@ describe('extract iOS notification data', () => {
realm_uri: ['array', 'of', 'string'],
}),
).toThrow(/invalid/);

expect(
make({
...barebones.stream,
user_id: 'abc',
}),
).toThrow(/invalid/);
});

test('hypothetical future: different event types', () => {
Expand Down
17 changes: 13 additions & 4 deletions src/notification/extract.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import PushNotificationIOS from '@react-native-community/push-notification-ios';

import type { Notification } from './types';
import { makeUserId } from '../api/idTypes';
import type { JSONable, JSONableDict, JSONableInput, JSONableInputDict } from '../utils/jsonable';
import * as logging from '../utils/logging';

Expand Down Expand Up @@ -172,24 +173,32 @@ export const fromAPNsImpl = (rawData: ?JSONableDict): Notification | void => {
throw err('invalid');
}

const { realm_uri } = zulip;
const { realm_uri, user_id } = zulip;
if (realm_uri === undefined) {
throw err('archaic (pre-1.9.x)');
}
if (typeof realm_uri !== 'string') {
throw err('invalid');
}
if (user_id !== undefined && typeof user_id !== 'number') {
throw err('invalid');
}

const identity = {
realm_uri,
...(user_id === undefined ? Object.freeze({}) : { user_id: makeUserId(user_id) }),
};

if (recipient_type === 'stream') {
const { stream, topic } = zulip;
if (typeof stream !== 'string' || typeof topic !== 'string') {
throw err('invalid');
}
return {
...identity,
recipient_type: 'stream',
stream,
topic,
realm_uri,
};
} else {
/* recipient_type === 'private' */
Expand All @@ -204,16 +213,16 @@ export const fromAPNsImpl = (rawData: ?JSONableDict): Notification | void => {
throw err('invalid');
}
return {
...identity,
recipient_type: 'private',
pm_users: ids.sort((a, b) => a - b).join(','),
realm_uri,
};
}

if (typeof sender_email !== 'string') {
throw err('invalid');
}
return { recipient_type: 'private', sender_email, realm_uri };
return { ...identity, recipient_type: 'private', sender_email };
}

/* unreachable */
Expand Down
71 changes: 49 additions & 22 deletions src/notification/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import PushNotificationIOS from '@react-native-community/push-notification-ios';
import type { PushNotificationEventName } from '@react-native-community/push-notification-ios';

import type { Notification } from './types';
import type { Auth, Dispatch, GlobalDispatch, Identity, Narrow, UserId, UserOrBot } from '../types';
import type { Auth, Dispatch, GlobalDispatch, Account, Narrow, UserId, UserOrBot } from '../types';
import { topicNarrow, pm1to1NarrowFromUser, pmNarrowFromRecipients } from '../utils/narrow';
import type { JSONable, JSONableDict } from '../utils/jsonable';
import * as api from '../api';
Expand All @@ -25,16 +25,16 @@ import { getAccounts } from '../directSelectors';
/**
* Identify the account the notification is for, if possible.
*
* Returns an index into `identities`, or `null` if we can't tell.
* Returns an index into `accounts`, or `null` if we can't tell.
* In the latter case, logs a warning.
*
* @param identities Identities corresponding to the accounts state in Redux.
* @param accounts The accounts state in Redux.
*/
export const getAccountFromNotificationData = (
data: Notification,
identities: $ReadOnlyArray<Identity>,
accounts: $ReadOnlyArray<Account>,
Copy link
Member

Choose a reason for hiding this comment

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

The Identity type doesn't have the user ID, and it's not super
convenient to add it there. That's because the Identity type is
based on the Auth type (and we make Identity objects out of Auth
objects at runtime), and the Auth type doesn't have the user ID.
It's not super convenient to give it to the Auth type because there
are places where we make Auth objects but we don't have a user ID,
like `authFromCallbackUrl` in src/start/webAuth.js.

Oof. The Auth does of course have the email. So anywhere we have an Auth and can't readily make an Identity, because we lack the user ID, is an obstacle to converting from emails to IDs, #3764.

In authFromCallbackUrl, we're decoding one of those zulip:// URLs the server used to complete a web login and get us the API key. So we should get the server to start giving us user IDs there, even if we won't be able to count on their presence for a while. That should be a thread in #api design, then.

): number | null => {
const { realm_uri } = data;
const { realm_uri, user_id } = data;
if (realm_uri == null) {
// Old server, no realm info included. If needed to cater to 1.8.x
// servers, could try to guess using serverHost; for now, don't.
Expand All @@ -50,7 +50,7 @@ export const getAccountFromNotificationData = (
}

const urlMatches = [];
identities.forEach((account, i) => {
accounts.forEach((account, i) => {
if (account.realm.origin === realmUrl.origin) {
urlMatches.push(i);
}
Expand All @@ -62,7 +62,7 @@ export const getAccountFromNotificationData = (
// just a race -- this notification was sent before the logout); or
// there's some confusion where the realm_uri we have is different from
// the one the server sends in notifications.
const knownUrls = identities.map(({ realm }) => realm.href);
const knownUrls = accounts.map(({ realm }) => realm.href);
logging.warn('notification realm_uri not found in accounts', {
realm_uri,
parsed_url: realmUrl,
Expand All @@ -71,23 +71,50 @@ export const getAccountFromNotificationData = (
return null;
}

if (urlMatches.length > 1) {
// The user has several accounts in the notification's realm. We should
// be able to tell the right one using the notification's `user_id`...
// except we don't store user IDs in `accounts`, only emails. Until we
// fix that, just ignore the information.
logging.warn('notification realm_uri ambiguous; multiple matches found', {
realm_uri,
parsed_url: realmUrl,
match_count: urlMatches.length,
unique_identities_count: new Set(urlMatches.map(matchIndex => identities[matchIndex].email))
.size,
});
// TODO get user_id into accounts data, and use that
Copy link
Member

Choose a reason for hiding this comment

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

nit: this TODO comment should go away in the later commit where we start using the user_id

return null;
// TODO(server-2.1): Remove this, because user_id will always be present
if (user_id === undefined) {
if (urlMatches.length > 1) {
logging.warn(
'notification realm_uri ambiguous; multiple matches found; user_id missing (old server)',
{
realm_uri,
parsed_url: realmUrl,
match_count: urlMatches.length,
unique_identities_count: new Set(urlMatches.map(matchIndex => accounts[matchIndex].email))
.size,
},
);
return null;
} else {
return urlMatches[0];
}
}

return urlMatches[0];
// There may be multiple accounts in the notification's realm. Pick one
// based on the notification's `user_id`.
const userMatch = urlMatches.find(urlMatch => accounts[urlMatch].userId === user_id);
if (userMatch == null) {
// Maybe we didn't get a userId match because the correct account just
// hasn't had its userId recorded on it yet. See jsdoc on the Account
// type for when that is.
const nullUserIdMatches = urlMatches.filter(urlMatch => accounts[urlMatch].userId === null);
switch (nullUserIdMatches.length) {
case 0:
logging.warn(
'notifications: No accounts found with matching realm and matching-or-null user ID',
);
return null;
case 1:
return nullUserIdMatches[0];
default:
logging.warn(
'notifications: Multiple accounts found with matching realm and null user ID; could not choose',
{ nullUserIdMatchesCount: nullUserIdMatches.length },
);
return null;
}
}
return userMatch;
};

export const getNarrowFromNotificationData = (
Expand Down
4 changes: 2 additions & 2 deletions src/notification/notificationActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { identityOfAccount, authOfAccount } from '../account/accountMisc';
import { getAllUsersByEmail, getOwnUserId } from '../users/userSelectors';
import { doNarrow } from '../message/messagesActions';
import { accountSwitch } from '../account/accountActions';
import { getIdentities, getAccount, tryGetActiveAccountState } from '../account/accountsSelectors';
import { getAccount, tryGetActiveAccountState } from '../account/accountsSelectors';

export const gotPushToken = (pushToken: string | null): AccountIndependentAction => ({
type: GOT_PUSH_TOKEN,
Expand All @@ -53,7 +53,7 @@ export const narrowToNotification = (data: ?Notification): GlobalThunkAction<voi
}

const globalState = getState();
const accountIndex = getAccountFromNotificationData(data, getIdentities(globalState));
const accountIndex = getAccountFromNotificationData(data, getAccounts(globalState));
if (accountIndex !== null && accountIndex > 0) {
// Notification is for a non-active account. Switch there.
dispatch(accountSwitch(accountIndex));
Expand Down
12 changes: 9 additions & 3 deletions src/notification/types.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
// @flow strict-local
import type { UserId } from '../types';

type NotificationBase = {|
realm_uri: string,
user_id?: UserId,
|};

/**
* The data we need in JS/React code for acting on a notification.
Expand All @@ -13,8 +19,8 @@
*/
// NOTE: Keep the Android-side code in sync with this type definition.
export type Notification =
| {| recipient_type: 'stream', stream: string, topic: string, realm_uri: string |}
| {| ...NotificationBase, recipient_type: 'stream', stream: string, topic: string |}
// Group PM messages have `pm_users`, which is sorted, comma-separated IDs.
| {| recipient_type: 'private', pm_users: string, realm_uri: string |}
| {| ...NotificationBase, recipient_type: 'private', pm_users: string |}
// 1:1 PM messages lack `pm_users`.
| {| recipient_type: 'private', sender_email: string, realm_uri: string |};
| {| ...NotificationBase, recipient_type: 'private', sender_email: string |};