Make notes component detect links and show on top (#771)

This commit is contained in:
Leendert de Borst
2025-04-18 14:09:44 +02:00
parent 87b1d49544
commit ba4eea2dc8
6 changed files with 355 additions and 194 deletions

View File

@@ -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<FormInputCopyToClipboardProps> = ({
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 (
<TouchableOpacity
onPress={copyToClipboard}
style={[
styles.inputContainer,
{
backgroundColor: isDarkMode ? '#1f2937' : '#f3f4f6',
borderColor: isDarkMode ? '#374151' : '#d1d5db',
},
]}
>
<View style={styles.inputContent}>
<View>
<Text style={[styles.label, { color: isDarkMode ? '#9ca3af' : '#6b7280' }]}>
{label}
</Text>
<Text style={[styles.value, { color: isDarkMode ? '#f3f4f6' : '#1f2937' }]}>
{displayValue}
</Text>
</View>
<View style={styles.actions}>
{type === 'password' && (
<TouchableOpacity
onPress={() => setIsPasswordVisible(!isPasswordVisible)}
style={styles.iconButton}
>
<Text style={styles.iconText}>
{isPasswordVisible ? '👁️' : '👁️‍🗨️'}
</Text>
</TouchableOpacity>
)}
</View>
</View>
</TouchableOpacity>
);
};
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 (
<ThemedScrollView style={styles.container}>
<ThemedView style={styles.header}>
@@ -144,79 +77,9 @@ export default function CredentialDetailsScreen() {
)}
</View>
</ThemedView>
<ThemedView style={styles.section}>
<ThemedText type="subtitle">Login Credentials</ThemedText>
{email && (
<FormInputCopyToClipboard
label="Email"
value={email}
/>
)}
{username && (
<FormInputCopyToClipboard
label="Username"
value={username}
/>
)}
{password && (
<FormInputCopyToClipboard
label="Password"
value={password}
type="password"
/>
)}
</ThemedView>
{(hasName || credential.Alias?.NickName || credential.Alias?.BirthDate) && (
<ThemedView style={styles.section}>
<ThemedText type="subtitle">Alias</ThemedText>
{hasName && (
<FormInputCopyToClipboard
label="Full Name"
value={fullName}
/>
)}
{credential.Alias?.FirstName && (
<FormInputCopyToClipboard
label="First Name"
value={credential.Alias.FirstName}
/>
)}
{credential.Alias?.LastName && (
<FormInputCopyToClipboard
label="Last Name"
value={credential.Alias.LastName}
/>
)}
{credential.Alias?.NickName && (
<FormInputCopyToClipboard
label="Nickname"
value={credential.Alias.NickName}
/>
)}
{credential.Alias?.BirthDate && !isNaN(credential.Alias.BirthDate.getTime()) && credential.Alias.BirthDate.getTime() !== new Date(0).getTime() && (
<FormInputCopyToClipboard
label="Birth Date"
value={credential.Alias.BirthDate.toISOString().split('T')[0]}
/>
)}
</ThemedView>
)}
{credential.Notes && (
<ThemedView style={styles.section}>
<ThemedText type="subtitle">Notes</ThemedText>
<View style={[styles.notesContainer, {
backgroundColor: isDarkMode ? '#1f2937' : '#f3f4f6',
borderColor: isDarkMode ? '#374151' : '#d1d5db',
}]}>
<Text style={[styles.notes, { color: isDarkMode ? '#f3f4f6' : '#1f2937' }]}>
{credential.Notes}
</Text>
</View>
</ThemedView>
)}
<NotesSection credential={credential} />
<LoginCredentials credential={credential} />
<AliasDetails credential={credential} />
</ThemedScrollView>
);
}
@@ -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,
},
});

View File

@@ -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<FormInputCopyToClipboardProps> = ({
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 (
<TouchableOpacity
onPress={copyToClipboard}
style={[
styles.inputContainer,
{
backgroundColor: isDarkMode ? '#1f2937' : '#f3f4f6',
borderColor: isDarkMode ? '#374151' : '#d1d5db',
},
]}
>
<View style={styles.inputContent}>
<View>
<Text style={[styles.label, { color: isDarkMode ? '#9ca3af' : '#6b7280' }]}>
{label}
</Text>
<Text style={[styles.value, { color: isDarkMode ? '#f3f4f6' : '#1f2937' }]}>
{displayValue}
</Text>
</View>
<View style={styles.actions}>
{type === 'password' && (
<TouchableOpacity
onPress={() => setIsPasswordVisible(!isPasswordVisible)}
style={styles.iconButton}
>
<Text style={styles.iconText}>
{isPasswordVisible ? '👁️' : '👁️‍🗨️'}
</Text>
</TouchableOpacity>
)}
</View>
</View>
</TouchableOpacity>
);
};
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;

View File

@@ -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<AliasDetailsProps> = ({ 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 (
<ThemedView style={styles.section}>
<ThemedText type="subtitle">Alias</ThemedText>
{hasName && (
<FormInputCopyToClipboard
label="Full Name"
value={fullName}
/>
)}
{credential.Alias?.FirstName && (
<FormInputCopyToClipboard
label="First Name"
value={credential.Alias.FirstName}
/>
)}
{credential.Alias?.LastName && (
<FormInputCopyToClipboard
label="Last Name"
value={credential.Alias.LastName}
/>
)}
{credential.Alias?.NickName && (
<FormInputCopyToClipboard
label="Nickname"
value={credential.Alias.NickName}
/>
)}
{credential.Alias?.BirthDate && !isNaN(credential.Alias.BirthDate.getTime()) && credential.Alias.BirthDate.getTime() !== new Date(0).getTime() && (
<FormInputCopyToClipboard
label="Birth Date"
value={credential.Alias.BirthDate.toISOString().split('T')[0]}
/>
)}
</ThemedView>
);
};
const styles = {
section: {
padding: 16,
paddingBottom: 0,
gap: 12,
},
};

View File

@@ -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<LoginCredentialsProps> = ({ credential }) => {
const email = credential.Alias?.Email?.trim();
const username = credential.Username?.trim();
const password = credential.Password?.trim();
return (
<ThemedView style={styles.section}>
<ThemedText type="subtitle">Login Credentials</ThemedText>
{email && (
<FormInputCopyToClipboard
label="Email"
value={email}
/>
)}
{username && (
<FormInputCopyToClipboard
label="Username"
value={username}
/>
)}
{password && (
<FormInputCopyToClipboard
label="Password"
value={password}
type="password"
/>
)}
</ThemedView>
);
};
const styles = {
section: {
padding: 16,
paddingBottom: 0,
gap: 12,
},
};

View File

@@ -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<NotesSectionProps> = ({ 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 (
<ThemedView style={styles.section}>
<ThemedText type="subtitle">Notes</ThemedText>
<View style={[styles.notesContainer, {
backgroundColor: isDarkMode ? '#1f2937' : '#f3f4f6',
borderColor: isDarkMode ? '#374151' : '#d1d5db',
}]}>
<Text style={[styles.notes, { color: isDarkMode ? '#f3f4f6' : '#1f2937' }]}>
{parts.map((part, index) => {
if (part.type === 'url') {
return (
<Pressable
key={index}
onPress={() => handleLinkPress(part.url!)}
>
<Text style={[styles.link, { color: isDarkMode ? '#60a5fa' : '#2563eb' }]}>
{part.content}
</Text>
</Pressable>
);
}
return (
<Text key={index} selectable={true}>
{part.content}
</Text>
);
})}
</Text>
</View>
</ThemedView>
);
};
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',
},
});