Skip to content
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

realm input screen: Offer nicer experience for copied URL #5287

Merged
merged 7 commits into from
Apr 5, 2022
10 changes: 5 additions & 5 deletions src/chat/ChatScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { getAuth, getCaughtUpForNarrow } from '../selectors';
import { showErrorAlert } from '../utils/info';
import { TranslationContext } from '../boot/TranslationProvider';
import * as api from '../api';
import { useEdgeTriggeredEffect } from '../reactUtils';
import { useConditionalEffect } from '../reactUtils';

type Props = $ReadOnly<{|
navigation: AppNavigationProp<'chat'>,
Expand Down Expand Up @@ -71,9 +71,9 @@ const useMessagesWithFetch = args => {
// like using instance variables in class components:
// https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables
const shouldFetchWhenNextFocused = React.useRef<boolean>(false);
const scheduleFetch = () => {
const scheduleFetch = useCallback(() => {
shouldFetchWhenNextFocused.current = true;
};
}, []);

const [fetchError, setFetchError] = React.useState<mixed>(null);

Expand All @@ -92,7 +92,7 @@ const useMessagesWithFetch = args => {
if (eventQueueId !== null) {
scheduleFetch();
}
}, [eventQueueId]);
}, [eventQueueId, scheduleFetch]);

