Skip to content
Closed
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 @@ -13,6 +13,7 @@ import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
Expand Down Expand Up @@ -42,7 +43,14 @@ class T3SecretInputModule : Module() {
view.input.setTextSize(TypedValue.COMPLEX_UNIT_DIP, value)
}
Prop("editable") { view: T3SecretInputView, value: Boolean -> view.input.isEnabled = value }
Prop("masked") { view: T3SecretInputView, value: Boolean -> view.setMasked(value) }
Prop("returnKey") { view: T3SecretInputView, value: String ->
view.input.imeOptions =
if (value == "next") EditorInfo.IME_ACTION_NEXT else EditorInfo.IME_ACTION_DONE
}
Events("onChangeText", "onSubmit")

AsyncFunction("focus") { view: T3SecretInputView -> view.focusInput() }
}
}
}
Expand Down Expand Up @@ -78,14 +86,7 @@ class T3SecretInputView(context: Context, appContext: AppContext) : ExpoView(con
input.importantForAutofill = IMPORTANT_FOR_AUTOFILL_NO
}
input.imeOptions = EditorInfo.IME_ACTION_DONE
// Not setSingleLine(): that swaps out the password transformation. The password variation is
// already single line, and the keyboard keeps typed text out of suggestions.
input.inputType =
InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_VARIATION_PASSWORD or
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
// The password variation switches the typeface to monospace.
input.typeface = Typeface.DEFAULT
setMasked(true)
input.setTextColor(Color.BLACK)
input.setHintTextColor(Color.GRAY)
input.addTextChangedListener(
Expand All @@ -101,7 +102,12 @@ class T3SecretInputView(context: Context, appContext: AppContext) : ExpoView(con
}
)
input.setOnEditorActionListener { _, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_DONE || event?.keyCode == KeyEvent.KEYCODE_ENTER) {
// Next is submitted too: JS moves focus to the following field.
if (
actionId == EditorInfo.IME_ACTION_DONE ||
actionId == EditorInfo.IME_ACTION_NEXT ||
event?.keyCode == KeyEvent.KEYCODE_ENTER
) {
// Hardware Enter reports IME_NULL on Android 13 and earlier, and both key down and key up.
if (event == null || event.action == KeyEvent.ACTION_DOWN) {
onSubmit(mapOf("value" to input.text.toString()))
Expand Down Expand Up @@ -133,6 +139,28 @@ class T3SecretInputView(context: Context, appContext: AppContext) : ExpoView(con
applyingValue = false
}

/** Both variations are single line and keep typed text out of suggestions. */
fun setMasked(masked: Boolean) {
val variation =
if (masked) InputType.TYPE_TEXT_VARIATION_PASSWORD
else InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
val inputType =
InputType.TYPE_CLASS_TEXT or variation or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
if (input.inputType == inputType) return
val selection = input.selectionEnd
// Not setSingleLine(): that swaps out the password transformation.
input.inputType = inputType
// The password variations switch the typeface to monospace.
input.typeface = Typeface.DEFAULT
if (selection >= 0) input.setSelection(minOf(selection, input.text.length))
}

fun focusInput() {
input.requestFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT)
}

fun setCursorColor(color: Int) {
input.highlightColor = Color.argb(0x52, Color.red(color), Color.green(color), Color.blue(color))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
Expand Down
30 changes: 28 additions & 2 deletions apps/mobile/src/components/ui/SecretTextInput.android.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { requireNativeView } from "expo";
import type { Ref } from "react";
import { processColor, View, type NativeSyntheticEvent, type ViewProps } from "react-native";

import { cn } from "../../lib/cn";
import { flattenThemeColor } from "../../lib/mobileTheme";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import type { SecretTextInputProps } from "./SecretTextInput.types";

interface NativeSecretInputRef {
focus: () => Promise<void>;
}

interface NativeSecretInputProps extends ViewProps {
readonly ref?: Ref<NativeSecretInputRef>;
readonly inputLabel: string;
readonly value: string;
readonly placeholder: string;
Expand All @@ -14,6 +21,8 @@ interface NativeSecretInputProps extends ViewProps {
readonly cursorColor: number;
readonly fontSize: number;
readonly editable: boolean;
readonly masked: boolean;
readonly returnKey: "next" | "done";
readonly onChangeText: (event: NativeSyntheticEvent<{ readonly value: string }>) => void;
readonly onSubmit: (event: NativeSyntheticEvent<{ readonly value: string }>) => void;
}
Expand All @@ -33,12 +42,27 @@ function nativeColor(value: string) {
* offer logins above the keyboard. This field is a native EditText that reports no autofill type.
* The frame matches AppTextInput's.
*/
export function SecretTextInput(props: SecretTextInputProps) {
export function SecretTextInput({ onMount, ...props }: SecretTextInputProps) {
const theme = useUniwindTheme();
const surface = theme["--color-input"];
return (
<View className="min-h-13.5 justify-center rounded-2xl border border-input-border bg-input px-3.5">
<View
className={cn(
"min-h-13.5 justify-center rounded-2xl border border-input-border bg-input px-3.5",
props.className,
)}
>
<NativeSecretInput
ref={
onMount === undefined
? undefined
: (native: NativeSecretInputRef | null) =>
onMount(
native === null
? null
: { focus: () => void native.focus().catch(() => undefined) },
)
}
inputLabel={props.accessibilityLabel}
value={props.value}
placeholder={props.placeholder}
Expand All @@ -47,6 +71,8 @@ export function SecretTextInput(props: SecretTextInputProps) {
cursorColor={nativeColor(flattenThemeColor(theme["--color-focus"], surface))}
fontSize={FONT_SIZE}
editable={props.editable}
masked={props.revealed !== true}
returnKey={props.returnKeyType ?? "done"}
onChangeText={(event) => props.onChangeText(event.nativeEvent.value)}
onSubmit={(event) => props.onSubmitEditing(event.nativeEvent.value)}
style={{ height: LINE_HEIGHT }}
Expand Down
10 changes: 7 additions & 3 deletions apps/mobile/src/components/ui/SecretTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,26 @@ import { AppTextInput } from "./AppText";
import type { SecretTextInputProps } from "./SecretTextInput.types";

/** Masked field for a value that must stay out of password managers and suggestions. */
export function SecretTextInput(props: SecretTextInputProps) {
export function SecretTextInput({ onMount, ...props }: SecretTextInputProps) {
const returnKeyType = props.returnKeyType ?? "done";
return (
<AppTextInput
ref={onMount}
accessibilityLabel={props.accessibilityLabel}
className={props.className}
placeholder={props.placeholder}
value={props.value}
onChangeText={props.onChangeText}
editable={props.editable}
secureTextEntry
secureTextEntry={props.revealed !== true}
autoCorrect={false}
autoCapitalize="none"
autoComplete="off"
textContentType="none"
importantForAutofill="no"
spellCheck={false}
returnKeyType="done"
returnKeyType={returnKeyType}
submitBehavior={returnKeyType === "next" ? "submit" : "blurAndSubmit"}
onSubmitEditing={(event) => props.onSubmitEditing(event.nativeEvent.text)}
/>
);
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/components/ui/SecretTextInput.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export interface SecretTextInputHandle {
readonly focus: () => void;
}

export interface SecretTextInputProps {
readonly accessibilityLabel: string;
readonly placeholder: string;
Expand All @@ -6,4 +10,10 @@ export interface SecretTextInputProps {
/** Receives the field's current text, so a submit never uses a stale render of it. */
readonly onSubmitEditing: (value: string) => void;
readonly editable: boolean;
/** Shows the typed text; the field stays out of password managers either way. */
readonly revealed?: boolean;
/** "next" keeps the keyboard open for the following field. */
readonly returnKeyType?: "next" | "done";
readonly className?: string;
readonly onMount?: (handle: SecretTextInputHandle | null) => void;
}
Loading
Loading