mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-27 03:31:53 +00:00
feat: add password visibility toggle to login forms (#1019)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
This commit is contained in:
111
components/PasswordInput.tsx
Normal file
111
components/PasswordInput.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity } from "react-native";
|
||||
import { Input } from "./common/Input";
|
||||
|
||||
// Discriminated union for password visibility control
|
||||
type PasswordVisibilityControlled = {
|
||||
value?: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder: string;
|
||||
showPassword: boolean;
|
||||
onShowPasswordChange: (show: boolean) => void;
|
||||
topOffset?: number;
|
||||
layout?: "tv" | "mobile";
|
||||
};
|
||||
|
||||
type PasswordVisibilityUncontrolled = {
|
||||
value?: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder: string;
|
||||
showPassword?: never;
|
||||
onShowPasswordChange?: never;
|
||||
topOffset?: number;
|
||||
layout?: "tv" | "mobile";
|
||||
defaultShowPassword?: boolean;
|
||||
};
|
||||
|
||||
type PasswordInputProps =
|
||||
| PasswordVisibilityControlled
|
||||
| PasswordVisibilityUncontrolled;
|
||||
|
||||
export const PasswordInput: React.FC<PasswordInputProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
value = "",
|
||||
onChangeText,
|
||||
placeholder,
|
||||
topOffset = 14, // Default 14px for mobile
|
||||
layout = "mobile",
|
||||
} = props;
|
||||
|
||||
// Type guard to check if we're in controlled mode
|
||||
const isControlled =
|
||||
"showPassword" in props && "onShowPasswordChange" in props;
|
||||
|
||||
// Internal state for uncontrolled mode
|
||||
const [internalShowPassword, setInternalShowPassword] = useState(() =>
|
||||
!isControlled && "defaultShowPassword" in props
|
||||
? ((props as PasswordVisibilityUncontrolled).defaultShowPassword ?? false)
|
||||
: false,
|
||||
);
|
||||
|
||||
// Use controlled value if available, otherwise use internal state
|
||||
const showPassword = isControlled
|
||||
? (props as PasswordVisibilityControlled).showPassword
|
||||
: internalShowPassword;
|
||||
|
||||
const handleTogglePassword = () => {
|
||||
if (isControlled) {
|
||||
(props as PasswordVisibilityControlled).onShowPasswordChange(
|
||||
!showPassword,
|
||||
);
|
||||
} else {
|
||||
// For uncontrolled mode, toggle internal state
|
||||
setInternalShowPassword(!showPassword);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate top position style with validation
|
||||
const getTopStyle = () => {
|
||||
if (typeof topOffset !== "number" || Number.isNaN(topOffset)) {
|
||||
console.warn(`Invalid topOffset value: ${topOffset}`);
|
||||
return { top: 14 }; // Default fallback (14px for mobile)
|
||||
}
|
||||
return { top: topOffset };
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
onChangeText={onChangeText}
|
||||
value={value}
|
||||
secureTextEntry={!showPassword}
|
||||
extraClassName='pr-4'
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={handleTogglePassword}
|
||||
className={`absolute right-3 p-1 ${
|
||||
layout === "tv" ? "h-10 justify-center" : ""
|
||||
}`}
|
||||
style={getTopStyle()}
|
||||
accessible={true}
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={
|
||||
showPassword ? t("login.hide_password") : t("login.show_password")
|
||||
}
|
||||
accessibilityHint={t("login.toggle_password_visibility")}
|
||||
accessibilityState={{ checked: showPassword }}
|
||||
>
|
||||
<Ionicons
|
||||
name={showPassword ? "eye-off" : "eye"}
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { useMMKVString } from "react-native-mmkv";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { ListGroup } from "./list/ListGroup";
|
||||
import { ListItem } from "./list/ListItem";
|
||||
|
||||
@@ -17,8 +18,7 @@ interface PreviousServersListProps {
|
||||
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
onServerSelect,
|
||||
}) => {
|
||||
const [_previousServers, setPreviousServers] =
|
||||
useMMKVString("previousServers");
|
||||
const [_previousServers] = useMMKVString("previousServers");
|
||||
|
||||
const previousServers = useMemo(() => {
|
||||
return JSON.parse(_previousServers || "[]") as Server[];
|
||||
@@ -37,14 +37,16 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
onPress={() => onServerSelect(s)}
|
||||
title={s.address}
|
||||
showArrow
|
||||
className='min-h-[48px] py-2'
|
||||
/>
|
||||
))}
|
||||
<ListItem
|
||||
onPress={() => {
|
||||
setPreviousServers("[]");
|
||||
storage.delete("previousServers");
|
||||
}}
|
||||
title={t("server.clear_button")}
|
||||
textColor='red'
|
||||
className='min-h-[48px] py-2'
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
@@ -20,8 +20,8 @@ export function Input(props: InputProps) {
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className={`
|
||||
w-full text-lg px-5 py-4 rounded-2xl
|
||||
${isFocused ? "bg-neutral-700 border-2 border-white" : "bg-neutral-900 border-2 border-transparent"}
|
||||
w-full text-lg px-5 py-5 rounded-2xl
|
||||
${isFocused ? "bg-neutral-700 border-2 border-white" : "bg-neutral-900 border-2 border-neutral-800"}
|
||||
text-white ${extraClassName}
|
||||
`}
|
||||
allowFontScaling={false}
|
||||
@@ -41,11 +41,15 @@ export function Input(props: InputProps) {
|
||||
) : (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className='p-4 rounded-xl bg-neutral-900'
|
||||
className={`p-3 rounded-xl bg-neutral-900 ${
|
||||
isFocused ? "border-2 border-white" : "border-2 border-neutral-800"
|
||||
} ${extraClassName}`}
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
placeholderTextColor={"#9CA3AF"}
|
||||
clearButtonMode='while-editing'
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -177,7 +177,7 @@ export const FilterSheet = <T,>({
|
||||
{showSearch && (
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
className='my-2 border-neutral-800 border'
|
||||
extraClassName='my-2 border-neutral-800 border'
|
||||
value={search}
|
||||
onChangeText={(text) => {
|
||||
setSearch(text);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -34,9 +34,9 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
<TouchableOpacity
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${
|
||||
Platform.OS === "android" ? "min-h-12" : "min-h-[44px]"
|
||||
} pr-4 pl-4 ${disabled ? "opacity-50" : ""}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
<ListItemContent
|
||||
@@ -54,9 +54,9 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
);
|
||||
return (
|
||||
<View
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${
|
||||
Platform.OS === "android" ? "min-h-12" : "min-h-[44px]"
|
||||
} pr-4 pl-4 ${disabled ? "opacity-50" : ""}`}
|
||||
{...viewProps}
|
||||
>
|
||||
<ListItemContent
|
||||
|
||||
@@ -36,6 +36,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
"home.settings.gesture_controls.horizontal_swipe_skip_description",
|
||||
)}
|
||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||
style={{ minHeight: 72, paddingTop: 12, paddingBottom: 12 }}
|
||||
>
|
||||
<Switch
|
||||
value={settings.enableHorizontalSwipeSkip}
|
||||
@@ -52,6 +53,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
"home.settings.gesture_controls.left_side_brightness_description",
|
||||
)}
|
||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||
style={{ minHeight: 72, paddingTop: 12, paddingBottom: 12 }}
|
||||
>
|
||||
<Switch
|
||||
value={settings.enableLeftSideBrightnessSwipe}
|
||||
@@ -68,6 +70,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
"home.settings.gesture_controls.right_side_volume_description",
|
||||
)}
|
||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||
style={{ minHeight: 72, paddingTop: 12, paddingBottom: 12 }}
|
||||
>
|
||||
<Switch
|
||||
value={settings.enableRightSideVolumeSwipe}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { PasswordInput } from "@/components/PasswordInput";
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -30,6 +31,9 @@ export const JellyseerrSettings = () => {
|
||||
string | undefined
|
||||
>(settings?.jellyseerrServerUrl || undefined);
|
||||
|
||||
const [showJellyseerrPassword, setShowJellyseerrPassword] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const loginToJellyseerrMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
||||
@@ -127,7 +131,7 @@ export const JellyseerrSettings = () => {
|
||||
</Text>
|
||||
</View>
|
||||
<Input
|
||||
className='border border-neutral-800 mb-2'
|
||||
extraClassName='border border-neutral-800 mb-2'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||
)}
|
||||
@@ -146,23 +150,20 @@ export const JellyseerrSettings = () => {
|
||||
<Text className='font-bold mb-2'>
|
||||
{t("home.settings.plugins.jellyseerr.password")}
|
||||
</Text>
|
||||
<Input
|
||||
className='border border-neutral-800'
|
||||
autoFocus={true}
|
||||
focusable={true}
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.password_placeholder",
|
||||
{ username: user?.Name },
|
||||
)}
|
||||
value={jellyseerrPassword}
|
||||
keyboardType='default'
|
||||
secureTextEntry={true}
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='password'
|
||||
onChangeText={setJellyseerrPassword}
|
||||
editable={!loginToJellyseerrMutation.isPending}
|
||||
/>
|
||||
<View className='relative'>
|
||||
<PasswordInput
|
||||
value={jellyseerrPassword}
|
||||
onChangeText={setJellyseerrPassword}
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.password_placeholder",
|
||||
{ username: user?.Name },
|
||||
)}
|
||||
showPassword={showJellyseerrPassword}
|
||||
onShowPasswordChange={setShowJellyseerrPassword}
|
||||
layout='mobile'
|
||||
topOffset={11}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
loading={loginToJellyseerrMutation.isPending}
|
||||
disabled={loginToJellyseerrMutation.isPending}
|
||||
|
||||
Reference in New Issue
Block a user