From 93f88bb3fc2ed27c31b7e8197b14f43b63b2d3ab Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 22 Feb 2026 13:23:27 +0100 Subject: [PATCH] Allow to copy/share credential URL from mobile app when long-pressing (#1765) --- apps/mobile-app/app/(tabs)/items/[id].tsx | 126 ++++++++++++++++++++-- apps/mobile-app/i18n/locales/en.json | 9 +- 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/apps/mobile-app/app/(tabs)/items/[id].tsx b/apps/mobile-app/app/(tabs)/items/[id].tsx index 302174fc5..e6a61099c 100644 --- a/apps/mobile-app/app/(tabs)/items/[id].tsx +++ b/apps/mobile-app/app/(tabs)/items/[id].tsx @@ -2,9 +2,11 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, View, Text, StyleSheet, Linking, Platform } from 'react-native'; +import { ActivityIndicator, View, Text, StyleSheet, Linking, Platform, Share, TouchableOpacity } from 'react-native'; +import ContextMenu from 'react-native-context-menu-view'; import Toast from 'react-native-toast-message'; +import { copyToClipboardWithExpiration } from '@/utils/ClipboardUtility'; import type { Item } from '@/utils/dist/core/models/vault'; import { FieldTypes, getFieldValue, FieldKey } from '@/utils/dist/core/models/vault'; import emitter from '@/utils/EventEmitter'; @@ -27,6 +29,10 @@ import { ThemedView } from '@/components/themed/ThemedView'; import { HeaderBackButton } from '@/components/ui/HeaderBackButton'; import { RobustPressable } from '@/components/ui/RobustPressable'; import { useDb } from '@/context/DbContext'; +import { LocalPreferencesService } from '@/services/LocalPreferencesService'; + +import type { NativeSyntheticEvent } from 'react-native'; +import type { ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view'; /** * Item details screen. @@ -134,6 +140,91 @@ export default function ItemDetailsScreen() : React.ReactNode { // Get email for EmailPreview const email = getFieldValue(item, FieldKey.LoginEmail); + /** + * Helper function to copy URL to clipboard with auto-clear + */ + const copyUrlToClipboard = async (url: string): Promise => { + try { + // Get clipboard clear timeout from settings + const timeoutSeconds = await LocalPreferencesService.getClipboardClearTimeout(); + + // Use centralized clipboard utility + await copyToClipboardWithExpiration(url, timeoutSeconds); + + if (Platform.OS === 'ios') { + Toast.show({ + type: 'success', + text1: t('items.toasts.urlCopied'), + position: 'bottom', + }); + } + } catch (error) { + console.error('Failed to copy URL to clipboard:', error); + } + }; + + /** + * Handles the context menu action for URLs + */ + const handleUrlContextMenuAction = async ( + event: NativeSyntheticEvent, + url: string + ): Promise => { + const { name } = event.nativeEvent; + + switch (name) { + case t('items.urlContextMenu.copyLink'): + await copyUrlToClipboard(url); + break; + case t('items.urlContextMenu.openLink'): + await Linking.openURL(url); + break; + case t('items.urlContextMenu.shareLink'): + try { + await Share.share({ + message: url, + url: Platform.OS === 'ios' ? url : undefined, + }); + } catch (error) { + console.error('Failed to share URL:', error); + } + break; + } + }; + + /** + * Get context menu actions for URL + */ + const getUrlMenuActions = (): { + title: string; + systemIcon: string; + }[] => [ + { + title: t('items.urlContextMenu.copyLink'), + systemIcon: Platform.select({ + ios: 'doc.on.doc', + android: 'baseline_content_copy', + default: 'doc.on.doc', + }), + }, + { + title: t('items.urlContextMenu.openLink'), + systemIcon: Platform.select({ + ios: 'arrow.up.right.square', + android: 'baseline_open_in_new', + default: 'arrow.up.right.square', + }), + }, + { + title: t('items.urlContextMenu.shareLink'), + systemIcon: Platform.select({ + ios: 'square.and.arrow.up', + android: 'baseline_share', + default: 'square.and.arrow.up', + }), + }, + ]; + /** * Render all URL values from URL fields. */ @@ -153,14 +244,28 @@ export default function ItemDetailsScreen() : React.ReactNode { const key = `${urlField.FieldKey}-${idx}`; return isValidUrl ? ( - Linking.openURL(urlValue)} + title={t('items.urlContextMenu.title')} + actions={getUrlMenuActions()} + onPress={(event) => handleUrlContextMenuAction(event, urlValue)} + previewBackgroundColor={colors.accentBackground} > - - {urlValue} - - + + Linking.openURL(urlValue)} + onLongPress={() => { + // Ignore long press to prevent context menu long press from triggering the press. + }} + activeOpacity={0.7} + hitSlop={{ top: 15, bottom: 15, left: 15, right: 15 }} + > + + {urlValue} + + + + ) : ( {urlValue} @@ -235,6 +340,11 @@ const styles = StyleSheet.create({ }, urlContainer: { gap: 2, - marginTop: 2, + }, + urlContextWrapper: { + paddingVertical: 6, + paddingHorizontal: 12, + marginVertical: -6, + marginHorizontal: -12, }, }); \ No newline at end of file diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index cadf38e01..7c635e97b 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -467,7 +467,8 @@ "itemDeleted": "Item deleted successfully", "usernameCopied": "Username copied to clipboard", "emailCopied": "Email copied to clipboard", - "passwordCopied": "Password copied to clipboard" + "passwordCopied": "Password copied to clipboard", + "urlCopied": "URL copied to clipboard" }, "createNewAliasFor": "Create new alias for", "contextMenu": { @@ -478,6 +479,12 @@ "copyEmail": "Copy Email", "copyPassword": "Copy Password" }, + "urlContextMenu": { + "title": "URL Options", + "copyLink": "Copy Link", + "openLink": "Open Link", + "shareLink": "Share Link" + }, "viewHistory": "View history", "history": "History", "noHistoryAvailable": "No history available",