diff --git a/src/__tests__/lib/exampleData.js b/src/__tests__/lib/exampleData.js index 578ae3cc09b..5f2885600fb 100644 --- a/src/__tests__/lib/exampleData.js +++ b/src/__tests__/lib/exampleData.js @@ -123,7 +123,7 @@ export const makeCrossRealmBot = (args: { name?: string } = {}): CrossRealmBot = is_bot: true, }); -export const realm = 'https://zulip.example.org'; +export const realm = new URL('https://zulip.example.org'); export const zulipVersion = new ZulipVersion('2.1.0-234-g7c3acf4'); @@ -133,7 +133,7 @@ export const makeAccount = ( args: { user?: User, email?: string, - realm?: string, + realm?: URL, apiKey?: string, zulipFeatureLevel?: number | null, zulipVersion?: ZulipVersion | null, @@ -475,7 +475,7 @@ export const action = deepFreeze({ realm_send_welcome_emails: true, realm_show_digest_email: true, realm_signup_notifications_stream_id: 3, - realm_uri: selfAccount.realm, + realm_uri: selfAccount.realm.toString(), realm_video_chat_provider: 1, realm_waiting_period_threshold: 3, zulip_feature_level: 1, diff --git a/src/account-info/AccountDetails.js b/src/account-info/AccountDetails.js index 6bb38e002b5..46150cf4ce2 100644 --- a/src/account-info/AccountDetails.js +++ b/src/account-info/AccountDetails.js @@ -28,7 +28,7 @@ const componentStyles = createStyleSheet({ const AVATAR_SIZE = 200; type SelectorProps = {| - realm: string, + realm: URL, userStatusText: string | void, |}; diff --git a/src/account/AccountItem.js b/src/account/AccountItem.js index a6ca72a55cc..9d7fca77fef 100644 --- a/src/account/AccountItem.js +++ b/src/account/AccountItem.js @@ -39,7 +39,7 @@ const styles = createStyleSheet({ type Props = $ReadOnly<{| index: number, email: string, - realm: string, + realm: URL, onSelect: (index: number) => void, onRemove: (index: number) => void, showDoneIcon: boolean, @@ -58,7 +58,7 @@ export default class AccountItem extends PureComponent { - + {!showDoneIcon ? ( diff --git a/src/account/AccountList.js b/src/account/AccountList.js index 7d7f187f795..01ef1585a1a 100644 --- a/src/account/AccountList.js +++ b/src/account/AccountList.js @@ -20,7 +20,7 @@ export default class AccountList extends PureComponent { `${item.email}${item.realm}`} + keyExtractor={item => `${item.email}${item.realm.toString()}`} ItemSeparatorComponent={() => } renderItem={({ item, index }) => ( { describe('REALM_ADD', () => { describe('on list of identities', () => { - const account1 = eg.makeAccount({ realm: 'https://realm.one.org', apiKey: '' }); - const account2 = eg.makeAccount({ realm: 'https://realm.two.org', apiKey: '' }); + const account1 = eg.makeAccount({ realm: new URL('https://realm.one.org'), apiKey: '' }); + const account2 = eg.makeAccount({ realm: new URL('https://realm.two.org'), apiKey: '' }); const prevState = deepFreeze([account1, account2]); const baseAction = deepFreeze({ type: REALM_ADD, @@ -27,7 +27,7 @@ describe('accountsReducer', () => { }); test('if no account with this realm exists, prepend new one, with empty email/apiKey', () => { - const newRealm = 'https://new.realm.org'; + const newRealm = new URL('https://new.realm.org'); const action = deepFreeze({ ...baseAction, realm: newRealm }); expect(accountsReducer(prevState, action)).toEqual([ eg.makeAccount({ realm: newRealm, email: '', apiKey: '' }), @@ -153,8 +153,8 @@ describe('accountsReducer', () => { }); describe('LOGIN_SUCCESS', () => { - const account1 = eg.makeAccount({ email: '', realm: 'https://one.example.org' }); - const account2 = eg.makeAccount({ realm: 'https://two.example.org' }); + const account1 = eg.makeAccount({ email: '', realm: new URL('https://one.example.org') }); + const account2 = eg.makeAccount({ realm: new URL('https://two.example.org') }); const prevState = deepFreeze([account1, account2]); @@ -181,7 +181,7 @@ describe('accountsReducer', () => { test('on login, if account does not exist, add as first item', () => { const newAccount = eg.makeAccount({ email: 'newaccount@example.com', - realm: 'https://new.realm.org', + realm: new URL('https://new.realm.org'), zulipVersion: null, zulipFeatureLevel: null, }); diff --git a/src/account/accountActions.js b/src/account/accountActions.js index ab948cdb4aa..37e40e99b91 100644 --- a/src/account/accountActions.js +++ b/src/account/accountActions.js @@ -15,7 +15,7 @@ export const switchAccount = (index: number): Action => ({ }); export const realmAdd = ( - realm: string, + realm: URL, zulipFeatureLevel: number, zulipVersion: ZulipVersion, ): Action => ({ @@ -30,7 +30,7 @@ export const removeAccount = (index: number): Action => ({ index, }); -export const loginSuccess = (realm: string, email: string, apiKey: string): Action => ({ +export const loginSuccess = (realm: URL, email: string, apiKey: string): Action => ({ type: LOGIN_SUCCESS, realm, email, diff --git a/src/account/accountMisc.js b/src/account/accountMisc.js index 5f2252906ff..0a535b9d170 100644 --- a/src/account/accountMisc.js +++ b/src/account/accountMisc.js @@ -8,7 +8,8 @@ export const identityOfAuth: Auth => Identity = identitySlice; export const identityOfAccount: Account => Identity = identitySlice; /** A string corresponding uniquely to an identity, for use in `Map`s. */ -export const keyOfIdentity = ({ realm, email }: Identity): string => `${realm}\0${email}`; +export const keyOfIdentity = ({ realm, email }: Identity): string => + `${realm.toString()}\0${email}`; export const authOfAccount = (account: Account): Auth => { const { realm, email, apiKey } = account; diff --git a/src/account/accountsReducer.js b/src/account/accountsReducer.js index b4cba842e05..c9c06e6705e 100644 --- a/src/account/accountsReducer.js +++ b/src/account/accountsReducer.js @@ -16,7 +16,9 @@ import { NULL_ARRAY } from '../nullObjects'; const initialState = NULL_ARRAY; const realmAdd = (state, action) => { - const accountIndex = state.findIndex(account => account.realm === action.realm); + const accountIndex = state.findIndex( + account => account.realm.toString() === action.realm.toString(), + ); if (accountIndex !== -1) { const newAccount = { @@ -60,7 +62,8 @@ const accountSwitch = (state, action) => { const findAccount = (state: AccountsState, identity: Identity): number => { const { realm, email } = identity; return state.findIndex( - account => account.realm === realm && (!account.email || account.email === email), + account => + account.realm.toString() === realm.toString() && (!account.email || account.email === email), ); }; diff --git a/src/actionTypes.js b/src/actionTypes.js index 2a03fbb0b3e..db6394f565a 100644 --- a/src/actionTypes.js +++ b/src/actionTypes.js @@ -142,7 +142,7 @@ type AccountSwitchAction = {| type RealmAddAction = {| type: typeof REALM_ADD, - realm: string, + realm: URL, zulipFeatureLevel: number, zulipVersion: ZulipVersion, |}; @@ -154,7 +154,7 @@ type AccountRemoveAction = {| type LoginSuccessAction = {| type: typeof LOGIN_SUCCESS, - realm: string, + realm: URL, email: string, apiKey: string, |}; diff --git a/src/api/apiFetch.js b/src/api/apiFetch.js index 95e16b02189..a571464b3ed 100644 --- a/src/api/apiFetch.js +++ b/src/api/apiFetch.js @@ -46,7 +46,7 @@ export const apiFetch = async ( auth: Auth, route: string, params: $Diff<$Exact, {| headers: mixed |}>, -) => fetchWithAuth(auth, `${auth.realm}/${apiVersion}/${route}`, params); +) => fetchWithAuth(auth, `${auth.realm.toString()}/${apiVersion}/${route}`, params); export const apiCall = async ( auth: Auth, diff --git a/src/api/settings/getServerSettings.js b/src/api/settings/getServerSettings.js index 4afa0394e63..f4a78ef44de 100644 --- a/src/api/settings/getServerSettings.js +++ b/src/api/settings/getServerSettings.js @@ -42,5 +42,5 @@ export type ApiResponseServerSettings = {| |}; /** See https://zulip.com/api/server-settings */ -export default async (realm: string): Promise => +export default async (realm: URL): Promise => apiGet({ apiKey: '', email: '', realm }, 'server_settings'); diff --git a/src/api/transportTypes.js b/src/api/transportTypes.js index 25121027c1d..00776b00746 100644 --- a/src/api/transportTypes.js +++ b/src/api/transportTypes.js @@ -16,7 +16,7 @@ * an `Auth`. */ export type Auth = {| - realm: string, + realm: URL, apiKey: string, email: string, |}; diff --git a/src/boot/store.js b/src/boot/store.js index 1104540fb2d..e3193d753ef 100644 --- a/src/boot/store.js +++ b/src/boot/store.js @@ -156,6 +156,8 @@ const migrations: { [string]: (GlobalState) => GlobalState } = { ...state, accounts: state.accounts.map(a => ({ ...a, + // `a.realm` is a string until migration 15 + // $FlowMigrationFudge realm: a.realm.replace(/\/+$/, ''), })), }), @@ -187,6 +189,16 @@ const migrations: { [string]: (GlobalState) => GlobalState } = { })), }), + // Convert Accounts[].realm from `string` to `URL` + '15': state => ({ + ...state, + accounts: state.accounts.map(a => ({ + ...a, + // $FlowMigrationFudge - `a.realm` will be a string here + realm: new URL(a.realm), + })), + }), + // TIP: When adding a migration, consider just using `dropCache`. }; @@ -271,6 +283,8 @@ const SERIALIZED_TYPE_FIELD_NAME: '__serializedType__' = '__serializedType__'; const customReplacer = (key, value, defaultReplacer) => { if (value instanceof ZulipVersion) { return { data: value.raw(), [SERIALIZED_TYPE_FIELD_NAME]: 'ZulipVersion' }; + } else if (value instanceof URL) { + return { data: value.toString(), [SERIALIZED_TYPE_FIELD_NAME]: 'URL' }; } return defaultReplacer(key, value); }; @@ -281,6 +295,8 @@ const customReviver = (key, value, defaultReviver) => { switch (value[SERIALIZED_TYPE_FIELD_NAME]) { case 'ZulipVersion': return new ZulipVersion(data); + case 'URL': + return new URL(data); default: // Fall back to defaultReviver, below } diff --git a/src/common/OwnAvatar.js b/src/common/OwnAvatar.js index 93c2d472cd2..d767ea8cea1 100644 --- a/src/common/OwnAvatar.js +++ b/src/common/OwnAvatar.js @@ -11,7 +11,7 @@ type Props = $ReadOnly<{| dispatch: Dispatch, user: User, size: number, - realm: string, + realm: URL, |}>; /** diff --git a/src/common/UserAvatarWithPresence.js b/src/common/UserAvatarWithPresence.js index 3ce7fa54a1e..2b5cae6d049 100644 --- a/src/common/UserAvatarWithPresence.js +++ b/src/common/UserAvatarWithPresence.js @@ -23,7 +23,7 @@ type Props = $ReadOnly<{| avatarUrl: ?string, email: string, size: number, - realm: string, + realm: URL, shape: 'rounded' | 'square', onPress?: () => void, |}>; @@ -43,7 +43,6 @@ class UserAvatarWithPresence extends PureComponent { avatarUrl: '', email: '', size: 32, - realm: '', shape: 'rounded', }; diff --git a/src/common/WebLink.js b/src/common/WebLink.js index 957833617e9..3ae256f5b4a 100644 --- a/src/common/WebLink.js +++ b/src/common/WebLink.js @@ -13,7 +13,7 @@ type Props = $ReadOnly<{| dispatch: Dispatch, label: string, href: string, - realm: string, + realm: URL, |}>; /** diff --git a/src/emoji/__tests__/emojiSelectors-test.js b/src/emoji/__tests__/emojiSelectors-test.js index e4ac90cb710..b545860ba5f 100644 --- a/src/emoji/__tests__/emojiSelectors-test.js +++ b/src/emoji/__tests__/emojiSelectors-test.js @@ -11,7 +11,7 @@ describe('getActiveImageEmojiById', () => { const state = { accounts: [ { - realm: 'https://example.com', + realm: new URL('https://example.com'), }, ], realm: { @@ -56,7 +56,7 @@ describe('getActiveImageEmojiById', () => { describe('getAllImageEmojiById', () => { test('get realm emojis with absolute url', () => { const state = { - accounts: [{ realm: 'https://example.com' }], + accounts: [{ realm: new URL('https://example.com') }], realm: { emoji: { 1: { @@ -87,7 +87,7 @@ describe('getAllImageEmojiById', () => { describe('getAllImageEmojiByCode', () => { test('get realm emoji object with emoji names as the keys', () => { const state = { - accounts: [{ realm: 'https://example.com' }], + accounts: [{ realm: new URL('https://example.com') }], realm: { emoji: { 1: { @@ -129,7 +129,7 @@ describe('getAllImageEmojiByCode', () => { describe('getActiveImageEmojiByName', () => { test('get realm emoji object with emoji names as the keys', () => { const state = { - accounts: [{ realm: 'https://example.com' }], + accounts: [{ realm: new URL('https://example.com') }], realm: { emoji: { 1: { diff --git a/src/nav/__tests__/navReducer-test.js b/src/nav/__tests__/navReducer-test.js index 9aa83f6d44f..add69acfc58 100644 --- a/src/nav/__tests__/navReducer-test.js +++ b/src/nav/__tests__/navReducer-test.js @@ -130,7 +130,7 @@ describe('navReducer', () => { const action = deepFreeze({ type: REHYDRATE, payload: { - accounts: [{ apiKey: '', realm: 'https://example.com' }], + accounts: [{ apiKey: '', realm: new URL('https://example.com') }], users: [], realm: {}, }, @@ -147,8 +147,8 @@ describe('navReducer', () => { type: REHYDRATE, payload: { accounts: [ - { apiKey: '', realm: 'https://example.com', email: 'johndoe@example.com' }, - { apiKey: '', realm: 'https://example.com', email: 'janedoe@example.com' }, + { apiKey: '', realm: new URL('https://example.com'), email: 'johndoe@example.com' }, + { apiKey: '', realm: new URL('https://example.com'), email: 'janedoe@example.com' }, ], users: [], realm: {}, @@ -165,7 +165,9 @@ describe('navReducer', () => { const action = deepFreeze({ type: REHYDRATE, payload: { - accounts: [{ apiKey: '', realm: 'https://example.com', email: 'johndoe@example.com' }], + accounts: [ + { apiKey: '', realm: new URL('https://example.com'), email: 'johndoe@example.com' }, + ], users: [], realm: {}, }, diff --git a/src/nav/navActions.js b/src/nav/navActions.js index 1b5ec43e06b..5e9234b7215 100644 --- a/src/nav/navActions.js +++ b/src/nav/navActions.js @@ -51,7 +51,7 @@ export const navigateToAccountDetails = (userId: number): NavigationAction => export const navigateToGroupDetails = (recipients: UserOrBot[]): NavigationAction => StackActions.push({ routeName: 'group-details', params: { recipients } }); -export const navigateToRealmScreen = (realm?: string): NavigationAction => +export const navigateToRealmScreen = (realm?: URL): NavigationAction => StackActions.push({ routeName: 'realm', params: { realm } }); export const navigateToLightbox = (src: string, message: Message): NavigationAction => diff --git a/src/notification/index.js b/src/notification/index.js index 3ed221f27b6..eab6e18b538 100644 --- a/src/notification/index.js +++ b/src/notification/index.js @@ -38,7 +38,7 @@ export const getAccountFromNotificationData = ( const urlMatches = []; identities.forEach((account, i) => { - if (account.realm === realm_uri) { + if (account.realm.toString() === realm_uri) { urlMatches.push(i); } }); @@ -49,7 +49,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); + const knownUrls = identities.map(({ realm }) => realm.toString()); logging.warn('notification realm_uri not found in accounts', { realm_uri, known_urls: knownUrls, diff --git a/src/settings/LegalScreen.js b/src/settings/LegalScreen.js index 5306dc471f1..b0a49a65131 100644 --- a/src/settings/LegalScreen.js +++ b/src/settings/LegalScreen.js @@ -10,7 +10,7 @@ import { getCurrentRealm } from '../selectors'; type Props = $ReadOnly<{| dispatch: Dispatch, - realm: string, + realm: URL, |}>; class LegalScreen extends PureComponent { diff --git a/src/start/AuthScreen.js b/src/start/AuthScreen.js index 3d8c9e9b2a2..695968ea839 100644 --- a/src/start/AuthScreen.js +++ b/src/start/AuthScreen.js @@ -27,7 +27,7 @@ import styles from '../styles'; import { Centerer, Screen, ZulipButton } from '../common'; import { getCurrentRealm } from '../selectors'; import RealmInfo from './RealmInfo'; -import { encodeParamsForUrl, tryParseUrl } from '../utils/url'; +import { encodeParamsForUrl } from '../utils/url'; import * as webAuth from './webAuth'; import { loginSuccess, navigateToDev, navigateToPassword } from '../actions'; import IosCompliantAppleAuthButton from './IosCompliantAppleAuthButton'; @@ -168,7 +168,7 @@ export const activeAuthentications = ( type Props = $ReadOnly<{| dispatch: Dispatch, - realm: string, + realm: URL, navigation: NavigationScreenProp<{ params: {| serverSettings: ApiResponseServerSettings |} }>, |}>; @@ -260,7 +260,8 @@ class AuthScreen extends PureComponent { id_token: credential.identityToken, }); - openLink(`${this.props.realm}/complete/apple/?${params}`); + // TODO: Use a `URL` computation, for #4146. + openLink(`${this.props.realm.toString()}/complete/apple/?${params}`); // Currently, the rest is handled with the `zulip://` redirect, // same as in the web flow. @@ -275,12 +276,7 @@ class AuthScreen extends PureComponent { return false; } - const host = tryParseUrl(this.props.realm)?.host; - if (host === undefined) { - // `this.props.realm` invalid. - // TODO: Check this much sooner. - return false; - } + const { host } = this.props.realm; // The native flow for Apple auth assumes that the app and the server // are operated by the same organization, so that for a user to diff --git a/src/start/RealmScreen.js b/src/start/RealmScreen.js index 68eb3f69881..73194af3606 100644 --- a/src/start/RealmScreen.js +++ b/src/start/RealmScreen.js @@ -18,7 +18,7 @@ type SelectorProps = {| type Props = $ReadOnly<{| navigation: NavigationScreenProp<{ params: ?{| - realm: string | void, + realm: URL | void, initial?: boolean, |}, }>, @@ -28,7 +28,7 @@ type Props = $ReadOnly<{| |}>; type State = {| - realm: string, + realmInputValue: string, error: string | null, progress: boolean, |}; @@ -36,16 +36,16 @@ type State = {| class RealmScreen extends PureComponent { state = { progress: false, - realm: this.props.initialRealm, + realmInputValue: this.props.initialRealm, error: null, }; scrollView: ScrollView; tryRealm = async () => { - const { realm } = this.state; + const { realmInputValue } = this.state; - const parsedRealm = tryParseUrl(realm); + const parsedRealm = tryParseUrl(realmInputValue); if (!parsedRealm) { this.setState({ error: 'Please enter a valid URL' }); return; @@ -61,10 +61,10 @@ class RealmScreen extends PureComponent { error: null, }); try { - const serverSettings: ApiResponseServerSettings = await api.getServerSettings(realm); + const serverSettings: ApiResponseServerSettings = await api.getServerSettings(parsedRealm); dispatch( realmAdd( - realm, + parsedRealm, serverSettings.zulip_feature_level ?? 0, new ZulipVersion(serverSettings.zulip_version), ), @@ -81,7 +81,7 @@ class RealmScreen extends PureComponent { } }; - handleRealmChange = (value: string) => this.setState({ realm: value }); + handleRealmChange = (value: string) => this.setState({ realmInputValue: value }); componentDidMount() { const { initialRealm } = this.props; @@ -92,7 +92,7 @@ class RealmScreen extends PureComponent { render() { const { initialRealm, navigation } = this.props; - const { progress, error, realm } = this.state; + const { progress, error, realmInputValue } = this.state; const styles = { input: { marginTop: 16, marginBottom: 8 }, @@ -131,7 +131,7 @@ class RealmScreen extends PureComponent { text="Enter" progress={progress} onPress={this.tryRealm} - disabled={!isValidUrl(realm)} + disabled={!isValidUrl(realmInputValue)} /> ); @@ -139,5 +139,5 @@ class RealmScreen extends PureComponent { } export default connect((state, props) => ({ - initialRealm: props.navigation.state.params?.realm ?? '', + initialRealm: props.navigation.state.params?.realm?.toString() ?? '', }))(RealmScreen); diff --git a/src/start/__tests__/webAuth-test.js b/src/start/__tests__/webAuth-test.js index 4f64b856c88..d3395e955f5 100644 --- a/src/start/__tests__/webAuth-test.js +++ b/src/start/__tests__/webAuth-test.js @@ -1,25 +1,29 @@ /* @flow strict-local */ import { authFromCallbackUrl } from '../webAuth'; +import * as eg from '../../__tests__/lib/exampleData'; describe('authFromCallbackUrl', () => { const otp = '13579bdf'; - const realm = 'https://chat.example'; test('success', () => { - const url = `zulip://login?realm=${realm}&email=a@b&otp_encrypted_api_key=2636fdeb`; - expect(authFromCallbackUrl(url, otp, realm)).toEqual({ realm, email: 'a@b', apiKey: '5af4' }); + const url = `zulip://login?realm=${eg.realm.toString()}&email=a@b&otp_encrypted_api_key=2636fdeb`; + expect(authFromCallbackUrl(url, otp, eg.realm)).toEqual({ + realm: eg.realm, + email: 'a@b', + apiKey: '5af4', + }); }); test('wrong realm', () => { const url = 'zulip://login?realm=https://other.example.org&email=a@b&otp_encrypted_api_key=2636fdeb'; - expect(authFromCallbackUrl(url, otp, realm)).toEqual(null); + expect(authFromCallbackUrl(url, otp, eg.realm)).toEqual(null); }); test('not login', () => { // Hypothetical link that isn't a login... but somehow with all the same // query params, for extra confusion for good measure. - const url = `zulip://message?realm=${realm}&email=a@b&otp_encrypted_api_key=2636fdeb`; - expect(authFromCallbackUrl(url, otp, realm)).toEqual(null); + const url = `zulip://message?realm=${eg.realm.toString()}&email=a@b&otp_encrypted_api_key=2636fdeb`; + expect(authFromCallbackUrl(url, otp, eg.realm)).toEqual(null); }); }); diff --git a/src/start/webAuth.js b/src/start/webAuth.js index 57bb861f314..d818708675b 100644 --- a/src/start/webAuth.js +++ b/src/start/webAuth.js @@ -66,11 +66,7 @@ export const closeBrowser = () => { */ const extractApiKey = (encoded: string, otp: string) => hexToAscii(xorHexStrings(encoded, otp)); -export const authFromCallbackUrl = ( - callbackUrl: string, - otp: string, - realm: string, -): Auth | null => { +export const authFromCallbackUrl = (callbackUrl: string, otp: string, realm: URL): Auth | null => { // callback format expected: zulip://login?realm={}&email={}&otp_encrypted_api_key={} const url = tryParseUrl(callbackUrl); if (!url) { @@ -82,8 +78,7 @@ export const authFromCallbackUrl = ( return null; } const callbackRealm = tryParseUrl(callbackRealmStr); - // TODO: Check validity of `realm` much sooner - if (!callbackRealm || callbackRealm.origin !== new URL(realm).origin) { + if (!callbackRealm || callbackRealm.origin !== realm.origin) { return null; } diff --git a/src/utils/__tests__/internalLinks-test.js b/src/utils/__tests__/internalLinks-test.js index 6af79ffa69c..80cd605e905 100644 --- a/src/utils/__tests__/internalLinks-test.js +++ b/src/utils/__tests__/internalLinks-test.js @@ -12,148 +12,95 @@ import { } from '../internalLinks'; import * as eg from '../../__tests__/lib/exampleData'; +const realm = new URL('https://example.com'); + describe('isInternalLink', () => { test('when link is external, return false', () => { - expect(isInternalLink('https://example.com', 'https://another.com')).toBe(false); + expect(isInternalLink('https://example.com', new URL('https://another.com'))).toBe(false); }); test('when link is internal, but not in app, return false', () => { - expect(isInternalLink('https://example.com/user_uploads', 'https://example.com')).toBe(false); + expect(isInternalLink('https://example.com/user_uploads', realm)).toBe(false); }); test('when link is internal and in app, return true', () => { - expect(isInternalLink('https://example.com/#narrow/stream/jest', 'https://example.com')).toBe( - true, - ); + expect(isInternalLink('https://example.com/#narrow/stream/jest', realm)).toBe(true); }); test('when link is relative and in app, return true', () => { - expect(isInternalLink('#narrow/stream/jest/topic/topic1', 'https://example.com')).toBe(true); - expect(isInternalLink('/#narrow/stream/jest', 'https://example.com')).toBe(true); + expect(isInternalLink('#narrow/stream/jest/topic/topic1', realm)).toBe(true); + expect(isInternalLink('/#narrow/stream/jest', realm)).toBe(true); }); test('links including IDs are also recognized', () => { - expect(isInternalLink('#narrow/stream/123-jest/topic/topic1', 'https://example.com')).toBe( - true, - ); - expect(isInternalLink('/#narrow/stream/123-jest', 'https://example.com')).toBe(true); - expect(isInternalLink('/#narrow/pm-with/123-mark', 'https://example.com')).toBe(true); + expect(isInternalLink('#narrow/stream/123-jest/topic/topic1', realm)).toBe(true); + expect(isInternalLink('/#narrow/stream/123-jest', realm)).toBe(true); + expect(isInternalLink('/#narrow/pm-with/123-mark', realm)).toBe(true); }); }); describe('isMessageLink', () => { test('only in-app link containing "near/" is a message link', () => { - expect(isMessageLink('https://example.com/#narrow/stream/jest', 'https://example.com')).toBe( - false, - ); - expect(isMessageLink('https://example.com/#narrow/#near/1', 'https://example.com')).toBe(true); + expect(isMessageLink('https://example.com/#narrow/stream/jest', realm)).toBe(false); + expect(isMessageLink('https://example.com/#narrow/#near/1', realm)).toBe(true); }); }); describe('getLinkType', () => { test('links to a different domain are of "external" type', () => { - expect(getLinkType('https://google.com/some-path', 'https://example.com')).toBe('external'); + expect(getLinkType('https://google.com/some-path', realm)).toBe('external'); }); test('only in-app link containing "stream" is a stream link', () => { - expect( - getLinkType('https://example.com/#narrow/pm-with/1,2-group', 'https://example.com'), - ).toBe('pm'); - expect(getLinkType('https://example.com/#narrow/stream/jest', 'https://example.com')).toBe( - 'stream', - ); - expect(getLinkType('https://example.com/#narrow/stream/stream/', 'https://example.com')).toBe( - 'stream', - ); + expect(getLinkType('https://example.com/#narrow/pm-with/1,2-group', realm)).toBe('pm'); + expect(getLinkType('https://example.com/#narrow/stream/jest', realm)).toBe('stream'); + expect(getLinkType('https://example.com/#narrow/stream/stream/', realm)).toBe('stream'); }); test('when a url is not a topic narrow return false', () => { - expect( - getLinkType('https://example.com/#narrow/pm-with/1,2-group', 'https://example.com'), - ).toBe('pm'); - expect(getLinkType('https://example.com/#narrow/stream/jest', 'https://example.com')).toBe( - 'stream', - ); - expect( - getLinkType( - 'https://example.com/#narrow/stream/stream/topic/topic/near/', - 'https://example.com', - ), - ).toBe('home'); - expect(getLinkType('https://example.com/#narrow/stream/topic/', 'https://example.com')).toBe( - 'stream', + expect(getLinkType('https://example.com/#narrow/pm-with/1,2-group', realm)).toBe('pm'); + expect(getLinkType('https://example.com/#narrow/stream/jest', realm)).toBe('stream'); + expect(getLinkType('https://example.com/#narrow/stream/stream/topic/topic/near/', realm)).toBe( + 'home', ); + expect(getLinkType('https://example.com/#narrow/stream/topic/', realm)).toBe('stream'); }); test('when a url is a topic narrow return true', () => { + expect(getLinkType('https://example.com/#narrow/stream/jest/topic/test', realm)).toBe('topic'); expect( - getLinkType('https://example.com/#narrow/stream/jest/topic/test', 'https://example.com'), - ).toBe('topic'); - expect( - getLinkType( - 'https://example.com/#narrow/stream/mobile/subject/topic/near/378333', - 'https://example.com', - ), - ).toBe('topic'); - expect( - getLinkType('https://example.com/#narrow/stream/mobile/topic/topic/', 'https://example.com'), - ).toBe('topic'); - expect( - getLinkType( - 'https://example.com/#narrow/stream/stream/topic/topic/near/1', - 'https://example.com', - ), + getLinkType('https://example.com/#narrow/stream/mobile/subject/topic/near/378333', realm), ).toBe('topic'); + expect(getLinkType('https://example.com/#narrow/stream/mobile/topic/topic/', realm)).toBe( + 'topic', + ); + expect(getLinkType('https://example.com/#narrow/stream/stream/topic/topic/near/1', realm)).toBe( + 'topic', + ); expect( - getLinkType( - 'https://example.com/#narrow/stream/stream/subject/topic/near/1', - 'https://example.com', - ), + getLinkType('https://example.com/#narrow/stream/stream/subject/topic/near/1', realm), ).toBe('topic'); - expect(getLinkType('/#narrow/stream/stream/subject/topic', 'https://example.com')).toBe( - 'topic', - ); + expect(getLinkType('/#narrow/stream/stream/subject/topic', realm)).toBe('topic'); }); test('only in-app link containing "pm-with" is a group link', () => { + expect(getLinkType('https://example.com/#narrow/stream/jest/topic/test', realm)).toBe('topic'); + expect(getLinkType('https://example.com/#narrow/pm-with/1,2-group', realm)).toBe('pm'); + expect(getLinkType('https://example.com/#narrow/pm-with/1,2-group/near/1', realm)).toBe('pm'); expect( - getLinkType('https://example.com/#narrow/stream/jest/topic/test', 'https://example.com'), - ).toBe('topic'); - expect( - getLinkType('https://example.com/#narrow/pm-with/1,2-group', 'https://example.com'), - ).toBe('pm'); - expect( - getLinkType('https://example.com/#narrow/pm-with/1,2-group/near/1', 'https://example.com'), - ).toBe('pm'); - expect( - getLinkType( - 'https://example.com/#narrow/pm-with/a.40b.2Ecom.2Ec.2Ed.2Ecom/near/3', - 'https://example.com', - ), + getLinkType('https://example.com/#narrow/pm-with/a.40b.2Ecom.2Ec.2Ed.2Ecom/near/3', realm), ).toBe('pm'); }); test('only in-app link containing "is" is a special link', () => { - expect( - getLinkType('https://example.com/#narrow/stream/jest/topic/test', 'https://example.com'), - ).toBe('topic'); - expect(getLinkType('https://example.com/#narrow/is/private', 'https://example.com')).toBe( - 'special', - ); - expect(getLinkType('https://example.com/#narrow/is/starred', 'https://example.com')).toBe( - 'special', - ); - expect(getLinkType('https://example.com/#narrow/is/mentioned', 'https://example.com')).toBe( - 'special', - ); - expect(getLinkType('https://example.com/#narrow/is/men', 'https://example.com')).toBe('home'); - expect(getLinkType('https://example.com/#narrow/is/men/stream', 'https://example.com')).toBe( - 'home', - ); - expect(getLinkType('https://example.com/#narrow/are/men/stream', 'https://example.com')).toBe( - 'home', - ); + expect(getLinkType('https://example.com/#narrow/stream/jest/topic/test', realm)).toBe('topic'); + expect(getLinkType('https://example.com/#narrow/is/private', realm)).toBe('special'); + expect(getLinkType('https://example.com/#narrow/is/starred', realm)).toBe('special'); + expect(getLinkType('https://example.com/#narrow/is/mentioned', realm)).toBe('special'); + expect(getLinkType('https://example.com/#narrow/is/men', realm)).toBe('home'); + expect(getLinkType('https://example.com/#narrow/is/men/stream', realm)).toBe('home'); + expect(getLinkType('https://example.com/#narrow/are/men/stream', realm)).toBe('home'); }); }); @@ -189,7 +136,7 @@ describe('getNarrowFromLink', () => { const get = (url, streams = []) => getNarrowFromLink( url, - 'https://example.com', + new URL('https://example.com'), usersById, new Map(streams.map(s => [s.stream_id, s])), ); @@ -343,26 +290,18 @@ describe('getNarrowFromLink', () => { describe('getMessageIdFromLink', () => { test('not message link', () => { - expect( - getMessageIdFromLink('https://example.com/#narrow/is/private', 'https://example.com'), - ).toBe(0); + expect(getMessageIdFromLink('https://example.com/#narrow/is/private', realm)).toBe(0); }); test('when link is a group link, return anchor message id', () => { expect( - getMessageIdFromLink( - 'https://example.com/#narrow/pm-with/1,3-group/near/1/', - 'https://example.com', - ), + getMessageIdFromLink('https://example.com/#narrow/pm-with/1,3-group/near/1/', realm), ).toBe(1); }); test('when link is a topic link, return anchor message id', () => { expect( - getMessageIdFromLink( - 'https://example.com/#narrow/stream/jest/topic/test/near/1', - 'https://example.com', - ), + getMessageIdFromLink('https://example.com/#narrow/stream/jest/topic/test/near/1', realm), ).toBe(1); }); }); diff --git a/src/utils/__tests__/url-test.js b/src/utils/__tests__/url-test.js index 57a0bed50fb..b27c5c197f0 100644 --- a/src/utils/__tests__/url-test.js +++ b/src/utils/__tests__/url-test.js @@ -14,7 +14,7 @@ import type { AutocompletionDefaults } from '../url'; describe('getResource', () => { test('when uri contains domain, do not change, add auth headers', () => { const auth: Auth = { - realm: 'https://example.com/', + realm: new URL('https://example.com/'), apiKey: 'someApiKey', email: 'johndoe@example.com', }; @@ -31,7 +31,7 @@ describe('getResource', () => { }); const exampleAuth: Auth = { - realm: 'https://example.com', + realm: new URL('https://example.com'), email: 'nobody@example.org', apiKey: 'someApiKey', }; @@ -59,20 +59,20 @@ describe('getResource', () => { }); describe('isUrlOnRealm', () => { + const realm = new URL('https://example.com'); + test('when link is on realm, return true', () => { - expect(isUrlOnRealm('/#narrow/stream/jest', 'https://example.com')).toBe(true); + expect(isUrlOnRealm('/#narrow/stream/jest', realm)).toBe(true); - expect(isUrlOnRealm('https://example.com/#narrow/stream/jest', 'https://example.com')).toBe( - true, - ); + expect(isUrlOnRealm('https://example.com/#narrow/stream/jest', realm)).toBe(true); - expect(isUrlOnRealm('#narrow/#near/1', 'https://example.com')).toBe(true); + expect(isUrlOnRealm('#narrow/#near/1', realm)).toBe(true); }); test('when link is on not realm, return false', () => { - expect(isUrlOnRealm('https://demo.example.com', 'https://example.com')).toBe(false); + expect(isUrlOnRealm('https://demo.example.com', realm)).toBe(false); - expect(isUrlOnRealm('www.google.com', 'https://example.com')).toBe(false); + expect(isUrlOnRealm('www.google.com', realm)).toBe(false); }); }); diff --git a/src/utils/avatar.js b/src/utils/avatar.js index 24d7b73b707..87d553374d3 100644 --- a/src/utils/avatar.js +++ b/src/utils/avatar.js @@ -24,7 +24,7 @@ export const getGravatarFromEmail = (email: string = '', size: number): string = export const getAvatarUrl = ( avatarUrl: ?string, email: string, - realm: string, + realm: URL, size: number = 80, ): string => { if (typeof avatarUrl !== 'string') { @@ -36,11 +36,11 @@ export const getAvatarUrl = ( return size > 100 ? getMediumAvatar(fullUrl) : fullUrl; }; -export const getAvatarFromUser = (user: UserOrBot, realm: string, size?: number): string => +export const getAvatarFromUser = (user: UserOrBot, realm: URL, size?: number): string => getAvatarUrl(user.avatar_url, user.email, realm, size); export const getAvatarFromMessage = ( message: Message | Outbox, - realm: string, + realm: URL, size?: number, ): string => getAvatarUrl(message.avatar_url, message.sender_email, realm, size); diff --git a/src/utils/internalLinks.js b/src/utils/internalLinks.js index b4a9d6fe614..8d2c4c00f83 100644 --- a/src/utils/internalLinks.js +++ b/src/utils/internalLinks.js @@ -4,9 +4,11 @@ import type { Narrow, Stream, User } from '../types'; import { topicNarrow, streamNarrow, groupNarrow, specialNarrow } from './narrow'; import { isUrlOnRealm } from './url'; -const getPathsFromUrl = (url: string = '', realm: string) => { +// TODO: Work out what this does, write a jsdoc for its interface, and +// reimplement using URL object (not just for the realm) +const getPathsFromUrl = (url: string = '', realm: URL) => { const paths = url - .split(realm) + .split(realm.toString()) .pop() .split('#narrow/') .pop() @@ -19,17 +21,25 @@ const getPathsFromUrl = (url: string = '', realm: string) => { return paths; }; +// TODO: Work out what this does, write a jsdoc for its interface, and +// reimplement using URL object (not just for the realm) /** PRIVATE -- exported only for tests. */ -export const isInternalLink = (url: string, realm: string): boolean => - isUrlOnRealm(url, realm) ? /^(\/#narrow|#narrow)/i.test(url.split(realm).pop()) : false; +export const isInternalLink = (url: string, realm: URL): boolean => + isUrlOnRealm(url, realm) + ? /^(\/#narrow|#narrow)/i.test(url.split(realm.toString()).pop()) + : false; +// TODO: Work out what this does, write a jsdoc for its interface, and +// reimplement using URL object (not just for the realm) /** PRIVATE -- exported only for tests. */ -export const isMessageLink = (url: string, realm: string): boolean => +export const isMessageLink = (url: string, realm: URL): boolean => isInternalLink(url, realm) && url.includes('near'); type LinkType = 'external' | 'home' | 'pm' | 'topic' | 'stream' | 'special'; -export const getLinkType = (url: string, realm: string): LinkType => { +// TODO: Work out what this does, write a jsdoc for its interface, and +// reimplement using URL object (not just for the realm) +export const getLinkType = (url: string, realm: URL): LinkType => { if (!isInternalLink(url, realm)) { return 'external'; } @@ -115,7 +125,7 @@ const parsePmOperand = (operand, usersById) => { export const getNarrowFromLink = ( url: string, - realm: string, + realm: URL, usersById: Map, streamsById: Map, ): Narrow | null => { @@ -141,7 +151,7 @@ export const getNarrowFromLink = ( } }; -export const getMessageIdFromLink = (url: string, realm: string): number => { +export const getMessageIdFromLink = (url: string, realm: URL): number => { const paths = getPathsFromUrl(url, realm); return isMessageLink(url, realm) ? parseInt(paths[paths.lastIndexOf('near') + 1], 10) : 0; diff --git a/src/utils/url.js b/src/utils/url.js index 1266e98b183..bc06075992b 100644 --- a/src/utils/url.js +++ b/src/utils/url.js @@ -40,8 +40,10 @@ export const tryParseUrl = (url: string, base?: string | URL): URL | void => { } }; -export const isUrlOnRealm = (url: string = '', realm: string): boolean => - url.startsWith('/') || url.startsWith(realm) || !/^(http|www.)/i.test(url); +// TODO: Work out what this does, write a jsdoc for its interface, and +// reimplement using URL object (not just for the realm) +export const isUrlOnRealm = (url: string = '', realm: URL): boolean => + url.startsWith('/') || url.startsWith(realm.toString()) || !/^(http|www.)/i.test(url); const getResourceWithAuth = (uri: string, auth: Auth) => ({ uri: new URL(uri, auth.realm).toString(), diff --git a/src/webview/html/messageTypingAsHtml.js b/src/webview/html/messageTypingAsHtml.js index 1e2db871951..8360767b1d2 100644 --- a/src/webview/html/messageTypingAsHtml.js +++ b/src/webview/html/messageTypingAsHtml.js @@ -3,7 +3,7 @@ import template from './template'; import type { UserOrBot } from '../../types'; import { getAvatarFromUser } from '../../utils/avatar'; -const typingAvatar = (realm: string, user: UserOrBot): string => template` +const typingAvatar = (realm: URL, user: UserOrBot): string => template`
template`
`; -export default (realm: string, users: $ReadOnlyArray): string => template` +export default (realm: URL, users: $ReadOnlyArray): string => template` $!${users.map(user => typingAvatar(realm, user)).join('')}
diff --git a/src/webview/js/__tests__/rewriteHtml.js b/src/webview/js/__tests__/rewriteHtml.js index 0833a6bb5dc..2f48d81e8e1 100644 --- a/src/webview/js/__tests__/rewriteHtml.js +++ b/src/webview/js/__tests__/rewriteHtml.js @@ -5,7 +5,7 @@ import rewriteHtml from '../rewriteHtml'; import type { Auth } from '../../../types'; describe('rewriteHtml', () => { - const realm = 'https://realm.example.com'; + const realm = new URL('https://realm.example.com'); global.jsdom.reconfigure({ url: 'file:///nowhere_land/index.html' }); const auth: Auth = { @@ -27,7 +27,7 @@ describe('rewriteHtml', () => { const prefixes = { relative: '', 'root-relative': '/', - 'absolute on-realm': realm, + 'absolute on-realm': realm.toString(), 'absolute off-realm': 'https://example.org', }; diff --git a/src/webview/js/generatedEs3.js b/src/webview/js/generatedEs3.js index bcd5acfe35a..98f7042bf30 100644 --- a/src/webview/js/generatedEs3.js +++ b/src/webview/js/generatedEs3.js @@ -36,6 +36,55 @@ var compiledWebviewJs = (function (exports) { return Constructor; } + function _defineProperty(obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + + return obj; + } + + function ownKeys(object, enumerableOnly) { + var keys = Object.keys(object); + + if (Object.getOwnPropertySymbols) { + var symbols = Object.getOwnPropertySymbols(object); + if (enumerableOnly) symbols = symbols.filter(function (sym) { + return Object.getOwnPropertyDescriptor(object, sym).enumerable; + }); + keys.push.apply(keys, symbols); + } + + return keys; + } + + function _objectSpread2(target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i] != null ? arguments[i] : {}; + + if (i % 2) { + ownKeys(Object(source), true).forEach(function (key) { + _defineProperty(target, key, source[key]); + }); + } else if (Object.getOwnPropertyDescriptors) { + Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); + } else { + ownKeys(Object(source)).forEach(function (key) { + Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); + }); + } + } + + return target; + } + var sendMessage = (function (msg) { window.ReactNativeWebView.postMessage(JSON.stringify(msg)); }); @@ -182,7 +231,7 @@ var compiledWebviewJs = (function (exports) { }); var rewriteImageUrls = function rewriteImageUrls(auth, element) { - var realm = new URL(auth.realm); + var realm = auth.realm; var imageTags = [].concat(element instanceof HTMLImageElement ? [element] : [], Array.from(element.getElementsByTagName('img'))); imageTags.forEach(function (img) { var actualSrc = img.getAttribute('src'); @@ -610,7 +659,11 @@ var compiledWebviewJs = (function (exports) { sendScrollMessageIfListShort(); }; - var handleInitialLoad = function handleInitialLoad(platformOS, scrollMessageId, auth) { + var handleInitialLoad = function handleInitialLoad(platformOS, scrollMessageId, rawAuth) { + var auth = _objectSpread2(_objectSpread2({}, rawAuth), {}, { + realm: new URL(rawAuth.realm) + }); + if (platformOS === 'ios') { window.addEventListener('message', handleMessageEvent); } else { diff --git a/src/webview/js/js.js b/src/webview/js/js.js index 30621d659f9..3760e9c40d2 100644 --- a/src/webview/js/js.js +++ b/src/webview/js/js.js @@ -544,8 +544,12 @@ const handleUpdateEventContent = (uevent: WebViewUpdateEventContent) => { export const handleInitialLoad = ( platformOS: string, scrollMessageId: number | null, - auth: Auth, + // The `realm` part of an `Auth` object is a URL object. It's passed + // in its stringified form. + rawAuth: {| ...$Diff, realm: string |}, ) => { + const auth: Auth = { ...rawAuth, realm: new URL(rawAuth.realm) }; + // Since its version 5.x, the `react-native-webview` library dispatches our // `message` events at `window` on iOS but `document` on Android. if (platformOS === 'ios') { diff --git a/src/webview/js/rewriteHtml.js b/src/webview/js/rewriteHtml.js index ec877e7aa8d..2ea2cc745e0 100644 --- a/src/webview/js/rewriteHtml.js +++ b/src/webview/js/rewriteHtml.js @@ -16,7 +16,7 @@ const inlineApiRoutes: RegExp[] = ['^/user_uploads/', '^/thumbnail$', '^/avatar/ * inject an API key into its query parameters. */ const rewriteImageUrls = (auth: Auth, element: Element | Document) => { - const realm = new URL(auth.realm); + const realm = auth.realm; // Find the image elements to act on. const imageTags: $ReadOnlyArray = [].concat(