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
22 changes: 13 additions & 9 deletions src/api/modelTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,24 +345,28 @@ export type Reaction = $ReadOnly<{|
/**
* "Snapshot" objects from https://zulip.com/api/get-message-history .
*
* As of writing this JSDoc, the docs are unclear when/if the content, topic and
* rendered_content fields will be sent. Empirically, it has been determined that
* these are always sent, but this may change. See discussion here [1].
*
* [1]: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/get-message-history.20docs/near/921587
*
* See also `MessageEdit`.
*/
export type MessageSnapshot = $ReadOnly<{|
user_id: number,
timestamp: number,
topic: string,
content: string,
rendered_content: string,

/** Docs unclear but suggest absent if only content edited. */
topic?: string,
Copy link
Contributor

Choose a reason for hiding this comment

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

by experimenting it's possible to determine which fields are always present.

It sounds like you've tested at least the common cases (a topic was edited but not the content, or vice versa) and seen that these fields fields were there in those cases.

I suppose, to make this entirely true, we have to also be sure that there won't be any surprising cases we haven't tested yet. It might be easier to post in #mobile-team with any questions about the docs, and that may have the side-effect of improving the docs for the next person who has the same questions. 🙂

Copy link
Member

Choose a reason for hiding this comment

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

That discussion here:
https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/get-message-history.20docs/near/921587

Whenever we find a place where the API docs seem to be wrong, I always want to discuss it in chat and bring it to Tim's attention there. Reasons include:

  • I want the API docs to converge on being right too 🙂 -- if we have a steady state where our types say one thing and the API docs say another, that'll be confusing to a later reader
  • If for whatever reason the API docs don't get updated promptly, I'll want the comments in our code to be super explicit that the API docs say X but the truth is Y, and here's how we've determined the truth is Y. That gives the future reader a hope of not being confused by the contradiction, because at least they can see that the author of the types was aware of what the API docs said, and wasn't just misreading them, or working from what at that future time is an out-of-date version
  • The answer might be that the documented API is as intended, and the server's behavior that disagrees is a bug!

As of right now (since a few minutes ago), the status is that Tim confirms the current behavior is what you're seeing empirically -- but potentially wants to fix it by changing the behavior to match the docs instead of vice versa.


/**
* Docs unclear, but suggest these five absent if only topic edited.
* They definitely say "prev"/"diff" properties absent on the first snapshot.
*/
content?: string,
rendered_content?: string,
// These are present just if the content was edited.
prev_content?: string,
prev_rendered_content?: string,
content_html_diff?: string,

// Present just if the topic was edited.
prev_topic?: string,
|}>;

/**
Expand Down
152 changes: 152 additions & 0 deletions src/common/ZulipWebView.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/* @flow strict-local */
import React from 'react';
import { Platform, NativeModules } from 'react-native';
import { WebView } from 'react-native-webview';
import type { WebViewMessageEvent, WebViewNavigation } from 'react-native-webview';
import { tryParseUrl } from '../utils/url';
import * as logging from '../utils/logging';

/**
* Returns the `onShouldStartLoadWithRequest` function for webviews for a
* given base URL.
*
* Paranoia^WSecurity: only load `baseUrl`, and only load it once. Any other
* requests should be handed off to the OS, not loaded inside the WebView.
*/
const getOnShouldStartLoadWithRequest = (baseUrl: string) => {
const onShouldStartLoadWithRequest: (event: WebViewNavigation) => boolean = (() => {
// Inner closure to actually test the URL.
const urlTester: (url: string) => boolean = (() => {
// On Android this function is documented to be skipped on first load:
// therefore, simply never return true.
if (Platform.OS === 'android') {
return (url: string) => false;
}

// Otherwise (for iOS), return a closure that evaluates to `true` _exactly
// once_, and even then only if the URL looks like what we're expecting.
let loaded_once = false;
return (url: string) => {
const parsedUrl = tryParseUrl(url);
if (!loaded_once && parsedUrl && parsedUrl.toString() === baseUrl.toString()) {
loaded_once = true;
return true;
}
return false;
};
})();

// Outer closure to perform logging.
return (event: WebViewNavigation) => {
const ok = urlTester(event.url);
if (!ok) {
logging.warn('webview: rejected navigation event', {
navigation_event: { ...event },
expected_url: baseUrl.toString(),
});
}
return ok;
};
})();

return onShouldStartLoadWithRequest;
};

/**
* The URL of the platform-specific assets folder.
*
* - On iOS: We can't easily hardcode this because it includes UUIDs.
* So we bring it over the React Native bridge in ZLPConstants.m.
*
* - On Android: Different apps' WebViews see different (virtual) root
* directories as `file:///`, and in particular the WebView provides
* the APK's `assets/` directory as `file:///android_asset/`. [1]
* We can easily hardcode that, so we do.
*
* [1] Oddly, this essential feature doesn't seem to be documented! It's
* widely described in how-tos across the web and StackOverflow answers.
* It's assumed in some related docs which mention it in passing, and
* treated matter-of-factly in some Chromium bug threads. Details at:
* https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/android.20filesystem/near/796440
*/
const assetsUrl =
Platform.OS === 'ios'
? new URL(NativeModules.ZLPConstants.resourceURL)
: new URL('file:///android_asset/');

/**
* The URL of the webview-assets folder.
*
* This is the folder populated at build time by `tools/build-webview`.
*/
const webviewAssetsUrl = new URL('webview/', assetsUrl);

type Props = $ReadOnly<{|
html: string,
onMessage?: WebViewMessageEvent => mixed,
onError?: (event: mixed) => void,
|}>;

/**
* A wrapper over React Native Webview. Internally configures basic functionality
* and security.
*
* This is a stateless function component, to ensure there are no un-necessary
* re-renders of the webview due to state changes.
*
* @prop html The HTML to be rendered.
* @prop onMessage Message event handler for the webview.
* @prop onError Error event handler for the webview.
*/
const ZulipWebView = function ZulipWebView(
props: $ReadOnly<{|
...Props,
innerRef: ((null | WebView) => mixed) | { current: null | WebView },
|}>,
) {
const { onMessage, onError, innerRef } = props;

/**
* Effective URL of the MessageList webview.
*
* It points to `index.html` in the webview-assets folder, which
* doesn't exist.
*
* It doesn't need to exist because we provide all HTML at
* creation (or refresh) time. This serves only as a placeholder,
* so that relative URLs (e.g., to `base.css`, which does exist)
* and cross-domain security restrictions have somewhere to
* believe that this document originates from.
*/
const baseUrl = new URL('index.html', webviewAssetsUrl);

// Needs to be declared explicitly to prevent Flow from getting confused.
const html: string = props.html;

// The `originWhitelist` and `onShouldStartLoadWithRequest` props are
// meant to mitigate possible XSS bugs, by interrupting an attempted
// exploit if it tries to navigate to a new URL by e.g. setting
// `window.location`.
//
// Note that neither of them is a hard security barrier; they're checked
// only against the URL of the document itself. They cannot be used to
// validate the URL of other resources the WebView loads.
//
// Worse, the `originWhitelist` parameter is completely broken. See:
// https://github.com/react-native-community/react-native-webview/pull/697
return (
<WebView
source={{ baseUrl: (baseUrl.toString(): string), html }}
originWhitelist={['file://']}
onShouldStartLoadWithRequest={getOnShouldStartLoadWithRequest(baseUrl.toString())}
style={{ backgroundColor: 'transparent' }}
onMessage={onMessage}
onError={onError}
ref={innerRef}
/>
);
};

export default React.forwardRef<Props, WebView>((props, ref) => (
<ZulipWebView innerRef={ref} {...props} />
));
1 change: 1 addition & 0 deletions src/common/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { default as FloatingActionButton } from './FloatingActionButton';
export { default as UserAvatar } from './UserAvatar';
export { default as Input } from './Input';
export { default as InputWithClearButton } from './InputWithClearButton';
export { default as ZulipWebView } from './ZulipWebView';
export { default as KeyboardAvoider } from './KeyboardAvoider';
export { default as Label } from './Label';
export { default as LineSeparator } from './LineSeparator';
Expand Down
86 changes: 86 additions & 0 deletions src/message/EditHistory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/* @flow strict-local */

import React from 'react';
import { View } from 'react-native';
import type { NavigationScreenProp } from 'react-navigation';
import type { Dispatch, Auth, ThemeName, UserOrBot } from '../types';
import SpinningProgress from '../common/SpinningProgress';
import type { MessageSnapshot } from '../api/modelTypes';
import { connect } from '../react-redux';
import { getAuth, getSettings, getAllUsersById } from '../selectors';
import { Screen, ZulipWebView } from '../common';
import { showToast } from '../utils/info';
import * as api from '../api';
import editHistoryHtml from '../webview/html/editHistoryHtml';

type SelectorProps = {|
auth: Auth,
usersById: Map<number, UserOrBot>,
themeName: ThemeName,
|};

type Props = $ReadOnly<{|
navigation: NavigationScreenProp<{ params: {| messageId: number |} }>,

dispatch: Dispatch,
...SelectorProps,
|}>;

type State = $ReadOnly<{|
messageHistory: MessageSnapshot[] | null,
|}>;

class EditHistory extends React.Component<Props, State> {
state = {
messageHistory: null,
};

componentDidMount() {
const { auth, navigation } = this.props;

api
.getMessageHistory(auth, navigation.state.params.messageId)
.then(response => {
this.setState({
messageHistory: response.message_history,
});
})
.catch(err => {
navigation.goBack();
showToast('An error occurred while loading edit history');
});
}

render() {
const { messageHistory } = this.state;

if (messageHistory === null) {
return (
<Screen title="Edit History">
<View style={{ justifyContent: 'center', alignItems: 'center', flex: 1 }}>
<SpinningProgress color="white" size={48} />
</View>
</Screen>
);
}
const { usersById, auth, themeName } = this.props;

return (
<Screen title="Edit History">
<ZulipWebView
html={editHistoryHtml(messageHistory, themeName, usersById, auth)}
onError={(msg: mixed) => {
// eslint-disable-next-line no-console
console.error(msg);
}}
/>
</Screen>
);
}
}

export default connect<SelectorProps, _, _>((state, props) => ({
auth: getAuth(state),
usersById: getAllUsersById(state),
themeName: getSettings(state).theme,
}))(EditHistory);
15 changes: 14 additions & 1 deletion src/message/messageActionSheet.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { isTopicMuted } from '../utils/message';
import * as api from '../api';
import { showToast } from '../utils/info';
import { doNarrow, deleteOutboxMessage, navigateToEmojiPicker } from '../actions';
import { navigateToMessageReactionScreen } from '../nav/navActions';
import { navigateToMessageReactionScreen, navigateToEditHistory } from '../nav/navActions';
import { pmUiRecipientsFromMessage, streamNameOfStreamMessage } from '../utils/recipient';
import { deleteMessagesForTopic } from '../topics/topicActions';
import * as logging from '../utils/logging';
Expand All @@ -44,6 +44,7 @@ type ButtonDescription = {
ownUser: User,
message: Message | Outbox,
subscriptions: Subscription[],
backgroundData: BackgroundData,
dispatch: Dispatch,
_: GetText,
startEditMessage: (editMessage: EditMessage) => void,
Expand Down Expand Up @@ -204,6 +205,12 @@ const showReactions = ({ message, dispatch }) => {
showReactions.title = 'See who reacted';
showReactions.errorMessage = 'Failed to show reactions';

const editHistory = ({ message, dispatch }) => {
NavigationService.dispatch(navigateToEditHistory(message.id));
};
editHistory.title = 'Show edit history';
editHistory.errorMessage = 'Failed to show edit history';

const cancel = params => {};
cancel.title = 'Cancel';
cancel.errorMessage = 'Failed to hide menu';
Expand All @@ -219,6 +226,7 @@ const allButtonsRaw = {
starMessage,
unstarMessage,
showReactions,
editHistory,

// For headers
unmuteTopic,
Expand Down Expand Up @@ -321,6 +329,11 @@ export const constructMessageActionButtons = ({
} else {
buttons.push('starMessage');
}

if (message.last_edit_timestamp !== undefined) {
buttons.push('editHistory');
}

buttons.push('cancel');
return buttons;
};
Expand Down
2 changes: 2 additions & 0 deletions src/nav/AppNavigator.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import EmojiPickerScreen from '../emoji/EmojiPickerScreen';
import LegalScreen from '../settings/LegalScreen';
import UserStatusScreen from '../user-status/UserStatusScreen';
import SharingScreen from '../sharing/SharingScreen';
import EditHistory from '../message/EditHistory';

export default createStackNavigator(
// $FlowFixMe react-navigation types :-/ -- see a36814e80
Expand All @@ -65,6 +66,7 @@ export default createStackNavigator(
},
},
'message-reactions': { screen: MessageReactionList },
'edit-history': { screen: EditHistory },
password: { screen: PasswordAuthScreen },
realm: { screen: RealmScreen },
search: { screen: SearchMessagesScreen },
Expand Down
3 changes: 3 additions & 0 deletions src/nav/navActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ export const navigateToMessageReactionScreen = (
): NavigationAction =>
StackActions.push({ routeName: 'message-reactions', params: { messageId, reactionName } });

export const navigateToEditHistory = (messageId: number): NavigationAction =>
StackActions.push({ routeName: 'edit-history', params: { messageId } });

export const navigateToLegal = (): NavigationAction => StackActions.push({ routeName: 'legal' });

export const navigateToUserStatus = (): NavigationAction =>
Expand Down
Loading