// If we stop having any data at all about the messages in this narrow --
// we don't know any, and nor do we know if there are some -- then
Expand All @@ -104,7 +104,7 @@ const useMessagesWithFetch = args => {
// isFetching false, even though the fetch effect will cause a rerender
// with isFetching true. It'd be nice to avoid that.
const nothingKnown = messages.length === 0 && !caughtUp.older && !caughtUp.newer;
useEdgeTriggeredEffect(scheduleFetch, nothingKnown, true);
useConditionalEffect(scheduleFetch, nothingKnown);

// On first mount, fetch. (This also makes a fetch no longer scheduled,
// so the if-scheduled fetch below doesn't also fire.)
Expand Down
101 changes: 19 additions & 82 deletions src/common/SmartUrlInput.js
Original file line number Diff line number Diff line change
@@ -1,81 +1,46 @@
/* @flow strict-local */
import React, { useState, useRef, useCallback, useContext } from 'react';
import type { Node } from 'react';
import { TextInput, TouchableWithoutFeedback, View } from 'react-native';
import { TextInput, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import type { ViewStyleProp } from 'react-native/Libraries/StyleSheet/StyleSheet';

import type { AppNavigationProp } from '../nav/AppNavigator';
import { ThemeContext, createStyleSheet } from '../styles';
import { autocompleteRealmPieces, autocompleteRealm, fixRealmUrl } from '../utils/url';
import type { Protocol } from '../utils/url';
import ZulipText from './ZulipText';
import { ThemeContext, createStyleSheet, HALF_COLOR } from '../styles';

const styles = createStyleSheet({
wrapper: {
flexDirection: 'row',
opacity: 0.8,
},
realmInput: {
flex: 1,
padding: 0,
fontSize: 20,
},
realmPlaceholder: {
opacity: 0.75,
},
realmInputEmpty: {
width: 1,
},
});

type Props = $ReadOnly<{|
/**
* The protocol which will be used if the user doesn't specify one.
* Should almost certainly be "https://".
*/
defaultProtocol: Protocol,
/**
* The example organization name that will be displayed while the
* entry field is empty. Appears, briefly, as the initial (lowest-
* level) component of the realm's domain.
*/
defaultOrganization: string,
/**
* The default domain to which the user's input will be appended, if
* it appears not to contain an explicit domain.
*/
defaultDomain: string,
// TODO: Currently this type is acceptable because the only
// `navigation` prop we pass to a `SmartUrlInput` instance is the
// one from a component on AppNavigator.
navigation: AppNavigationProp<>,

style?: ViewStyleProp,
onChangeText: (value: string) => void,
onSubmitEditing: () => Promise<void>,
enablesReturnKeyAutomatically: boolean,
|}>;

export default function SmartUrlInput(props: Props): Node {
const {
defaultProtocol,
defaultOrganization,
defaultDomain,
style,
onChangeText,
onSubmitEditing,
enablesReturnKeyAutomatically,
} = props;
const { style, onChangeText, onSubmitEditing, enablesReturnKeyAutomatically } = props;

// We should replace the fixme with
// `React$ElementRef<typeof TextInput>` when we can. Currently, that
// would make `.current` be `any(implicit)`, which we don't want;
// this is probably down to bugs in Flow's special support for React.
const textInputRef = useRef<$FlowFixMe>();

/**
* The actual input string, exactly as entered by the user,
* without modifications by autocomplete.
*/
const [value, setValue] = useState<string>('');

const themeContext = useContext(ThemeContext);
Expand All @@ -86,6 +51,14 @@ export default function SmartUrlInput(props: Props): Node {
useFocusEffect(
useCallback(() => {
if (textInputRef.current) {
// Sometimes the effect of this `.focus()` is immediately undone
// (the keyboard is closed) by a Keyboard.dismiss() from React
// Navigation's internals. Seems like a complex bug, but the symptom
// isn't terrible, it just means that on back-navigating to this
// screen, sometimes the keyboard flicks open then closed, instead
// of just opening. Shrug. See
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/realm-input/near/1346690
//
// `.current` is not type-checked; see definition.
textInputRef.current.focus();
}
Expand All @@ -95,52 +68,18 @@ export default function SmartUrlInput(props: Props): Node {
const handleChange = useCallback(
(_value: string) => {
setValue(_value);

onChangeText(
fixRealmUrl(
autocompleteRealm(_value, { protocol: defaultProtocol, domain: defaultDomain }),
),
);
onChangeText(_value);
},
[defaultDomain, defaultProtocol, onChangeText],
[onChangeText],
);

const urlPress = useCallback(() => {
if (textInputRef.current) {
// `.current` is not type-checked; see definition.
textInputRef.current.blur();
setTimeout(() => {
if (textInputRef.current) {
// `.current` is not type-checked; see definition.
textInputRef.current.focus();
}
}, 100);
}
}, []);

const renderPlaceholderPart = (text: string) => (
<TouchableWithoutFeedback onPress={urlPress}>
<ZulipText
style={[styles.realmInput, { color: themeContext.color }, styles.realmPlaceholder]}
text={text}
/>
</TouchableWithoutFeedback>
);

const [prefix, , suffix] = autocompleteRealmPieces(value, {
domain: defaultDomain,
protocol: defaultProtocol,
});

return (
<View style={[styles.wrapper, style]}>
{prefix !== null && renderPlaceholderPart(prefix)}
<TextInput
style={[
styles.realmInput,
{ color: themeContext.color },
value.length === 0 && styles.realmInputEmpty,
]}
value={value}
placeholder="your-org.zulipchat.com"
placeholderTextColor={HALF_COLOR}
style={[styles.realmInput, { color: themeContext.color }]}
autoFocus
autoCorrect={false}
autoCapitalize="none"
Expand All @@ -153,8 +92,6 @@ export default function SmartUrlInput(props: Props): Node {
enablesReturnKeyAutomatically={enablesReturnKeyAutomatically}
ref={textInputRef}
/>
{!value && renderPlaceholderPart(defaultOrganization)}
{suffix !== null && renderPlaceholderPart(suffix)}
</View>
);
}
41 changes: 15 additions & 26 deletions src/reactUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,33 +85,22 @@ export const useHasStayedTrueForMs = (value: boolean, duration: number): boolean
};

/**
* Invoke the callback as an effect when `value` turns from false to true.
* Like `useEffect`, but the callback only runs when `value` is true.
*
* This differs from putting `value` in a `useEffect` dependency list in
* that the callback will *not* be invoked when the value changes in the
* other direction, from true to false.
* Callers should wrap the callback in `useCallback` with an appropriate
* array of dependencies.
*
* `includeStart` controls what happens if `value` is true on the very first
* render. If `includeStart` is true, then the effect is triggered (so
* treating its previous state of nonexistence as equivalent to being
* false). If `includeStart` is false, then the effect is not triggered (so
* treating its previous state of nonexistence as not a valid state to
* compare to.)
* The callback will run once at the beginning of every period of `value`
* being true, and again throughout such a period whenever the value of the
* callback changes.
*
* The callback is not permitted to return a cleanup function, because it's
* not clear what the semantics should be of when such a cleanup function
* would be run.
* As with `useEffect`, the cleanup function, if provided, will run once for
* every time the callback is called. If `value` goes from true to false,
* the cleanup function will be called at that time.
*/
export const useEdgeTriggeredEffect = (
cb: () => void,
value: boolean,
includeStart: boolean,
): void => {
const prev = usePrevious(value, !includeStart);
useEffect(() => {
if (value && !prev) {
cb();
}
// No dependencies list -- the effect itself decides whether to act.
});
};
// The claims about when `cb` runs assume that useEffect doesn't run its
// callback on non-initial renders where its dependencies are unchanged. The
// docs could be clearer about that:
// https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects
export const useConditionalEffect = (cb: () => void | (() => void), value: boolean): void =>
useEffect(() => (value ? cb() : undefined), [value, cb]);
15 changes: 10 additions & 5 deletions src/start/RealmInputScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ type State = {|
progress: boolean,
|};

const urlFromInputValue = (realmInputValue: string): URL | void => {
const withScheme = /^https?:\/\//.test(realmInputValue)
? realmInputValue
: `https://${realmInputValue}`;

return tryParseUrl(withScheme);
};

export default class RealmInputScreen extends PureComponent<Props, State> {
state: State = {
progress: false,
Expand All @@ -37,7 +45,7 @@ export default class RealmInputScreen extends PureComponent<Props, State> {
tryRealm: () => Promise<void> = async () => {
const { realmInputValue } = this.state;

const parsedRealm = tryParseUrl(realmInputValue);
const parsedRealm = urlFromInputValue(realmInputValue);
if (!parsedRealm) {
this.setState({ error: 'Please enter a valid URL' });
return;
Expand Down Expand Up @@ -92,9 +100,6 @@ export default class RealmInputScreen extends PureComponent<Props, State> {
<SmartUrlInput
style={styles.input}
navigation={navigation}
defaultProtocol="https://"
defaultOrganization="your-org"
defaultDomain="zulipchat.com"
onChangeText={this.handleRealmChange}
onSubmitEditing={this.tryRealm}
enablesReturnKeyAutomatically
Expand All @@ -109,7 +114,7 @@ export default class RealmInputScreen extends PureComponent<Props, State> {
text="Enter"
progress={progress}
onPress={this.tryRealm}
disabled={tryParseUrl(realmInputValue) === undefined}
disabled={urlFromInputValue(realmInputValue) === undefined}
/>
</Screen>
);
Expand Down
Loading