From ba4eea2dc88ac4a4cb0b13b737af5cf8d72758bd Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 18 Apr 2025 14:09:44 +0200 Subject: [PATCH] Make notes component detect links and show on top (#771) --- mobile-app/app/(tabs)/(credentials)/[id].tsx | 211 ++---------------- mobile-app/app/components/CredentialIcon.tsx | 0 .../components/FormInputCopyToClipboard.tsx | 106 +++++++++ .../credentialDetails/AliasDetails.tsx | 61 +++++ .../credentialDetails/LoginCredentials.tsx | 48 ++++ .../credentialDetails/NotesSection.tsx | 123 ++++++++++ 6 files changed, 355 insertions(+), 194 deletions(-) delete mode 100644 mobile-app/app/components/CredentialIcon.tsx create mode 100644 mobile-app/components/FormInputCopyToClipboard.tsx create mode 100644 mobile-app/components/credentialDetails/AliasDetails.tsx create mode 100644 mobile-app/components/credentialDetails/LoginCredentials.tsx create mode 100644 mobile-app/components/credentialDetails/NotesSection.tsx diff --git a/mobile-app/app/(tabs)/(credentials)/[id].tsx b/mobile-app/app/(tabs)/(credentials)/[id].tsx index b30c825c1..0061850f8 100644 --- a/mobile-app/app/(tabs)/(credentials)/[id].tsx +++ b/mobile-app/app/(tabs)/(credentials)/[id].tsx @@ -1,82 +1,16 @@ import { useLocalSearchParams } from 'expo-router'; import { useEffect, useState } from 'react'; -import { ActivityIndicator, View, Text, TouchableOpacity, Image, ScrollView, useColorScheme, StyleSheet } from 'react-native'; -import * as Clipboard from 'expo-clipboard'; +import { ActivityIndicator, View, Text, useColorScheme, StyleSheet } from 'react-native'; import Toast from 'react-native-toast-message'; import { ThemedText } from '@/components/ThemedText'; import { ThemedView } from '@/components/ThemedView'; -import { useDb } from '@/context/DbContext'; -import { Credential } from '@/utils/types/Credential'; import { ThemedScrollView } from '@/components/ThemedScrollView'; import { CredentialIcon } from '@/components/CredentialIcon'; - -interface FormInputCopyToClipboardProps { - label: string; - value: string | undefined; - type?: 'text' | 'password'; -} - -const FormInputCopyToClipboard: React.FC = ({ - label, - value, - type = 'text', -}) => { - const [isPasswordVisible, setIsPasswordVisible] = useState(false); - const colorScheme = useColorScheme(); - const isDarkMode = colorScheme === 'dark'; - - const copyToClipboard = async () => { - if (value) { - await Clipboard.setStringAsync(value); - Toast.show({ - type: 'success', - text1: 'Copied to clipboard', - position: 'bottom', - visibilityTime: 2000, // Show for 2 seconds - }); - } - }; - - const displayValue = type === 'password' && !isPasswordVisible - ? '••••••••' - : value; - - return ( - - - - - {label} - - - {displayValue} - - - - {type === 'password' && ( - setIsPasswordVisible(!isPasswordVisible)} - style={styles.iconButton} - > - - {isPasswordVisible ? '👁️' : '👁️‍🗨️'} - - - )} - - - - ); -}; +import { useDb } from '@/context/DbContext'; +import { Credential } from '@/utils/types/Credential'; +import { LoginCredentials } from '@/components/credentialDetails/LoginCredentials'; +import { AliasDetails } from '@/components/credentialDetails/AliasDetails'; +import { NotesSection } from '@/components/credentialDetails/NotesSection'; export default function CredentialDetailsScreen() { const { id } = useLocalSearchParams(); @@ -93,7 +27,13 @@ export default function CredentialDetailsScreen() { try { const cred = await dbContext.sqliteClient!.getCredentialById(id as string); if (cred?.Alias?.BirthDate) { - cred.Alias.BirthDate = new Date(cred.Alias.BirthDate); + // Convert the string date to a Date object + const date = new Date(cred.Alias.BirthDate); + if (!isNaN(date.getTime())) { + cred.Alias.BirthDate = date; + } else { + cred.Alias.BirthDate = undefined; + } } setCredential(cred); } catch (err) { @@ -105,7 +45,6 @@ export default function CredentialDetailsScreen() { loadCredential(); - // Cleanup function to hide any visible toasts when navigating away return () => { Toast.hide(); }; @@ -123,12 +62,6 @@ export default function CredentialDetailsScreen() { return null; } - const email = credential.Alias?.Email?.trim(); - const username = credential.Username?.trim(); - const password = credential.Password?.trim(); - const hasName = Boolean(credential.Alias?.FirstName?.trim() || credential.Alias?.LastName?.trim()); - const fullName = [credential.Alias?.FirstName, credential.Alias?.LastName].filter(Boolean).join(' '); - return ( @@ -144,79 +77,9 @@ export default function CredentialDetailsScreen() { )} - - - Login Credentials - {email && ( - - )} - {username && ( - - )} - {password && ( - - )} - - - {(hasName || credential.Alias?.NickName || credential.Alias?.BirthDate) && ( - - Alias - {hasName && ( - - )} - {credential.Alias?.FirstName && ( - - )} - {credential.Alias?.LastName && ( - - )} - {credential.Alias?.NickName && ( - - )} - {credential.Alias?.BirthDate && !isNaN(credential.Alias.BirthDate.getTime()) && credential.Alias.BirthDate.getTime() !== new Date(0).getTime() && ( - - )} - - )} - - {credential.Notes && ( - - Notes - - - {credential.Notes} - - - - )} + + + ); } @@ -224,7 +87,7 @@ export default function CredentialDetailsScreen() { const styles = StyleSheet.create({ container: { flex: 1, - marginBottom: 80, + marginBottom: 100, }, header: { flexDirection: 'row', @@ -247,44 +110,4 @@ const styles = StyleSheet.create({ serviceUrl: { fontSize: 14, }, - section: { - padding: 16, - gap: 12, - }, - inputContainer: { - borderRadius: 8, - borderWidth: 1, - marginVertical: 4, - }, - inputContent: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 12, - }, - label: { - fontSize: 12, - marginBottom: 4, - }, - value: { - fontSize: 16, - }, - actions: { - flexDirection: 'row', - gap: 8, - }, - iconButton: { - padding: 8, - }, - iconText: { - fontSize: 16, - }, - notesContainer: { - padding: 12, - borderRadius: 8, - borderWidth: 1, - }, - notes: { - fontSize: 14, - }, }); \ No newline at end of file diff --git a/mobile-app/app/components/CredentialIcon.tsx b/mobile-app/app/components/CredentialIcon.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/mobile-app/components/FormInputCopyToClipboard.tsx b/mobile-app/components/FormInputCopyToClipboard.tsx new file mode 100644 index 000000000..3fc839b61 --- /dev/null +++ b/mobile-app/components/FormInputCopyToClipboard.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import { View, Text, TouchableOpacity, useColorScheme, StyleSheet } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import Toast from 'react-native-toast-message'; + +interface FormInputCopyToClipboardProps { + label: string; + value: string | undefined; + type?: 'text' | 'password'; +} + +const FormInputCopyToClipboard: React.FC = ({ + label, + value, + type = 'text', +}) => { + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const colorScheme = useColorScheme(); + const isDarkMode = colorScheme === 'dark'; + + const copyToClipboard = async () => { + if (value) { + await Clipboard.setStringAsync(value); + Toast.show({ + type: 'success', + text1: 'Copied to clipboard', + position: 'bottom', + visibilityTime: 2000, + }); + } + }; + + const displayValue = type === 'password' && !isPasswordVisible + ? '••••••••' + : value; + + return ( + + + + + {label} + + + {displayValue} + + + + {type === 'password' && ( + setIsPasswordVisible(!isPasswordVisible)} + style={styles.iconButton} + > + + {isPasswordVisible ? '👁️' : '👁️‍🗨️'} + + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + inputContainer: { + borderRadius: 8, + borderWidth: 1, + padding: 12, + marginBottom: 12, + }, + inputContent: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + label: { + fontSize: 12, + marginBottom: 4, + }, + value: { + fontSize: 16, + fontWeight: '500', + }, + actions: { + flexDirection: 'row', + alignItems: 'center', + }, + iconButton: { + padding: 8, + }, + iconText: { + fontSize: 20, + }, +}); + +export default FormInputCopyToClipboard; \ No newline at end of file diff --git a/mobile-app/components/credentialDetails/AliasDetails.tsx b/mobile-app/components/credentialDetails/AliasDetails.tsx new file mode 100644 index 000000000..4119c5a79 --- /dev/null +++ b/mobile-app/components/credentialDetails/AliasDetails.tsx @@ -0,0 +1,61 @@ +import { ThemedText } from '@/components/ThemedText'; +import { ThemedView } from '@/components/ThemedView'; +import { Credential } from '@/utils/types/Credential'; +import FormInputCopyToClipboard from '@/components/FormInputCopyToClipboard'; + +interface AliasDetailsProps { + credential: Credential; +} + +export const AliasDetails: React.FC = ({ credential }) => { + const hasName = Boolean(credential.Alias?.FirstName?.trim() || credential.Alias?.LastName?.trim()); + const fullName = [credential.Alias?.FirstName, credential.Alias?.LastName].filter(Boolean).join(' '); + + if (!hasName && !credential.Alias?.NickName && !credential.Alias?.BirthDate) { + return null; + } + + return ( + + Alias + {hasName && ( + + )} + {credential.Alias?.FirstName && ( + + )} + {credential.Alias?.LastName && ( + + )} + {credential.Alias?.NickName && ( + + )} + {credential.Alias?.BirthDate && !isNaN(credential.Alias.BirthDate.getTime()) && credential.Alias.BirthDate.getTime() !== new Date(0).getTime() && ( + + )} + + ); +}; + +const styles = { + section: { + padding: 16, + paddingBottom: 0, + gap: 12, + }, +}; \ No newline at end of file diff --git a/mobile-app/components/credentialDetails/LoginCredentials.tsx b/mobile-app/components/credentialDetails/LoginCredentials.tsx new file mode 100644 index 000000000..b67b434b7 --- /dev/null +++ b/mobile-app/components/credentialDetails/LoginCredentials.tsx @@ -0,0 +1,48 @@ +import { View } from 'react-native'; +import { ThemedText } from '@/components/ThemedText'; +import { ThemedView } from '@/components/ThemedView'; +import { Credential } from '@/utils/types/Credential'; +import FormInputCopyToClipboard from '@/components/FormInputCopyToClipboard'; + +interface LoginCredentialsProps { + credential: Credential; +} + +export const LoginCredentials: React.FC = ({ credential }) => { + const email = credential.Alias?.Email?.trim(); + const username = credential.Username?.trim(); + const password = credential.Password?.trim(); + + return ( + + Login Credentials + {email && ( + + )} + {username && ( + + )} + {password && ( + + )} + + ); +}; + +const styles = { + section: { + padding: 16, + paddingBottom: 0, + gap: 12, + }, +}; \ No newline at end of file diff --git a/mobile-app/components/credentialDetails/NotesSection.tsx b/mobile-app/components/credentialDetails/NotesSection.tsx new file mode 100644 index 000000000..bbfb1db44 --- /dev/null +++ b/mobile-app/components/credentialDetails/NotesSection.tsx @@ -0,0 +1,123 @@ +import { View, Text, useColorScheme, StyleSheet, Linking, Pressable } from 'react-native'; +import { ThemedText } from '@/components/ThemedText'; +import { ThemedView } from '@/components/ThemedView'; +import { Credential } from '@/utils/types/Credential'; + +interface NotesSectionProps { + credential: Credential; +} + +/** + * Split text into parts, separating URLs from regular text to make them clickable. + */ +const splitTextAndUrls = (text: string): Array<{ type: 'text' | 'url', content: string, url?: string }> => { + const urlPattern = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/g; + + const parts: Array<{ type: 'text' | 'url', content: string, url?: string }> = []; + let lastIndex = 0; + let match; + + while ((match = urlPattern.exec(text)) !== null) { + // Add text before the URL if it's not empty + if (match.index > lastIndex) { + const textBefore = text.slice(lastIndex, match.index); + if (textBefore.trim()) { + parts.push({ + type: 'text', + content: textBefore + }); + } + } + + // Add the URL + const url = match[0]; + const href = url.startsWith('http') ? url : `http://${url}`; + parts.push({ + type: 'url', + content: url, + url: href + }); + + lastIndex = match.index + url.length; + } + + // Add remaining text if it's not empty + if (lastIndex < text.length) { + const remainingText = text.slice(lastIndex); + if (remainingText.trim()) { + parts.push({ + type: 'text', + content: remainingText + }); + } + } + + return parts; +}; + +export const NotesSection: React.FC = ({ credential }) => { + const colorScheme = useColorScheme(); + const isDarkMode = colorScheme === 'dark'; + + if (!credential.Notes) { + return null; + } + + const parts = splitTextAndUrls(credential.Notes); + + const handleLinkPress = (url: string) => { + Linking.openURL(url); + }; + + return ( + + Notes + + + {parts.map((part, index) => { + if (part.type === 'url') { + return ( + handleLinkPress(part.url!)} + > + + {part.content} + + + ); + } + return ( + + {part.content} + + ); + })} + + + + ); +}; + +const styles = StyleSheet.create({ + section: { + padding: 16, + paddingBottom: 8, + gap: 12, + }, + notesContainer: { + padding: 12, + borderRadius: 8, + borderWidth: 1, + }, + notes: { + fontSize: 14, + }, + link: { + fontSize: 14, + textDecorationLine: 'underline', + }, +}); \ No newline at end of file