mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-19 05:47:43 -04:00
Make notes component detect links and show on top (#771)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
106
mobile-app/components/FormInputCopyToClipboard.tsx
Normal file
106
mobile-app/components/FormInputCopyToClipboard.tsx
Normal 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;
|
||||
61
mobile-app/components/credentialDetails/AliasDetails.tsx
Normal file
61
mobile-app/components/credentialDetails/AliasDetails.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
48
mobile-app/components/credentialDetails/LoginCredentials.tsx
Normal file
48
mobile-app/components/credentialDetails/LoginCredentials.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
123
mobile-app/components/credentialDetails/NotesSection.tsx
Normal file
123
mobile-app/components/credentialDetails/NotesSection.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user