diff --git a/.gitignore b/.gitignore index 664273ce0..ad4e869b1 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,7 @@ yarn-error.* CLAUDE.local.md -.dev/worktree/* \ No newline at end of file +# IDE +.idea + +.dev/worktree/* diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index fa3955004..038c2fe75 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -264,9 +264,13 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: ) : null; + // Check if user has write access (edit or admin level, or is the owner) + const hasWriteAccess = !session.accessLevel || session.accessLevel === 'edit' || session.accessLevel === 'admin'; + const isReadOnly = session.accessLevel === 'view'; + const input = ( { + if (!hasWriteAccess) { + Modal.alert(t('common.error'), t('sessionSharing.noEditPermission')); + return; + } if (message.trim()) { setMessage(''); clearDraft(); @@ -295,6 +303,7 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: // Autocomplete configuration autocompletePrefixes={['@', '/']} autocompleteSuggestions={(query) => getSuggestions(sessionId, query)} + disabled={isReadOnly} usageData={sessionUsage ? { inputTokens: sessionUsage.inputTokens, outputTokens: sessionUsage.outputTokens, diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx index 631df7f39..cbb830e0d 100644 --- a/sources/app/(app)/session/[id]/info.tsx +++ b/sources/app/(app)/session/[id]/info.tsx @@ -66,10 +66,13 @@ function SessionInfoContent({ session }: { session: Session }) { const devModeEnabled = __DEV__; const sessionName = getSessionName(session); const sessionStatus = useSessionStatus(session); - + // Check if CLI version is outdated const isCliOutdated = session.metadata?.version && !isVersionSupported(session.metadata.version, MINIMUM_CLI_VERSION); + // Check if user has admin access for sharing management + const canManageSharing = !session.accessLevel || session.accessLevel === 'admin'; + const handleCopySessionId = useCallback(async () => { if (!session) return; try { @@ -257,6 +260,14 @@ function SessionInfoContent({ session }: { session: Session }) { onPress={() => router.push(`/machine/${session.metadata?.machineId}`)} /> )} + {canManageSharing && ( + } + onPress={() => router.push(`/session/${session.id}/sharing`)} + /> + )} {sessionStatus.isConnected && ( ([]); + const [publicShare, setPublicShare] = useState(null); + const [friends, setFriends] = useState([]); + + const [showShareDialog, setShowShareDialog] = useState(false); + const [showFriendSelector, setShowFriendSelector] = useState(false); + const [showPublicLinkDialog, setShowPublicLinkDialog] = useState(false); + + // Load sharing data + const loadSharingData = useCallback(async () => { + try { + const credentials = sync.getCredentials(); + + // Load shares + const sharesData = await getSessionShares(credentials, sessionId); + setShares(sharesData); + + // Load public share + try { + const publicShareData = await getPublicShare(credentials, sessionId); + setPublicShare(publicShareData); + } catch (e) { + // No public share exists + setPublicShare(null); + } + + // Load friends list + const friendsData = await getFriendsList(credentials); + setFriends(friendsData); + } catch (error) { + console.error('Failed to load sharing data:', error); + } + }, [sessionId]); + + useEffect(() => { + loadSharingData(); + }, [loadSharingData]); + + // Handle adding a new share + const handleAddShare = useCallback(async (userId: string, accessLevel: ShareAccessLevel) => { + try { + const credentials = sync.getCredentials(); + + await createSessionShare(credentials, sessionId, { + userId, + accessLevel, + }); + + await loadSharingData(); + setShowFriendSelector(false); + } catch (error) { + throw new HappyError(t('errors.operationFailed'), false); + } + }, [sessionId, loadSharingData]); + + // Handle updating share access level + const handleUpdateShare = useCallback(async (shareId: string, accessLevel: ShareAccessLevel) => { + try { + const credentials = sync.getCredentials(); + await updateSessionShare(credentials, sessionId, shareId, accessLevel); + await loadSharingData(); + } catch (error) { + throw new HappyError(t('errors.operationFailed'), false); + } + }, [sessionId, loadSharingData]); + + // Handle removing a share + const handleRemoveShare = useCallback(async (shareId: string) => { + try { + const credentials = sync.getCredentials(); + await deleteSessionShare(credentials, sessionId, shareId); + await loadSharingData(); + } catch (error) { + throw new HappyError(t('errors.operationFailed'), false); + } + }, [sessionId, loadSharingData]); + + // Handle creating public share + const handleCreatePublicShare = useCallback(async (options: { + expiresInDays?: number; + maxUses?: number; + isConsentRequired: boolean; + }) => { + try { + const credentials = sync.getCredentials(); + + // Generate random token (12 bytes = 24 hex chars) + const tokenBytes = getRandomBytes(12); + const token = Array.from(tokenBytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + // Get session data encryption key + const dataKey = sync.getSessionDataKey(sessionId); + if (!dataKey) { + throw new HappyError(t('errors.sessionNotFound'), false); + } + + // Encrypt data key with the token + const encryptedDataKey = await encryptDataKeyForPublicShare(dataKey, token); + + const expiresAt = options.expiresInDays + ? Date.now() + options.expiresInDays * 24 * 60 * 60 * 1000 + : undefined; + + await createPublicShare(credentials, sessionId, { + token, + encryptedDataKey, + expiresAt, + maxUses: options.maxUses, + isConsentRequired: options.isConsentRequired, + }); + + await loadSharingData(); + } catch (error) { + throw new HappyError(t('errors.operationFailed'), false); + } + }, [sessionId, loadSharingData]); + + // Handle deleting public share + const handleDeletePublicShare = useCallback(async () => { + try { + const credentials = sync.getCredentials(); + await deletePublicShare(credentials, sessionId); + await loadSharingData(); + setShowPublicLinkDialog(false); + } catch (error) { + throw new HappyError(t('errors.operationFailed'), false); + } + }, [sessionId, loadSharingData]); + + if (!session) { + return ( + + + + {t('errors.sessionDeleted')} + + + ); + } + + const excludedUserIds = shares.map(share => share.sharedWithUser.id); + // Check if current user is the session owner + const currentUserId = sync.getUserID(); + const canManage = session.owner === currentUserId; + + return ( + <> + + {/* Current Shares */} + + {shares.length > 0 ? ( + shares.map(share => ( + } + onPress={() => setShowShareDialog(true)} + /> + )) + ) : ( + } + showChevron={false} + /> + )} + {canManage && ( + } + onPress={() => setShowFriendSelector(true)} + /> + )} + + + {/* Public Link */} + + {publicShare ? ( + } + onPress={() => setShowPublicLinkDialog(true)} + /> + ) : ( + } + onPress={() => setShowPublicLinkDialog(true)} + /> + )} + + + + {/* Dialogs */} + {showShareDialog && ( + { + setShowShareDialog(false); + setShowFriendSelector(true); + }} + onUpdateShare={handleUpdateShare} + onRemoveShare={handleRemoveShare} + onManagePublicLink={() => { + setShowShareDialog(false); + setShowPublicLinkDialog(true); + }} + onClose={() => setShowShareDialog(false)} + /> + )} + + {showFriendSelector && ( + + )} + + {showPublicLinkDialog && ( + setShowPublicLinkDialog(false)} + /> + )} + + ); +} + +export default memo(() => { + const { theme } = useUnistyles(); + const { id } = useLocalSearchParams<{ id: string }>(); + const isDataReady = useIsDataReady(); + + if (!isDataReady) { + return ( + + + + {t('common.loading')} + + + ); + } + + return ; +}); diff --git a/sources/app/(app)/share/[token].tsx b/sources/app/(app)/share/[token].tsx new file mode 100644 index 000000000..8bf025c85 --- /dev/null +++ b/sources/app/(app)/share/[token].tsx @@ -0,0 +1,192 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text, ActivityIndicator } from 'react-native'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { ItemList } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { useUnistyles } from 'react-native-unistyles'; +import { t } from '@/text'; +import { sync } from '@/sync/sync'; +import { decryptDataKeyFromPublicShare } from '@/sync/publicShareEncryption'; +import { Ionicons } from '@expo/vector-icons'; +import { getServerUrl } from "@/sync/serverConfig"; + +/** + * Public share access screen + * + * This screen handles accessing a session via a public share link. + * The token from the URL is used to decrypt the session data key. + */ +export default function PublicShareAccessScreen() { + const { token } = useLocalSearchParams<{ token: string }>(); + const router = useRouter(); + const { theme } = useUnistyles(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [shareInfo, setShareInfo] = useState<{ + sessionId: string; + ownerName: string; + requiresConsent: boolean; + } | null>(null); + + useEffect(() => { + if (!token) { + setError(t('errors.invalidShareLink')); + setLoading(false); + return; + } + + loadPublicShare(); + }, [token]); + + const loadPublicShare = async (withConsent: boolean = false) => { + try { + setLoading(true); + setError(null); + + const credentials = sync.getCredentials(); + const serverUrl = getServerUrl(); + + // Build URL with consent parameter if user has accepted + const url = withConsent + ? `${serverUrl}/v1/public-share/${token}?consent=true` + : `${serverUrl}/v1/public-share/${token}`; + + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${credentials.token}`, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + setError(t('session.sharing.shareNotFound')); + setLoading(false); + return; + } else if (response.status === 403) { + // Consent required but not provided + const data = await response.json(); + if (data.requiresConsent) { + // Show consent screen with owner info from server + setShareInfo({ + sessionId: data.sessionId || '', + ownerName: data.owner?.username || data.owner?.firstName || 'Unknown', + requiresConsent: true, + }); + setLoading(false); + return; + } + setError(t('session.sharing.shareExpired')); + setLoading(false); + return; + } else { + setError(t('errors.operationFailed')); + setLoading(false); + return; + } + } + + const data = await response.json(); + + // Decrypt the data encryption key using the token + const decryptedKey = await decryptDataKeyFromPublicShare( + data.encryptedDataKey, + token + ); + + if (!decryptedKey) { + setError(t('session.sharing.failedToDecrypt')); + setLoading(false); + return; + } + + // Store the decrypted key for this session + sync.storePublicShareKey(data.session.id, decryptedKey); + + setShareInfo({ + sessionId: data.session.id, + ownerName: data.owner?.username || data.owner?.firstName || 'Unknown', + requiresConsent: false, // Successfully accessed, no need to show consent screen + }); + setLoading(false); + } catch (err) { + console.error('Failed to load public share:', err); + setError(t('errors.operationFailed')); + setLoading(false); + } + }; + + const handleAcceptConsent = () => { + // Reload with consent=true to actually access the session + loadPublicShare(true); + }; + + const handleDeclineConsent = () => { + router.back(); + }; + + if (loading) { + return ( + + + + {t('common.loading')} + + + ); + } + + if (error) { + return ( + + + + {t('common.error')} + + + {error} + + + ); + } + + if (shareInfo && shareInfo.requiresConsent) { + return ( + + + + } + showChevron={false} + /> + + + + } + onPress={handleAcceptConsent} + /> + } + onPress={handleDeclineConsent} + /> + + + + ); + } + + // No consent required, navigate directly to session + if (shareInfo) { + router.replace(`/session/${shareInfo.sessionId}`); + return null; + } + + return null; +} diff --git a/sources/components/ActiveSessionsGroup.tsx b/sources/components/ActiveSessionsGroup.tsx index d567b9fb9..29a0af685 100644 --- a/sources/components/ActiveSessionsGroup.tsx +++ b/sources/components/ActiveSessionsGroup.tsx @@ -40,6 +40,22 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ shadowRadius: 0, elevation: 1, }, + sharedBadge: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 6, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + backgroundColor: theme.colors.surfaceHighest, + }, + sharedBadgeText: { + fontSize: 11, + fontWeight: '500', + color: theme.colors.textSecondary, + marginLeft: 4, + ...Typography.default(), + }, sectionHeader: { paddingTop: 12, paddingBottom: Platform.select({ ios: 6, default: 8 }), @@ -95,11 +111,13 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ flexDirection: 'row', alignItems: 'center', marginBottom: 4, + gap: 4, }, sessionTitle: { fontSize: 15, fontWeight: '500', ...Typography.default('semiBold'), + flexShrink: 1, }, sessionTitleConnected: { color: theme.colors.text, @@ -344,6 +362,12 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi const swipeableRef = React.useRef(null); const swipeEnabled = Platform.OS !== 'web'; + // Check if this is a shared session + const isSharedSession = !!session.owner; + const ownerName = session.ownerProfile + ? (session.ownerProfile.username || session.ownerProfile.firstName) + : null; + const [archivingSession, performArchive] = useHappyAction(async () => { const result = await sessionKill(session.id); if (!result.success) { @@ -404,6 +428,14 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi > {sessionName} + {isSharedSession && ownerName && ( + + + + {ownerName} + + + )} {/* Status line with dot */} diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index f3c7e1ff6..02389e014 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -64,6 +64,7 @@ interface AgentInputProps { isSendDisabled?: boolean; isSending?: boolean; minHeight?: number; + disabled?: boolean; } const MAX_CONTEXT_SIZE = 190000; @@ -699,6 +700,7 @@ export const AgentInput = React.memo(React.forwardRef @@ -902,7 +904,7 @@ export const AgentInput = React.memo(React.forwardRef {props.isSending ? ( void; + /** Currently selected user ID (optional) */ + selectedUserId?: string | null; + /** Currently selected access level (optional) */ + selectedAccessLevel?: ShareAccessLevel; +} + +/** + * Friend selector component for sharing + * + * @remarks + * Displays a searchable list of friends and allows selecting + * an access level. This is a controlled component - parent + * manages the modal and button states. + */ +export const FriendSelector = memo(function FriendSelector({ + friends, + excludedUserIds, + onSelect, + selectedUserId: initialSelectedUserId = null, + selectedAccessLevel: initialSelectedAccessLevel = 'view', +}: FriendSelectorProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedUserId, setSelectedUserId] = useState(initialSelectedUserId); + const [selectedAccessLevel, setSelectedAccessLevel] = useState(initialSelectedAccessLevel); + + // Filter friends based on search and exclusions + const filteredFriends = useMemo(() => { + const excluded = new Set(excludedUserIds); + return friends.filter(friend => { + if (excluded.has(friend.id)) return false; + if (!searchQuery) return true; + + const displayName = getDisplayName(friend).toLowerCase(); + const username = friend.username.toLowerCase(); + const query = searchQuery.toLowerCase(); + + return displayName.includes(query) || username.includes(query); + }); + }, [friends, excludedUserIds, searchQuery]); + + const selectedFriend = useMemo(() => { + return friends.find(f => f.id === selectedUserId); + }, [friends, selectedUserId]); + + // Call onSelect when both user and access level are chosen + React.useEffect(() => { + if (selectedUserId && selectedAccessLevel) { + onSelect(selectedUserId, selectedAccessLevel); + } + }, [selectedUserId, selectedAccessLevel, onSelect]); + + return ( + + {/* Search input */} + + + {/* Friend list */} + + item.id} + renderItem={({ item }) => ( + + setSelectedUserId(item.id)} + /> + {selectedUserId === item.id && ( + + )} + + )} + ListEmptyComponent={ + + + {searchQuery + ? t('friends.noFriendsFound') + : t('friends.noFriendsYet') + } + + + } + scrollEnabled={false} + /> + + + {/* Access level selection (only shown when friend is selected) */} + {selectedFriend && ( + + + {t('session.sharing.accessLevel')} + + setSelectedAccessLevel('view')} + rightElement={ + selectedAccessLevel === 'view' ? ( + + + + ) : ( + + ) + } + /> + setSelectedAccessLevel('edit')} + rightElement={ + selectedAccessLevel === 'edit' ? ( + + + + ) : ( + + ) + } + /> + setSelectedAccessLevel('admin')} + rightElement={ + selectedAccessLevel === 'admin' ? ( + + + + ) : ( + + ) + } + /> + + )} + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + padding: 16, + }, + searchInput: { + height: 40, + borderRadius: 8, + backgroundColor: theme.colors.surfaceHigh, + paddingHorizontal: 12, + marginBottom: 16, + fontSize: 16, + color: theme.colors.text, + }, + friendList: { + marginBottom: 16, + }, + friendItem: { + position: 'relative', + }, + selectedIndicator: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: 4, + backgroundColor: theme.colors.textLink, + }, + emptyState: { + padding: 32, + alignItems: 'center', + }, + emptyText: { + fontSize: 16, + color: theme.colors.textSecondary, + textAlign: 'center', + }, + accessLevelSection: { + marginTop: 8, + }, + sectionTitle: { + fontSize: 17, + fontWeight: '600', + color: theme.colors.text, + marginBottom: 12, + paddingHorizontal: 4, + }, + radioSelected: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: 'transparent', + borderWidth: 2, + borderColor: theme.colors.radio.active, + alignItems: 'center', + justifyContent: 'center', + }, + radioDot: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: theme.colors.radio.dot, + }, + radioUnselected: { + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 2, + borderColor: theme.colors.radio.inactive, + }, +})); diff --git a/sources/components/SessionSharing/PublicLinkDialog.tsx b/sources/components/SessionSharing/PublicLinkDialog.tsx new file mode 100644 index 000000000..61404ad6f --- /dev/null +++ b/sources/components/SessionSharing/PublicLinkDialog.tsx @@ -0,0 +1,360 @@ +import React, { memo, useState, useEffect } from 'react'; +import { View, Text, ScrollView, Switch } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import QRCode from 'qrcode'; +import { Image } from 'expo-image'; +import { PublicSessionShare } from '@/sync/sharingTypes'; +import { Item } from '@/components/Item'; +import { ItemList } from '@/components/ItemList'; +import { RoundButton } from '@/components/RoundButton'; +import { t } from '@/text'; +import { getServerUrl } from '@/sync/serverConfig'; + +/** + * Props for PublicLinkDialog component + */ +export interface PublicLinkDialogProps { + /** Existing public share if any */ + publicShare: PublicSessionShare | null; + /** Callback to create a new public share */ + onCreate: (options: { + expiresInDays?: number; + maxUses?: number; + isConsentRequired: boolean; + }) => void; + /** Callback to delete the public share */ + onDelete: () => void; + /** Callback when cancelled */ + onCancel: () => void; +} + +/** + * Dialog for managing public share links + * + * @remarks + * Displays the current public link with QR code, or allows creating a new one. + * Shows expiration date, usage count, and allows configuring consent requirement. + */ +export const PublicLinkDialog = memo(function PublicLinkDialog({ + publicShare, + onCreate, + onDelete, + onCancel +}: PublicLinkDialogProps) { + const [qrDataUrl, setQrDataUrl] = useState(null); + const [isCreating, setIsCreating] = useState(!publicShare); + const [expiresInDays, setExpiresInDays] = useState(7); + const [maxUses, setMaxUses] = useState(undefined); + const [isConsentRequired, setIsConsentRequired] = useState(true); + + // Generate QR code when public share exists + useEffect(() => { + if (!publicShare) { + setQrDataUrl(null); + return; + } + + // Use the configured server URL to generate the share link + const serverUrl = getServerUrl(); + const url = `${serverUrl}/share/${publicShare.token}`; + + QRCode.toDataURL(url, { + width: 250, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF', + }, + }) + .then(setQrDataUrl) + .catch(console.error); + }, [publicShare]); + + const handleCreate = () => { + onCreate({ + expiresInDays, + maxUses, + isConsentRequired, + }); + }; + + const formatDate = (timestamp: number) => { + return new Date(timestamp).toLocaleDateString(); + }; + + return ( + + + {t('session.sharing.publicLink')} + + + + + {isCreating ? ( + + + {t('session.sharing.publicLinkDescription')} + + + {/* Expiration */} + + + {t('session.sharing.expiresIn')} + + setExpiresInDays(7)} + rightElement={ + expiresInDays === 7 ? ( + + + + ) : ( + + ) + } + /> + setExpiresInDays(30)} + rightElement={ + expiresInDays === 30 ? ( + + + + ) : ( + + ) + } + /> + setExpiresInDays(undefined)} + rightElement={ + expiresInDays === undefined ? ( + + + + ) : ( + + ) + } + /> + + + {/* Max uses */} + + + {t('session.sharing.maxUsesLabel')} + + setMaxUses(undefined)} + rightElement={ + maxUses === undefined ? ( + + + + ) : ( + + ) + } + /> + setMaxUses(10)} + rightElement={ + maxUses === 10 ? ( + + + + ) : ( + + ) + } + /> + setMaxUses(50)} + rightElement={ + maxUses === 50 ? ( + + + + ) : ( + + ) + } + /> + + + {/* Consent */} + + + } + /> + + + {/* Create button */} + + + + + ) : publicShare ? ( + + {/* QR Code */} + {qrDataUrl && ( + + + + )} + + {/* Info */} + + {publicShare.expiresAt && ( + + )} + + + + {/* Delete button */} + + + + + ) : null} + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + width: 600, + maxWidth: '90%', + maxHeight: '80%', + backgroundColor: theme.colors.surface, + borderRadius: 12, + overflow: 'hidden', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + title: { + fontSize: 18, + fontWeight: '600', + color: theme.colors.text, + }, + content: { + flex: 1, + }, + description: { + fontSize: 14, + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 8, + lineHeight: 20, + }, + optionGroup: { + marginTop: 16, + }, + groupTitle: { + fontSize: 14, + fontWeight: '600', + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingBottom: 8, + textTransform: 'uppercase', + }, + radioSelected: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: 'transparent', + borderWidth: 2, + borderColor: theme.colors.radio.active, + alignItems: 'center', + justifyContent: 'center', + }, + radioDot: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: theme.colors.radio.dot, + }, + radioUnselected: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: 'transparent', + borderWidth: 2, + borderColor: theme.colors.radio.inactive, + }, + qrContainer: { + alignItems: 'center', + padding: 24, + backgroundColor: theme.colors.surface, + }, + buttonContainer: { + marginTop: 24, + marginBottom: 16, + paddingHorizontal: 16, + alignItems: 'center', + }, +})); diff --git a/sources/components/SessionSharing/SessionShareDialog.tsx b/sources/components/SessionSharing/SessionShareDialog.tsx new file mode 100644 index 000000000..550996376 --- /dev/null +++ b/sources/components/SessionSharing/SessionShareDialog.tsx @@ -0,0 +1,271 @@ +import React, { memo, useCallback, useState } from 'react'; +import { View, Text, ScrollView } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { Ionicons } from '@expo/vector-icons'; +import { Item } from '@/components/Item'; +import { ItemList } from '@/components/ItemList'; +import { t } from '@/text'; +import { SessionShare, ShareAccessLevel } from '@/sync/sharingTypes'; +import { Avatar } from '@/components/Avatar'; + +/** + * Props for the SessionShareDialog component + */ +interface SessionShareDialogProps { + /** ID of the session being shared */ + sessionId: string; + /** Current shares for this session */ + shares: SessionShare[]; + /** Whether the current user can manage shares (owner/admin) */ + canManage: boolean; + /** Callback when user wants to add a new share */ + onAddShare: () => void; + /** Callback when user updates share access level */ + onUpdateShare: (shareId: string, accessLevel: ShareAccessLevel) => void; + /** Callback when user removes a share */ + onRemoveShare: (shareId: string) => void; + /** Callback when user wants to create/manage public link */ + onManagePublicLink: () => void; + /** Callback to close the dialog */ + onClose: () => void; +} + +/** + * Dialog for managing session sharing + * + * @remarks + * Displays current shares and allows managing them. Shows: + * - List of users the session is shared with + * - Their access levels (view/edit/admin) + * - Options to add/remove shares (if canManage) + * - Link to public share management + */ +export const SessionShareDialog = memo(function SessionShareDialog({ + sessionId, + shares, + canManage, + onAddShare, + onUpdateShare, + onRemoveShare, + onManagePublicLink, + onClose +}: SessionShareDialogProps) { + const [selectedShareId, setSelectedShareId] = useState(null); + + const handleSharePress = useCallback((shareId: string) => { + if (canManage) { + setSelectedShareId(selectedShareId === shareId ? null : shareId); + } + }, [canManage, selectedShareId]); + + const handleAccessLevelChange = useCallback((shareId: string, accessLevel: ShareAccessLevel) => { + onUpdateShare(shareId, accessLevel); + setSelectedShareId(null); + }, [onUpdateShare]); + + const handleRemoveShare = useCallback((shareId: string) => { + onRemoveShare(shareId); + setSelectedShareId(null); + }, [onRemoveShare]); + + return ( + + + {t('session.sharing.title')} + + + + + + {/* Add share button */} + {canManage && ( + } + onPress={onAddShare} + /> + )} + + {/* Public link management */} + {canManage && ( + } + onPress={onManagePublicLink} + /> + )} + + {/* Current shares */} + {shares.length > 0 && ( + + + {t('session.sharing.sharedWith')} + + {shares.map(share => ( + handleSharePress(share.id)} + onAccessLevelChange={handleAccessLevelChange} + onRemove={handleRemoveShare} + /> + ))} + + )} + + {shares.length === 0 && !canManage && ( + + + {t('session.sharing.noShares')} + + + )} + + + + ); +}); + +/** + * Individual share item component + */ +interface ShareItemProps { + share: SessionShare; + canManage: boolean; + isSelected: boolean; + onPress: () => void; + onAccessLevelChange: (shareId: string, accessLevel: ShareAccessLevel) => void; + onRemove: (shareId: string) => void; +} + +const ShareItem = memo(function ShareItem({ + share, + canManage, + isSelected, + onPress, + onAccessLevelChange, + onRemove +}: ShareItemProps) { + const accessLevelLabel = getAccessLevelLabel(share.accessLevel); + const userName = share.sharedWithUser.username || [share.sharedWithUser.firstName, share.sharedWithUser.lastName] + .filter(Boolean) + .join(' '); + + return ( + + + } + onPress={canManage ? onPress : undefined} + showChevron={canManage} + /> + + {/* Access level options (shown when selected) */} + {isSelected && canManage && ( + + onAccessLevelChange(share.id, 'view')} + selected={share.accessLevel === 'view'} + /> + onAccessLevelChange(share.id, 'edit')} + selected={share.accessLevel === 'edit'} + /> + onAccessLevelChange(share.id, 'admin')} + selected={share.accessLevel === 'admin'} + /> + onRemove(share.id)} + destructive + /> + + )} + + ); +}); + +/** + * Get localized label for access level + */ +function getAccessLevelLabel(level: ShareAccessLevel): string { + switch (level) { + case 'view': + return t('session.sharing.viewOnly'); + case 'edit': + return t('session.sharing.canEdit'); + case 'admin': + return t('session.sharing.canManage'); + } +} + +const styles = StyleSheet.create((theme) => ({ + container: { + width: 600, + maxWidth: '90%', + maxHeight: '80%', + backgroundColor: theme.colors.surface, + borderRadius: 12, + overflow: 'hidden', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + title: { + fontSize: 18, + fontWeight: '600', + color: theme.colors.text, + }, + content: { + flex: 1, + }, + section: { + marginTop: 16, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingVertical: 8, + textTransform: 'uppercase', + }, + options: { + paddingLeft: 24, + backgroundColor: theme.colors.surfaceHigh, + }, + emptyState: { + padding: 32, + alignItems: 'center', + }, + emptyText: { + fontSize: 16, + color: theme.colors.textSecondary, + textAlign: 'center', + }, +})); diff --git a/sources/sync/apiSharing.ts b/sources/sync/apiSharing.ts new file mode 100644 index 000000000..9924943b8 --- /dev/null +++ b/sources/sync/apiSharing.ts @@ -0,0 +1,531 @@ +import { AuthCredentials } from '@/auth/tokenStorage'; +import { backoff } from '@/utils/time'; +import { getServerUrl } from './serverConfig'; +import { + SessionShare, + SessionShareResponse, + SessionSharesResponse, + CreateSessionShareRequest, + PublicSessionShare, + PublicShareResponse, + CreatePublicShareRequest, + AccessPublicShareResponse, + SharedSessionsResponse, + SessionWithShareResponse, + PublicShareAccessLogsResponse, + PublicShareBlockedUsersResponse, + BlockPublicShareUserRequest, + ShareNotFoundError, + PublicShareNotFoundError, + ConsentRequiredError, + SessionSharingError +} from './sharingTypes'; + +const API_ENDPOINT = getServerUrl(); + +/** + * Get all shares for a session + * + * @param credentials - User authentication credentials + * @param sessionId - ID of the session to get shares for + * @returns List of all shares for the session + * @throws {SessionSharingError} If the user doesn't have permission (not owner/admin) + * @throws {Error} For other API errors + * + * @remarks + * Only the session owner or users with admin access can view all shares. + * The returned shares include information about who has access and their + * access levels. + */ +export async function getSessionShares( + credentials: AuthCredentials, + sessionId: string +): Promise { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/shares`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + throw new Error(`Failed to get session shares: ${response.status}`); + } + + const data: SessionSharesResponse = await response.json(); + return data.shares; + }); +} + +/** + * Share a session with a specific user + * + * @param credentials - User authentication credentials + * @param sessionId - ID of the session to share + * @param request - Share creation request containing userId and accessLevel + * @returns The created or updated share + * @throws {SessionSharingError} If sharing fails (not friends, forbidden, etc.) + * @throws {Error} For other API errors + * + * @remarks + * Only the session owner or users with admin access can create shares. + * The target user must be a friend of the owner. If a share already exists + * for the user, it will be updated with the new access level. + * + * The server will automatically encrypt the session's data encryption key with + * the recipient's public key, allowing them to decrypt the session data. + */ +export async function createSessionShare( + credentials: AuthCredentials, + sessionId: string, + request: CreateSessionShareRequest +): Promise { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/shares`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }); + + if (!response.ok) { + if (response.status === 403) { + const error = await response.json(); + throw new SessionSharingError(error.error || 'Forbidden'); + } + if (response.status === 400) { + const error = await response.json(); + throw new SessionSharingError(error.error || 'Bad request'); + } + throw new Error(`Failed to create session share: ${response.status}`); + } + + const data: SessionShareResponse = await response.json(); + return data.share; + }); +} + +/** + * Update the access level of an existing share + * + * @param credentials - User authentication credentials + * @param sessionId - ID of the session + * @param shareId - ID of the share to update + * @param accessLevel - New access level to grant + * @returns The updated share + * @throws {SessionSharingError} If the user doesn't have permission + * @throws {ShareNotFoundError} If the share doesn't exist + * @throws {Error} For other API errors + * + * @remarks + * Only the session owner or users with admin access can update shares. + */ +export async function updateSessionShare( + credentials: AuthCredentials, + sessionId: string, + shareId: string, + accessLevel: 'view' | 'edit' | 'admin' +): Promise { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/shares/${shareId}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ accessLevel }) + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + if (response.status === 404) { + throw new ShareNotFoundError(); + } + throw new Error(`Failed to update session share: ${response.status}`); + } + + const data: SessionShareResponse = await response.json(); + return data.share; + }); +} + +/** + * Delete a share and revoke user access + * + * @param credentials - User authentication credentials + * @param sessionId - ID of the session + * @param shareId - ID of the share to delete + * @throws {SessionSharingError} If the user doesn't have permission + * @throws {ShareNotFoundError} If the share doesn't exist + * @throws {Error} For other API errors + * + * @remarks + * Only the session owner or users with admin access can delete shares. + * The shared user will immediately lose access to the session. + */ +export async function deleteSessionShare( + credentials: AuthCredentials, + sessionId: string, + shareId: string +): Promise { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/shares/${shareId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + if (response.status === 404) { + throw new ShareNotFoundError(); + } + throw new Error(`Failed to delete session share: ${response.status}`); + } + }); +} + +/** + * Get all sessions shared with the current user + * + * @param credentials - User authentication credentials + * @returns List of sessions that have been shared with the current user + * @throws {Error} For API errors + * + * @remarks + * Returns sessions where the current user has been granted access by other users. + * Each entry includes the session metadata, who shared it, and the access level granted. + */ +export async function getSharedSessions( + credentials: AuthCredentials +): Promise { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/shares/sessions`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + throw new Error(`Failed to get shared sessions: ${response.status}`); + } + + return await response.json(); + }); +} + +/** + * Get shared session details with encrypted key + */ +export async function getSharedSessionDetails( + credentials: AuthCredentials, + sessionId: string +): Promise { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/shares/sessions/${sessionId}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + if (response.status === 404) { + throw new ShareNotFoundError(); + } + throw new Error(`Failed to get shared session details: ${response.status}`); + } + + return await response.json(); + }); +} + +/** + * Create or update a public share link for a session + * + * @param credentials - User authentication credentials + * @param sessionId - ID of the session to share publicly + * @param request - Public share configuration (expiration, limits, consent) + * @returns The created or updated public share with its token + * @throws {SessionSharingError} If the user doesn't have permission + * @throws {Error} For other API errors + * + * @remarks + * Only the session owner can create public shares. Public shares are always + * read-only for security. If a public share already exists for the session, + * it will be updated with the new settings. + * + * The returned `token` can be used to construct a public URL for sharing. + */ +export async function createPublicShare( + credentials: AuthCredentials, + sessionId: string, + request: CreatePublicShareRequest & { token: string } +): Promise { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + throw new Error(`Failed to create public share: ${response.status}`); + } + + const data: PublicShareResponse = await response.json(); + return data.publicShare; + }); +} + +/** + * Get public share info for a session + */ +export async function getPublicShare( + credentials: AuthCredentials, + sessionId: string +): Promise { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + throw new Error(`Failed to get public share: ${response.status}`); + } + + const data: PublicShareResponse = await response.json(); + return data.publicShare; + }); +} + +/** + * Delete public share (disable public link) + */ +export async function deletePublicShare( + credentials: AuthCredentials, + sessionId: string +): Promise { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + throw new Error(`Failed to delete public share: ${response.status}`); + } + }); +} + +/** + * Access a session via a public share token + * + * @param token - The public share token from the URL + * @param consent - Whether the user consents to access logging (if required) + * @param credentials - Optional user credentials for authenticated access + * @returns Session data and encrypted key for decryption + * @throws {PublicShareNotFoundError} If the token is invalid, expired, or max uses reached + * @throws {ConsentRequiredError} If consent is required but not provided + * @throws {SessionSharingError} For other access errors + * @throws {Error} For other API errors + * + * @remarks + * This endpoint does not require authentication, allowing anonymous access. + * However, if credentials are provided, the user's identity will be logged. + * + * If the public share has `isConsentRequired` set to true, the `consent` + * parameter must be true, or a ConsentRequiredError will be thrown. + * + * Public shares are always read-only access. The returned session includes + * metadata and an encrypted data key for decrypting the session content. + */ +export async function accessPublicShare( + token: string, + consent?: boolean, + credentials?: AuthCredentials +): Promise { + return await backoff(async () => { + const url = new URL(`${API_ENDPOINT}/v1/public-share/${token}`); + if (consent !== undefined) { + url.searchParams.set('consent', consent.toString()); + } + + const headers: Record = {}; + if (credentials) { + headers['Authorization'] = `Bearer ${credentials.token}`; + } + + const response = await fetch(url.toString(), { + method: 'GET', + headers + }); + + if (!response.ok) { + if (response.status === 404) { + throw new PublicShareNotFoundError(); + } + if (response.status === 403) { + const error = await response.json(); + if (error.requiresConsent) { + throw new ConsentRequiredError(); + } + throw new SessionSharingError(error.error || 'Forbidden'); + } + throw new Error(`Failed to access public share: ${response.status}`); + } + + return await response.json(); + }); +} + +/** + * Get blocked users for public share + */ +export async function getPublicShareBlockedUsers( + credentials: AuthCredentials, + sessionId: string +): Promise { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share/blocked-users`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + if (response.status === 404) { + throw new PublicShareNotFoundError(); + } + throw new Error(`Failed to get blocked users: ${response.status}`); + } + + return await response.json(); + }); +} + +/** + * Block user from public share + */ +export async function blockPublicShareUser( + credentials: AuthCredentials, + sessionId: string, + request: BlockPublicShareUserRequest +): Promise { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share/blocked-users`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + if (response.status === 404) { + throw new PublicShareNotFoundError(); + } + throw new Error(`Failed to block user: ${response.status}`); + } + }); +} + +/** + * Unblock user from public share + */ +export async function unblockPublicShareUser( + credentials: AuthCredentials, + sessionId: string, + blockedUserId: string +): Promise { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share/blocked-users/${blockedUserId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + throw new Error(`Failed to unblock user: ${response.status}`); + } + }); +} + +/** + * Get access logs for public share + */ +export async function getPublicShareAccessLogs( + credentials: AuthCredentials, + sessionId: string, + limit?: number +): Promise { + return await backoff(async () => { + const url = new URL(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share/access-logs`); + if (limit !== undefined) { + url.searchParams.set('limit', limit.toString()); + } + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + if (response.status === 404) { + throw new PublicShareNotFoundError(); + } + throw new Error(`Failed to get access logs: ${response.status}`); + } + + return await response.json(); + }); +} diff --git a/sources/sync/apiTypes.ts b/sources/sync/apiTypes.ts index 4de92559f..18075eca3 100644 --- a/sources/sync/apiTypes.ts +++ b/sources/sync/apiTypes.ts @@ -147,6 +147,24 @@ export const ApiKvBatchUpdateSchema = z.object({ })) }); +// Session sharing event schemas +export const ApiSessionSharedSchema = z.object({ + t: z.literal('session-shared'), + sessionId: z.string(), +}); + +export const ApiSessionShareUpdatedSchema = z.object({ + t: z.literal('session-share-updated'), + sessionId: z.string(), + shareId: z.string(), +}); + +export const ApiSessionShareRevokedSchema = z.object({ + t: z.literal('session-share-revoked'), + sessionId: z.string(), + shareId: z.string(), +}); + export const ApiUpdateSchema = z.discriminatedUnion('t', [ ApiUpdateNewMessageSchema, ApiUpdateNewSessionSchema, @@ -159,7 +177,10 @@ export const ApiUpdateSchema = z.discriminatedUnion('t', [ ApiDeleteArtifactSchema, ApiRelationshipUpdatedSchema, ApiNewFeedPostSchema, - ApiKvBatchUpdateSchema + ApiKvBatchUpdateSchema, + ApiSessionSharedSchema, + ApiSessionShareUpdatedSchema, + ApiSessionShareRevokedSchema ]); export type ApiUpdateNewMessage = z.infer; diff --git a/sources/sync/friendTypes.ts b/sources/sync/friendTypes.ts index 4ad66bb98..873eb5d35 100644 --- a/sources/sync/friendTypes.ts +++ b/sources/sync/friendTypes.ts @@ -25,7 +25,8 @@ export const UserProfileSchema = z.object({ }).nullable(), username: z.string(), bio: z.string().nullable(), - status: RelationshipStatusSchema + status: RelationshipStatusSchema, + publicKey: z.string() }); export type UserProfile = z.infer; diff --git a/sources/sync/publicShareEncryption.ts b/sources/sync/publicShareEncryption.ts new file mode 100644 index 000000000..b5b46e086 --- /dev/null +++ b/sources/sync/publicShareEncryption.ts @@ -0,0 +1,69 @@ +import { deriveKey } from '@/encryption/deriveKey'; +import { encryptSecretBox, decryptSecretBox } from '@/encryption/libsodium'; +import { encodeBase64, decodeBase64 } from '@/encryption/base64'; + +/** + * Encrypt a data encryption key for public sharing using a token + * + * @param dataEncryptionKey - The session's data encryption key to encrypt + * @param token - The random public share token + * @returns Base64 encoded encrypted data key + * + * @remarks + * Uses SecretBox encryption with a key derived from the token. + * The token must be kept secret as it enables decryption. + */ +export async function encryptDataKeyForPublicShare( + dataEncryptionKey: Uint8Array, + token: string +): Promise { + // Derive encryption key from token + const tokenBytes = new TextEncoder().encode(token); + const encryptionKey = await deriveKey(tokenBytes, 'Happy Public Share', ['v1']); + + // Encrypt the data key + const encrypted = encryptSecretBox(dataEncryptionKey, encryptionKey); + + // Return as base64 + return encodeBase64(encrypted, 'base64'); +} + +/** + * Decrypt a data encryption key from a public share using a token + * + * @param encryptedDataKey - The encrypted data key (base64) + * @param token - The public share token + * @returns Decrypted data encryption key, or null if decryption fails + * + * @remarks + * This is the inverse of encryptDataKeyForPublicShare. + */ +export async function decryptDataKeyFromPublicShare( + encryptedDataKey: string, + token: string +): Promise { + try { + // Derive decryption key from token + const tokenBytes = new TextEncoder().encode(token); + const decryptionKey = await deriveKey(tokenBytes, 'Happy Public Share', ['v1']); + + // Decode from base64 + const encrypted = decodeBase64(encryptedDataKey, 'base64'); + + // Decrypt and return + const decrypted = decryptSecretBox(encrypted, decryptionKey); + if (!decrypted) { + return null; + } + + // Convert back to Uint8Array if it's a different type + if (typeof decrypted === 'string') { + return new TextEncoder().encode(decrypted); + } + + return new Uint8Array(decrypted); + } catch (error) { + console.error('Failed to decrypt public share data key:', error); + return null; + } +} diff --git a/sources/sync/sharingTypes.ts b/sources/sync/sharingTypes.ts new file mode 100644 index 000000000..b5c5cd76e --- /dev/null +++ b/sources/sync/sharingTypes.ts @@ -0,0 +1,489 @@ +import { z } from "zod"; + +// +// Session Sharing Types +// + +/** + * Access level for session sharing + * + * @remarks + * Defines the permission level a user has when accessing a shared session: + * - `view`: Read-only access to session messages and metadata + * - `edit`: Can send messages but cannot manage sharing settings + * - `admin`: Full access including sharing management + */ +export type ShareAccessLevel = 'view' | 'edit' | 'admin'; + +/** + * User profile information included in share responses + * + * @remarks + * This is a subset of the full user profile, containing only the information + * necessary for displaying who has access to a session. + */ +export interface ShareUserProfile { + /** Unique user identifier */ + id: string; + /** User's unique username */ + username: string; + /** User's first name, if set */ + firstName: string | null; + /** User's last name, if set */ + lastName: string | null; + /** URL to user's avatar image, if set */ + avatar: string | null; +} + +/** + * Session share (direct user-to-user sharing) + * + * @remarks + * Represents a direct share of a session between two users. The session owner + * can share with specific users who must be friends. Each share has an access + * level that determines what the shared user can do. + * + * The `encryptedDataKey` is only present when the current user is the recipient + * of the share, allowing them to decrypt the session data. + */ +export interface SessionShare { + /** Unique identifier for this share */ + id: string; + /** ID of the session being shared */ + sessionId: string; + /** User who receives access to the session */ + sharedWithUser: ShareUserProfile; + /** User who created the share (optional, only in some contexts) */ + sharedBy?: ShareUserProfile; + /** Access level granted to the shared user */ + accessLevel: ShareAccessLevel; + /** + * Session data encryption key, encrypted with the recipient's public key + * + * @remarks + * Base64 encoded. Only present when accessing as the shared user. + * Used to decrypt the session's messages and data. + */ + encryptedDataKey?: string; + /** Timestamp when the share was created (milliseconds since epoch) */ + createdAt: number; + /** Timestamp when the share was last updated (milliseconds since epoch) */ + updatedAt: number; +} + +/** + * Public session share (link-based sharing) + * + * @remarks + * Represents a public link that allows anyone with the token to access a session. + * Public shares are always read-only for security reasons. They can have optional + * expiration dates and usage limits. + * + * When `isConsentRequired` is true, users must explicitly consent to logging of + * their IP address and user agent before accessing the session. + */ +export interface PublicSessionShare { + /** Unique identifier for this public share */ + id: string; + /** ID of the session being shared (optional in some contexts) */ + sessionId?: string; + /** Random token used in the public URL */ + token: string; + /** + * Expiration timestamp (milliseconds since epoch), or null if never expires + * + * @remarks + * After this time, the link will no longer be accessible. + */ + expiresAt: number | null; + /** + * Maximum number of times the link can be accessed, or null for unlimited + * + * @remarks + * Once `useCount` reaches this value, the link becomes inaccessible. + */ + maxUses: number | null; + /** Number of times the link has been accessed */ + useCount: number; + /** + * Whether users must consent to access logging + * + * @remarks + * If true, the user must explicitly consent before their IP address and + * user agent are logged. If false, access is not logged. + */ + isConsentRequired: boolean; + /** Timestamp when the share was created (milliseconds since epoch) */ + createdAt: number; + /** Timestamp when the share was last updated (milliseconds since epoch) */ + updatedAt: number; +} + +/** + * Shared session with metadata + * + * @remarks + * Represents a session that has been shared with the current user, including + * the share metadata and session information needed to display it in a list. + */ +export interface SharedSession { + /** Session ID */ + id: string; + /** ID of the share that grants access */ + shareId: string; + /** Session sequence number for sync */ + seq: number; + /** Timestamp when session was created (milliseconds since epoch) */ + createdAt: number; + /** Timestamp when session was last updated (milliseconds since epoch) */ + updatedAt: number; + /** Whether the session is currently active */ + active: boolean; + /** Timestamp of last activity (milliseconds since epoch) */ + activeAt: number; + /** Session metadata (path, name, etc.) */ + metadata: any; + /** Version number of the metadata */ + metadataVersion: number; + /** User who shared this session */ + sharedBy: ShareUserProfile; + /** Access level granted to current user */ + accessLevel: ShareAccessLevel; + /** Session data encryption key, encrypted with current user's public key (base64) */ + encryptedDataKey: string; +} + +/** + * Access log entry for public shares + * + * @remarks + * Records when and by whom a public share was accessed. IP address and user + * agent are only logged if the user gave consent or consent was not required. + */ +export interface PublicShareAccessLog { + /** Unique identifier for this log entry */ + id: string; + /** + * User who accessed the share, if authenticated + * + * @remarks + * Null if the user accessed anonymously without authentication. + */ + user: ShareUserProfile | null; + /** Timestamp of access (milliseconds since epoch) */ + accessedAt: number; + /** + * IP address of the accessor + * + * @remarks + * Only logged if user gave consent (when `isConsentRequired` is true) + * or if consent was not required. + */ + ipAddress: string | null; + /** + * User agent string of the accessor's browser + * + * @remarks + * Only logged if user gave consent (when `isConsentRequired` is true) + * or if consent was not required. + */ + userAgent: string | null; +} + +/** + * Blocked user for public shares + * + * @remarks + * Represents a user who has been blocked from accessing a specific public share. + * Even if they have the token, blocked users will receive a 404 error. + */ +export interface PublicShareBlockedUser { + /** Unique identifier for this block entry */ + id: string; + /** User who is blocked */ + user: ShareUserProfile; + /** Optional reason for blocking (displayed to owner) */ + reason: string | null; + /** Timestamp when user was blocked (milliseconds since epoch) */ + blockedAt: number; +} + +// +// API Request/Response Types +// + +/** + * Request to create or update a session share + * + * @remarks + * Used when sharing a session with a specific user. The user must be a friend + * of the session owner. The server will handle encryption of the data key with + * the recipient's public key. + */ +export interface CreateSessionShareRequest { + /** ID of the user to share with */ + userId: string; + /** Access level to grant */ + accessLevel: ShareAccessLevel; +} + +/** Response containing a single session share */ +export interface SessionShareResponse { + /** The created or updated share */ + share: SessionShare; +} + +/** Response containing multiple session shares */ +export interface SessionSharesResponse { + /** List of shares for a session */ + shares: SessionShare[]; +} + +/** + * Request to create or update a public share + * + * @remarks + * Creates a public link for a session. The link can optionally have an + * expiration date, usage limit, and consent requirement for access logging. + */ +export interface CreatePublicShareRequest { + /** + * Session data encryption key, encrypted for public access + * + * @remarks + * Base64 encoded. Typically encrypted with a key derived from the token. + */ + encryptedDataKey: string; + /** + * Optional expiration timestamp (milliseconds since epoch) + * + * @remarks + * After this time, the link will no longer be accessible. + */ + expiresAt?: number; + /** + * Optional maximum number of accesses + * + * @remarks + * Once this limit is reached, the link becomes inaccessible. + */ + maxUses?: number; + /** + * Whether to require user consent for access logging + * + * @remarks + * If true, users must explicitly consent before their IP and user agent + * are logged. Defaults to false. + */ + isConsentRequired?: boolean; +} + +/** Response containing a public share */ +export interface PublicShareResponse { + /** The created, updated, or retrieved public share */ + publicShare: PublicSessionShare; +} + +/** + * Response when accessing a session via public share + * + * @remarks + * Returns the session data and encrypted key needed to decrypt it. + * Public shares always have view-only access. + */ +export interface AccessPublicShareResponse { + /** Session information */ + session: { + /** Session ID */ + id: string; + /** Session sequence number */ + seq: number; + /** Creation timestamp (milliseconds since epoch) */ + createdAt: number; + /** Last update timestamp (milliseconds since epoch) */ + updatedAt: number; + /** Whether session is active */ + active: boolean; + /** Last activity timestamp (milliseconds since epoch) */ + activeAt: number; + /** Session metadata */ + metadata: any; + /** Metadata version number */ + metadataVersion: number; + /** Agent state */ + agentState: any; + /** Agent state version number */ + agentStateVersion: number; + }; + /** Access level (always 'view' for public shares) */ + accessLevel: 'view'; + /** Encrypted data key for decrypting session (base64) */ + encryptedDataKey: string; +} + +/** Response containing sessions shared with the current user */ +export interface SharedSessionsResponse { + /** List of sessions that have been shared with the current user */ + shares: SharedSession[]; +} + +/** + * Response containing session details with share information + * + * @remarks + * Used when retrieving a specific session that may be owned or shared. + * The response structure differs based on whether the user is the owner + * or has shared access. + */ +export interface SessionWithShareResponse { + /** Session information */ + session: { + /** Session ID */ + id: string; + /** Session sequence number */ + seq: number; + /** Creation timestamp (milliseconds since epoch) */ + createdAt: number; + /** Last update timestamp (milliseconds since epoch) */ + updatedAt: number; + /** Whether session is active */ + active: boolean; + /** Last activity timestamp (milliseconds since epoch) */ + activeAt: number; + /** Session metadata */ + metadata: any; + /** Metadata version number */ + metadataVersion: number; + /** Agent state */ + agentState: any; + /** Agent state version number */ + agentStateVersion: number; + /** + * Session data encryption key (base64) + * + * @remarks + * Only present if the current user is the session owner. + */ + dataEncryptionKey?: string; + }; + /** Access level of current user */ + accessLevel: ShareAccessLevel; + /** + * Encrypted data key for decrypting session (base64) + * + * @remarks + * Only present if the current user has shared access (not the owner). + */ + encryptedDataKey?: string; + /** Whether the current user is the session owner */ + isOwner: boolean; +} + +/** Response containing access logs for a public share */ +export interface PublicShareAccessLogsResponse { + /** List of access log entries */ + logs: PublicShareAccessLog[]; +} + +/** Response containing blocked users for a public share */ +export interface PublicShareBlockedUsersResponse { + /** List of blocked users */ + blockedUsers: PublicShareBlockedUser[]; +} + +/** + * Request to block a user from a public share + * + * @remarks + * Prevents a specific user from accessing a public share, even if they + * have the token. Useful for dealing with abuse. + */ +export interface BlockPublicShareUserRequest { + /** ID of the user to block */ + userId: string; + /** + * Optional reason for blocking + * + * @remarks + * This is only visible to the session owner and helps track why + * users were blocked. + */ + reason?: string; +} + +// +// Error Types +// + +/** + * Base error class for session sharing operations + * + * @remarks + * All session sharing errors extend from this class for easy error handling. + */ +export class SessionSharingError extends Error { + constructor(message: string) { + super(message); + this.name = 'SessionSharingError'; + } +} + +/** + * Error thrown when a requested share does not exist + * + * @remarks + * This can occur when trying to access, update, or delete a share that + * has already been deleted or never existed. + */ +export class ShareNotFoundError extends SessionSharingError { + constructor() { + super('Share not found'); + this.name = 'ShareNotFoundError'; + } +} + +/** + * Error thrown when a public share token is invalid or expired + * + * @remarks + * This can occur if: + * - The token doesn't exist + * - The share has expired (past `expiresAt`) + * - The maximum uses have been reached + * - The current user is blocked + */ +export class PublicShareNotFoundError extends SessionSharingError { + constructor() { + super('Public share not found or expired'); + this.name = 'PublicShareNotFoundError'; + } +} + +/** + * Error thrown when accessing a public share that requires consent + * + * @remarks + * When `isConsentRequired` is true, users must explicitly consent to + * access logging by passing `consent=true` in the request. This error + * indicates the consent parameter was missing or false. + */ +export class ConsentRequiredError extends SessionSharingError { + constructor() { + super('Consent required for access'); + this.name = 'ConsentRequiredError'; + } +} + +/** + * Error thrown when a public share has reached its maximum usage limit + * + * @remarks + * When a public share has a `maxUses` limit and that limit has been + * reached, further access attempts will fail with this error. + */ +export class MaxUsesReachedError extends SessionSharingError { + constructor() { + super('Maximum uses reached'); + this.name = 'MaxUsesReachedError'; + } +} diff --git a/sources/sync/storageTypes.ts b/sources/sync/storageTypes.ts index bff187d04..c17fb1244 100644 --- a/sources/sync/storageTypes.ts +++ b/sources/sync/storageTypes.ts @@ -82,6 +82,16 @@ export interface Session { contextSize: number; timestamp: number; } | null; + // Sharing-related fields + owner?: string; // User ID of the session owner (for shared sessions) + ownerProfile?: { + id: string; + username: string; + firstName: string | null; + lastName: string | null; + avatar: string | null; + }; // Owner profile information (for shared sessions) + accessLevel?: 'view' | 'edit' | 'admin'; // Access level for shared sessions } export interface DecryptedMessage { diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index 49d9c07fd..a141bdf93 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -50,6 +50,7 @@ class Sync { private credentials!: AuthCredentials; public encryptionCache = new EncryptionCache(); private sessionsSync: InvalidateSync; + private sharedSessionsSync: InvalidateSync; private messagesSync = new Map(); private sessionReceivedMessages = new Map>(); private sessionDataKeys = new Map(); // Store session data encryption keys internally @@ -76,6 +77,7 @@ class Sync { constructor() { this.sessionsSync = new InvalidateSync(this.fetchSessions); + this.sharedSessionsSync = new InvalidateSync(this.fetchSharedSessions); this.settingsSync = new InvalidateSync(this.syncSettings); this.profileSync = new InvalidateSync(this.fetchProfile); this.purchasesSync = new InvalidateSync(this.syncPurchases); @@ -165,6 +167,7 @@ class Sync { // Invalidate sync log.log('🔄 #init: Invalidating all syncs'); this.sessionsSync.invalidate(); + this.sharedSessionsSync.invalidate(); this.settingsSync.invalidate(); this.profileSync.invalidate(); this.purchasesSync.invalidate(); @@ -176,11 +179,12 @@ class Sync { this.artifactsSync.invalidate(); this.feedSync.invalidate(); this.todosSync.invalidate(); - log.log('🔄 #init: All syncs invalidated, including artifacts and todos'); + log.log('🔄 #init: All syncs invalidated, including shared sessions, artifacts and todos'); - // Wait for both sessions and machines to load, then mark as ready + // Wait for sessions, shared sessions, and machines to load, then mark as ready Promise.all([ this.sessionsSync.awaitQueue(), + this.sharedSessionsSync.awaitQueue(), this.machinesSync.awaitQueue() ]).then(() => { storage.getState().applyReady(); @@ -503,6 +507,7 @@ class Sync { continue; } sessionKeys.set(session.id, decrypted); + this.sessionDataKeys.set(session.id, decrypted); // Store for later use } else { sessionKeys.set(session.id, null); } @@ -542,6 +547,104 @@ class Sync { } + private fetchSharedSessions = async () => { + if (!this.credentials) return; + + const API_ENDPOINT = getServerUrl(); + const response = await fetch(`${API_ENDPOINT}/v1/shares/sessions`, { + headers: { + 'Authorization': `Bearer ${this.credentials.token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch shared sessions: ${response.status}`); + } + + const data = await response.json(); + const sharedSessions = data.shares as Array<{ + session: { + id: string; + seq: number; + metadata: string; + metadataVersion: number; + agentState: string | null; + agentStateVersion: number; + active: boolean; + activeAt: number; + createdAt: number; + updatedAt: number; + }; + accessLevel: 'view' | 'edit' | 'admin'; + encryptedDataKey: string; + sharedBy: { + id: string; + username: string; + name: string | null; + }; + }>; + + // Initialize all shared session encryptions + const sessionKeys = new Map(); + for (const share of sharedSessions) { + if (share.encryptedDataKey) { + // Decrypt the encrypted data key using our private key + let decrypted = await this.encryption.decryptEncryptionKey(share.encryptedDataKey); + if (!decrypted) { + console.error(`Failed to decrypt shared data encryption key for session ${share.session.id}`); + continue; + } + sessionKeys.set(share.session.id, decrypted); + this.sessionDataKeys.set(share.session.id, decrypted); // Store for later use + } + } + await this.encryption.initializeSessions(sessionKeys); + + // Decrypt shared sessions + let decryptedSessions: (Omit & { presence?: "online" | number })[] = []; + for (const share of sharedSessions) { + const session = share.session; + + // Get session encryption (should always exist after initialization) + const sessionEncryption = this.encryption.getSessionEncryption(session.id); + if (!sessionEncryption) { + console.error(`Session encryption not found for shared session ${session.id}`); + continue; + } + + // Decrypt metadata and agent state + let metadata = await sessionEncryption.decryptMetadata(session.metadataVersion, session.metadata); + let agentState = await sessionEncryption.decryptAgentState(session.agentStateVersion, session.agentState); + + // Add owner information from sharedBy + const processedSession = { + id: session.id, + seq: session.seq, + tag: `shared-${session.id}`, // Generate a unique tag for shared sessions + thinking: false, + thinkingAt: 0, + metadata, + metadataVersion: session.metadataVersion, + agentState, + agentStateVersion: session.agentStateVersion, + active: session.active, + activeAt: session.activeAt, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + owner: share.sharedBy.id, // Mark the actual owner + ownerProfile: share.sharedBy, // Include owner profile information + accessLevel: share.accessLevel, // Add access level + lastMessage: null + }; + decryptedSessions.push(processedSession); + } + + // Apply to storage + this.applySessions(decryptedSessions); + log.log(`📥 fetchSharedSessions completed - processed ${decryptedSessions.length} shared sessions`); + } + public refreshMachines = async () => { return this.fetchMachines(); } @@ -554,6 +657,22 @@ class Sync { return this.credentials; } + public getUserID(): string { + return this.serverID; + } + + public getSessionDataKey(sessionId: string): Uint8Array | null { + return this.sessionDataKeys.get(sessionId) || null; + } + + public storePublicShareKey(sessionId: string, dataKey: Uint8Array): void { + this.sessionDataKeys.set(sessionId, dataKey); + } + + public getUserPublicKey(): Uint8Array { + return this.encryption.contentDataKey; + } + // Artifact methods public fetchArtifactsList = async (): Promise => { log.log('📦 fetchArtifactsList: Starting artifact sync'); @@ -1584,6 +1703,21 @@ class Sync { gitStatusSync.clearForSession(sessionId); log.log(`🗑️ Session ${sessionId} deleted from local storage`); + } else if (updateData.body.t === 'session-shared') { + log.log('🤝 Session shared with me'); + this.sharedSessionsSync.invalidate(); + } else if (updateData.body.t === 'session-share-updated') { + log.log('🔄 Session share access level updated'); + this.sharedSessionsSync.invalidate(); + } else if (updateData.body.t === 'session-share-revoked') { + log.log('🚫 Session share revoked'); + const sessionId = updateData.body.sessionId; + // Remove the session if we only had it through sharing + const session = storage.getState().sessions[sessionId]; + if (session && session.owner !== this.serverID) { + storage.getState().deleteSession(sessionId); + this.encryption.removeSessionEncryption(sessionId); + } } else if (updateData.body.t === 'update-session') { const session = storage.getState().sessions[updateData.body.id]; if (session) { diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 0e5df6366..a06d4c0d1 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -57,6 +57,9 @@ export const en = { fileViewer: 'File Viewer', loading: 'Loading...', retry: 'Retry', + share: 'Share', + sharing: 'Sharing', + sharedSessions: 'Shared Sessions', }, profile: { @@ -226,6 +229,7 @@ export const en = { userNotFound: 'User not found', sessionDeleted: 'Session has been deleted', sessionDeletedDescription: 'This session has been permanently removed', + invalidShareLink: 'Invalid or expired share link', // Error functions with context fieldError: ({ field, reason }: { field: string; reason: string }) => @@ -246,6 +250,12 @@ export const en = { failedToRemoveFriend: 'Failed to remove friend', searchFailed: 'Search failed. Please try again.', failedToSendRequest: 'Failed to send friend request', + cannotShareWithSelf: 'Cannot share with yourself', + canOnlyShareWithFriends: 'Can only share with friends', + shareNotFound: 'Share not found', + publicShareNotFound: 'Public share not found or expired', + consentRequired: 'Consent required for access', + maxUsesReached: 'Maximum uses reached', }, newSession: { @@ -292,6 +302,54 @@ export const en = { session: { inputPlaceholder: 'Type a message ...', + sharing: { + title: 'Session Sharing', + shareWith: 'Share with...', + sharedWith: 'Shared with', + shareSession: 'Share Session', + stopSharing: 'Stop Sharing', + accessLevel: 'Access Level', + publicLink: 'Public Link', + createPublicLink: 'Create Public Link', + deletePublicLink: 'Delete Public Link', + copyLink: 'Copy Link', + linkCopied: 'Link copied!', + viewOnly: 'View Only', + canEdit: 'Can Edit', + canManage: 'Can Manage', + sharedBy: ({ name }: { name: string }) => `Shared by ${name}`, + expiresAt: ({ date }: { date: string }) => `Expires: ${date}`, + maxUses: ({ count, used }: { count: number; used: number }) => `${used} / ${count} uses`, + unlimited: 'Unlimited', + requireConsent: 'Require consent for access logging', + consentRequired: 'This link requires your consent to log access information (IP address and user agent)', + giveConsent: 'I consent to access logging', + shareWithFriends: 'Share with friends only', + friendsOnly: 'Only friends can be added', + noShares: 'No shares yet', + viewOnlyDescription: 'Can view messages and metadata', + canEditDescription: 'Can send messages but cannot manage sharing', + canManageDescription: 'Full access including sharing management', + shareNotFound: 'Share link not found or has been revoked', + shareExpired: 'This share link has expired', + failedToDecrypt: 'Failed to decrypt share information', + consentDescription: 'By accepting, you consent to logging of your access information', + acceptAndView: 'Accept and View', + days7: '7 days', + days30: '30 days', + never: 'Never expires', + uses10: '10 uses', + uses50: '50 uses', + maxUsesLabel: 'Maximum uses', + publicLinkDescription: 'Create a shareable link that anyone can use to access this session', + expiresIn: 'Link expires in', + requireConsentDescription: 'Users must consent before accessing', + linkToken: 'Link Token', + expiresOn: 'Expires on', + usageCount: 'Usage', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used} / ${max} uses`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} uses`, + }, }, commandPalette: { @@ -366,7 +424,9 @@ export const en = { deleteSessionWarning: 'This action cannot be undone. All messages and data associated with this session will be permanently deleted.', failedToDeleteSession: 'Failed to delete session', sessionDeleted: 'Session deleted successfully', - + manageSharing: 'Manage Sharing', + manageSharingSubtitle: 'Share this session with friends or create a public link', + }, components: { @@ -840,6 +900,41 @@ export const en = { cancelRequestConfirm: ({ name }: { name: string }) => `Cancel your friendship request to ${name}?`, denyRequest: 'Deny friendship', nowFriendsWith: ({ name }: { name: string }) => `You are now friends with ${name}`, + searchFriends: 'Search friends', + noFriendsFound: 'No friends found', + }, + + sessionSharing: { + addShare: 'Add Share', + publicLink: 'Public Link', + publicLinkDescription: 'Create a public link that anyone can use to access this session. You can set an expiration date and usage limit.', + expiresIn: 'Expires in', + days7: '7 days', + days30: '30 days', + never: 'Never', + maxUses: 'Maximum uses', + unlimited: 'Unlimited', + uses10: '10 uses', + uses50: '50 uses', + requireConsent: 'Require consent', + requireConsentDescription: 'Users must accept terms before accessing', + linkToken: 'Link token', + expiresOn: 'Expires on', + usageCount: 'Usage count', + usageCountWithMax: ({ count, max }: { count: number; max: number }) => `${count} / ${max} uses`, + usageCountUnlimited: ({ count }: { count: number }) => `${count} uses`, + directSharing: 'Direct Sharing', + publicLinkActive: 'Public link active', + createPublicLink: 'Create public link', + viewOnlyMode: 'View-only mode', + noEditPermission: 'You don\'t have permission to edit this session', + shareNotFound: 'Share not found', + shareExpired: 'This share link has expired', + failedToDecrypt: 'Failed to decrypt session data', + consentRequired: 'Consent Required', + sharedBy: 'Shared by', + consentDescription: 'This session owner requires your consent before viewing', + acceptAndView: 'Accept and View Session', }, usage: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index e7a4d8cce..d0ec30f4b 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -57,6 +57,9 @@ export const ca: TranslationStructure = { fileViewer: 'Visualitzador de fitxers', loading: 'Carregant...', retry: 'Torna-ho a provar', + share: 'Compartir', + sharing: 'Compartint', + sharedSessions: 'Sessions compartides', }, profile: { @@ -246,6 +249,13 @@ export const ca: TranslationStructure = { failedToRemoveFriend: 'No s\'ha pogut eliminar l\'amic', searchFailed: 'La cerca ha fallat. Si us plau, torna-ho a provar.', failedToSendRequest: 'No s\'ha pogut enviar la sol·licitud d\'amistat', + cannotShareWithSelf: 'No pots compartir amb tu mateix', + canOnlyShareWithFriends: 'Només pots compartir amb amics', + shareNotFound: 'Compartició no trobada', + publicShareNotFound: 'Enllaç públic no trobat o expirat', + consentRequired: 'Es requereix consentiment per a l\'accés', + maxUsesReached: 'S\'ha assolit el màxim d\'usos', + invalidShareLink: 'Enllaç de compartició no vàlid o caducat', }, newSession: { @@ -292,6 +302,54 @@ export const ca: TranslationStructure = { session: { inputPlaceholder: 'Escriu un missatge...', + sharing: { + title: 'Compartir sessió', + shareWith: 'Compartir amb...', + sharedWith: 'Compartit amb', + shareSession: 'Compartir sessió', + stopSharing: 'Deixar de compartir', + accessLevel: 'Nivell d\'accés', + publicLink: 'Enllaç públic', + createPublicLink: 'Crear enllaç públic', + deletePublicLink: 'Eliminar enllaç públic', + copyLink: 'Copiar enllaç', + linkCopied: 'Enllaç copiat!', + viewOnly: 'Només visualització', + canEdit: 'Pot editar', + canManage: 'Pot gestionar', + sharedBy: ({ name }: { name: string }) => `Compartit per ${name}`, + expiresAt: ({ date }: { date: string }) => `Expira: ${date}`, + maxUses: ({ count, used }: { count: number; used: number }) => `${used} / ${count} usos`, + unlimited: 'Il·limitat', + requireConsent: 'Requerir consentiment per al registre d\'accés', + consentRequired: 'Aquest enllaç requereix el teu consentiment per registrar informació d\'accés (adreça IP i user agent)', + giveConsent: 'Dono el meu consentiment per al registre d\'accés', + shareWithFriends: 'Compartir només amb amics', + friendsOnly: 'Només es poden afegir amics', + noShares: 'Encara no hi ha comparticions', + viewOnlyDescription: 'Pot veure missatges i metadades', + canEditDescription: 'Pot enviar missatges però no gestionar la compartició', + canManageDescription: 'Accés complet incloent la gestió de compartició', + shareNotFound: 'Enllaç de compartició no trobat o ha estat revocat', + shareExpired: 'Aquest enllaç de compartició ha caducat', + failedToDecrypt: 'No s\'ha pogut desxifrar la informació de compartició', + consentDescription: 'En acceptar, consents el registre de la teva informació d\'accés', + acceptAndView: 'Acceptar i veure', + days7: '7 dies', + days30: '30 dies', + never: 'Mai caduca', + uses10: '10 usos', + uses50: '50 usos', + maxUsesLabel: 'Usos màxims', + publicLinkDescription: 'Crea un enllaç compartible que qualsevol pot utilitzar per accedir a aquesta sessió', + expiresIn: 'L\'enllaç caduca en', + requireConsentDescription: 'Els usuaris han de donar el seu consentiment abans d\'accedir', + linkToken: 'Token de l\'enllaç', + expiresOn: 'Caduca el', + usageCount: 'Usos', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used} / ${max} usos`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} usos`, + }, }, commandPalette: { @@ -366,7 +424,9 @@ export const ca: TranslationStructure = { deleteSessionWarning: 'Aquesta acció no es pot desfer. Tots els missatges i dades associats amb aquesta sessió s\'eliminaran permanentment.', failedToDeleteSession: 'Error en eliminar la sessió', sessionDeleted: 'Sessió eliminada amb èxit', - + manageSharing: 'Gestiona l\'accés', + manageSharingSubtitle: 'Comparteix aquesta sessió amb amics o crea un enllaç públic', + }, components: { @@ -839,6 +899,41 @@ export const ca: TranslationStructure = { cancelRequestConfirm: ({ name }: { name: string }) => `Cancel·lar la teva sol·licitud d\'amistat a ${name}?`, denyRequest: 'Rebutjar sol·licitud', nowFriendsWith: ({ name }: { name: string }) => `Ara ets amic de ${name}`, + searchFriends: 'Cercar amics', + noFriendsFound: 'No s\'han trobat amics', + }, + + sessionSharing: { + addShare: 'Afegir compartició', + publicLink: 'Enllaç públic', + publicLinkDescription: 'Crea un enllaç públic que qualsevol pot utilitzar per accedir a aquesta sessió. Pots establir una data de caducitat i un límit d\'ús.', + expiresIn: 'Caduca en', + days7: '7 dies', + days30: '30 dies', + never: 'Mai', + maxUses: 'Usos màxims', + unlimited: 'Il·limitat', + uses10: '10 usos', + uses50: '50 usos', + requireConsent: 'Requerir consentiment', + requireConsentDescription: 'Els usuaris han d\'acceptar els termes abans d\'accedir', + linkToken: 'Token de l\'enllaç', + expiresOn: 'Caduca el', + usageCount: 'Quantitat d\'usos', + usageCountWithMax: ({ count, max }: { count: number; max: number }) => `${count} / ${max} usos`, + usageCountUnlimited: ({ count }: { count: number }) => `${count} usos`, + directSharing: 'Compartició directa', + publicLinkActive: 'Enllaç públic actiu', + createPublicLink: 'Crear enllaç públic', + viewOnlyMode: 'Mode de només lectura', + noEditPermission: 'No tens permís per editar aquesta sessió', + shareNotFound: 'Compartició no trobada', + shareExpired: 'Aquest enllaç de compartició ha expirat', + failedToDecrypt: 'No s\'han pogut desxifrar les dades de la sessió', + consentRequired: 'Es requereix consentiment', + sharedBy: 'Compartit per', + consentDescription: 'El propietari de la sessió requereix el teu consentiment abans de visualitzar', + acceptAndView: 'Acceptar i veure la sessió', }, usage: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 03210817e..114de8e95 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -57,6 +57,9 @@ export const es: TranslationStructure = { fileViewer: 'Visor de archivos', loading: 'Cargando...', retry: 'Reintentar', + share: 'Compartir', + sharing: 'Compartiendo', + sharedSessions: 'Sesiones compartidas', }, profile: { @@ -246,6 +249,13 @@ export const es: TranslationStructure = { failedToRemoveFriend: 'No se pudo eliminar al amigo', searchFailed: 'La búsqueda falló. Por favor, intenta de nuevo.', failedToSendRequest: 'No se pudo enviar la solicitud de amistad', + cannotShareWithSelf: 'No puedes compartir contigo mismo', + canOnlyShareWithFriends: 'Solo puedes compartir con amigos', + shareNotFound: 'Compartido no encontrado', + publicShareNotFound: 'Enlace público no encontrado o expirado', + consentRequired: 'Se requiere consentimiento para acceder', + maxUsesReached: 'Se alcanzó el máximo de usos', + invalidShareLink: 'Enlace de compartir inválido o expirado', }, newSession: { @@ -292,6 +302,54 @@ export const es: TranslationStructure = { session: { inputPlaceholder: 'Escriba un mensaje ...', + sharing: { + title: 'Compartir sesión', + shareWith: 'Compartir con...', + sharedWith: 'Compartido con', + shareSession: 'Compartir sesión', + stopSharing: 'Dejar de compartir', + accessLevel: 'Nivel de acceso', + publicLink: 'Enlace público', + createPublicLink: 'Crear enlace público', + deletePublicLink: 'Eliminar enlace público', + copyLink: 'Copiar enlace', + linkCopied: '¡Enlace copiado!', + viewOnly: 'Solo lectura', + canEdit: 'Puede editar', + canManage: 'Puede administrar', + sharedBy: ({ name }: { name: string }) => `Compartido por ${name}`, + expiresAt: ({ date }: { date: string }) => `Expira: ${date}`, + maxUses: ({ count, used }: { count: number; used: number }) => `${used} / ${count} usos`, + unlimited: 'Ilimitado', + requireConsent: 'Requerir consentimiento para registro de acceso', + consentRequired: 'Este enlace requiere tu consentimiento para registrar información de acceso (dirección IP y user agent)', + giveConsent: 'Consiento el registro de acceso', + shareWithFriends: 'Compartir solo con amigos', + friendsOnly: 'Solo se pueden agregar amigos', + noShares: 'Aun no hay compartidos', + viewOnlyDescription: 'Puede ver mensajes y metadatos', + canEditDescription: 'Puede enviar mensajes pero no gestionar el uso compartido', + canManageDescription: 'Acceso completo incluyendo gestion de uso compartido', + shareNotFound: 'Enlace de compartir no encontrado o ha sido revocado', + shareExpired: 'Este enlace de compartir ha expirado', + failedToDecrypt: 'Error al descifrar la informacion de compartir', + consentDescription: 'Al aceptar, consientes el registro de tu informacion de acceso', + acceptAndView: 'Aceptar y ver', + days7: '7 dias', + days30: '30 dias', + never: 'Nunca expira', + uses10: '10 usos', + uses50: '50 usos', + maxUsesLabel: 'Usos maximos', + publicLinkDescription: 'Crea un enlace compartible que cualquiera puede usar para acceder a esta sesion', + expiresIn: 'El enlace expira en', + requireConsentDescription: 'Los usuarios deben dar consentimiento antes de acceder', + linkToken: 'Token del enlace', + expiresOn: 'Expira el', + usageCount: 'Usos', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used} / ${max} usos`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} usos`, + }, }, commandPalette: { @@ -366,7 +424,9 @@ export const es: TranslationStructure = { deleteSessionWarning: 'Esta acción no se puede deshacer. Todos los mensajes y datos asociados con esta sesión se eliminarán permanentemente.', failedToDeleteSession: 'Error al eliminar la sesión', sessionDeleted: 'Sesión eliminada exitosamente', - + manageSharing: 'Gestionar acceso', + manageSharingSubtitle: 'Comparte esta sesión con amigos o crea un enlace público', + }, components: { @@ -840,6 +900,41 @@ export const es: TranslationStructure = { cancelRequestConfirm: ({ name }: { name: string }) => `¿Cancelar tu solicitud de amistad a ${name}?`, denyRequest: 'Rechazar solicitud', nowFriendsWith: ({ name }: { name: string }) => `Ahora eres amigo de ${name}`, + searchFriends: 'Buscar amigos', + noFriendsFound: 'No se encontraron amigos', + }, + + sessionSharing: { + addShare: 'Agregar compartido', + publicLink: 'Enlace público', + publicLinkDescription: 'Crea un enlace público que cualquiera puede usar para acceder a esta sesión. Puedes establecer una fecha de caducidad y un límite de uso.', + expiresIn: 'Expira en', + days7: '7 días', + days30: '30 días', + never: 'Nunca', + maxUses: 'Usos máximos', + unlimited: 'Ilimitado', + uses10: '10 usos', + uses50: '50 usos', + requireConsent: 'Requerir consentimiento', + requireConsentDescription: 'Los usuarios deben aceptar los términos antes de acceder', + linkToken: 'Token del enlace', + expiresOn: 'Expira el', + usageCount: 'Cantidad de usos', + usageCountWithMax: ({ count, max }: { count: number; max: number }) => `${count} / ${max} usos`, + usageCountUnlimited: ({ count }: { count: number }) => `${count} usos`, + directSharing: 'Compartir directo', + publicLinkActive: 'Enlace público activo', + createPublicLink: 'Crear enlace público', + viewOnlyMode: 'Modo de solo lectura', + noEditPermission: 'No tienes permiso para editar esta sesión', + shareNotFound: 'Compartido no encontrado', + shareExpired: 'Este enlace de compartir ha expirado', + failedToDecrypt: 'Error al descifrar los datos de la sesión', + consentRequired: 'Consentimiento requerido', + sharedBy: 'Compartido por', + consentDescription: 'El propietario de la sesión requiere tu consentimiento antes de ver', + acceptAndView: 'Aceptar y ver sesión', }, usage: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index b78a3e579..272635e40 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -57,6 +57,9 @@ export const it: TranslationStructure = { fileViewer: 'Visualizzatore file', loading: 'Caricamento...', retry: 'Riprova', + share: 'Condividi', + sharing: 'Condivisione', + sharedSessions: 'Sessioni condivise', }, profile: { @@ -243,6 +246,13 @@ export const it: TranslationStructure = { failedToRemoveFriend: 'Impossibile rimuovere l\'amico', searchFailed: 'Ricerca non riuscita. Riprova.', failedToSendRequest: 'Impossibile inviare la richiesta di amicizia', + cannotShareWithSelf: 'Non puoi condividere con te stesso', + canOnlyShareWithFriends: 'Puoi condividere solo con amici', + shareNotFound: 'Condivisione non trovata', + publicShareNotFound: 'Link pubblico non trovato o scaduto', + consentRequired: 'Consenso richiesto per l\'accesso', + maxUsesReached: 'Numero massimo di utilizzi raggiunto', + invalidShareLink: 'Link di condivisione non valido o scaduto', }, newSession: { @@ -289,6 +299,54 @@ export const it: TranslationStructure = { session: { inputPlaceholder: 'Scrivi un messaggio ...', + sharing: { + title: 'Condivisione sessione', + shareWith: 'Condividi con...', + sharedWith: 'Condiviso con', + shareSession: 'Condividi sessione', + stopSharing: 'Interrompi condivisione', + accessLevel: 'Livello di accesso', + publicLink: 'Link pubblico', + createPublicLink: 'Crea link pubblico', + deletePublicLink: 'Elimina link pubblico', + copyLink: 'Copia link', + linkCopied: 'Link copiato!', + viewOnly: 'Solo visualizzazione', + canEdit: 'Può modificare', + canManage: 'Può gestire', + sharedBy: ({ name }: { name: string }) => `Condiviso da ${name}`, + expiresAt: ({ date }: { date: string }) => `Scade: ${date}`, + maxUses: ({ count, used }: { count: number; used: number }) => `${used} / ${count} utilizzi`, + unlimited: 'Illimitato', + requireConsent: 'Richiedi consenso per la registrazione degli accessi', + consentRequired: 'Questo link richiede il tuo consenso per registrare le informazioni di accesso (indirizzo IP e user agent)', + giveConsent: 'Acconsento alla registrazione degli accessi', + shareWithFriends: 'Condividi solo con amici', + friendsOnly: 'Solo gli amici possono essere aggiunti', + noShares: 'Ancora nessuna condivisione', + viewOnlyDescription: 'Puo visualizzare messaggi e metadati', + canEditDescription: 'Puo inviare messaggi ma non gestire la condivisione', + canManageDescription: 'Accesso completo inclusa la gestione della condivisione', + shareNotFound: 'Link di condivisione non trovato o revocato', + shareExpired: 'Questo link di condivisione e scaduto', + failedToDecrypt: 'Impossibile decrittare le informazioni di condivisione', + consentDescription: 'Accettando, acconsenti alla registrazione delle tue informazioni di accesso', + acceptAndView: 'Accetta e visualizza', + days7: '7 giorni', + days30: '30 giorni', + never: 'Mai scade', + uses10: '10 utilizzi', + uses50: '50 utilizzi', + maxUsesLabel: 'Utilizzi massimi', + publicLinkDescription: 'Crea un link condivisibile che chiunque puo usare per accedere a questa sessione', + expiresIn: 'Il link scade tra', + requireConsentDescription: 'Gli utenti devono dare il consenso prima di accedere', + linkToken: 'Token del link', + expiresOn: 'Scade il', + usageCount: 'Utilizzi', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used} / ${max} utilizzi`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} utilizzi`, + }, }, commandPalette: { @@ -363,7 +421,9 @@ export const it: TranslationStructure = { deleteSessionWarning: 'Questa azione non può essere annullata. Tutti i messaggi e i dati associati a questa sessione verranno eliminati definitivamente.', failedToDeleteSession: 'Impossibile eliminare la sessione', sessionDeleted: 'Sessione eliminata con successo', - + manageSharing: 'Gestisci condivisione', + manageSharingSubtitle: 'Condividi questa sessione con amici o crea un link pubblico', + }, components: { @@ -832,6 +892,41 @@ export const it: TranslationStructure = { cancelRequestConfirm: ({ name }: { name: string }) => `Annullare la tua richiesta di amicizia a ${name}?`, denyRequest: 'Rifiuta richiesta', nowFriendsWith: ({ name }: { name: string }) => `Ora sei amico di ${name}`, + searchFriends: 'Cerca amici', + noFriendsFound: 'Nessun amico trovato', + }, + + sessionSharing: { + addShare: 'Aggiungi condivisione', + publicLink: 'Link pubblico', + publicLinkDescription: 'Crea un link pubblico che chiunque può utilizzare per accedere a questa sessione. Puoi impostare una data di scadenza e un limite di utilizzo.', + expiresIn: 'Scade tra', + days7: '7 giorni', + days30: '30 giorni', + never: 'Mai', + maxUses: 'Utilizzi massimi', + unlimited: 'Illimitato', + uses10: '10 utilizzi', + uses50: '50 utilizzi', + requireConsent: 'Richiedi consenso', + requireConsentDescription: 'Gli utenti devono accettare i termini prima di accedere', + linkToken: 'Token del link', + expiresOn: 'Scade il', + usageCount: 'Numero di utilizzi', + usageCountWithMax: ({ count, max }: { count: number; max: number }) => `${count} / ${max} utilizzi`, + usageCountUnlimited: ({ count }: { count: number }) => `${count} utilizzi`, + directSharing: 'Condivisione diretta', + publicLinkActive: 'Link pubblico attivo', + createPublicLink: 'Crea link pubblico', + viewOnlyMode: 'Modalità di sola lettura', + noEditPermission: 'Non hai il permesso di modificare questa sessione', + shareNotFound: 'Condivisione non trovata', + shareExpired: 'Questo link di condivisione è scaduto', + failedToDecrypt: 'Impossibile decifrare i dati della sessione', + consentRequired: 'Consenso richiesto', + sharedBy: 'Condiviso da', + consentDescription: 'Il proprietario della sessione richiede il tuo consenso prima della visualizzazione', + acceptAndView: 'Accetta e visualizza sessione', }, usage: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 3dda1dcf8..bda802e21 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -60,6 +60,9 @@ export const ja: TranslationStructure = { fileViewer: 'ファイルビューアー', loading: '読み込み中...', retry: '再試行', + share: '共有', + sharing: '共有中', + sharedSessions: '共有セッション', }, profile: { @@ -246,6 +249,13 @@ export const ja: TranslationStructure = { failedToRemoveFriend: '友達の削除に失敗しました', searchFailed: '検索に失敗しました。再試行してください。', failedToSendRequest: '友達リクエストの送信に失敗しました', + cannotShareWithSelf: '自分自身とは共有できません', + canOnlyShareWithFriends: '友達とのみ共有できます', + shareNotFound: '共有が見つかりません', + publicShareNotFound: '公開共有が見つからないか期限切れです', + consentRequired: 'アクセスには同意が必要です', + maxUsesReached: '最大使用回数に達しました', + invalidShareLink: '無効または期限切れの共有リンク', }, newSession: { @@ -292,6 +302,54 @@ export const ja: TranslationStructure = { session: { inputPlaceholder: 'メッセージを入力...', + sharing: { + title: 'セッション共有', + shareWith: '共有先...', + sharedWith: '共有中', + shareSession: 'セッションを共有', + stopSharing: '共有を停止', + accessLevel: 'アクセスレベル', + publicLink: '公開リンク', + createPublicLink: '公開リンクを作成', + deletePublicLink: '公開リンクを削除', + copyLink: 'リンクをコピー', + linkCopied: 'リンクをコピーしました!', + viewOnly: '閲覧のみ', + canEdit: '編集可能', + canManage: '管理可能', + sharedBy: ({ name }: { name: string }) => `${name}さんが共有`, + expiresAt: ({ date }: { date: string }) => `有効期限: ${date}`, + maxUses: ({ count, used }: { count: number; used: number }) => `${used} / ${count} 回使用`, + unlimited: '無制限', + requireConsent: 'アクセスログの記録に同意を求める', + consentRequired: 'このリンクはアクセス情報(IPアドレスとユーザーエージェント)のログ記録への同意が必要です', + giveConsent: 'アクセスログの記録に同意します', + shareWithFriends: '友達のみと共有', + friendsOnly: '友達のみ追加可能', + noShares: 'まだ共有されていません', + viewOnlyDescription: 'メッセージとメタデータを閲覧可能', + canEditDescription: 'メッセージ送信可能、共有管理は不可', + canManageDescription: '共有管理を含む全てのアクセス権限', + shareNotFound: '共有リンクが見つからないか、取り消されました', + shareExpired: 'この共有リンクは有効期限が切れています', + failedToDecrypt: '共有情報の復号に失敗しました', + consentDescription: '承諾すると、あなたのアクセス情報の記録に同意したことになります', + acceptAndView: '承諾して表示', + days7: '7日間', + days30: '30日間', + never: '無期限', + uses10: '10回使用', + uses50: '50回使用', + maxUsesLabel: '最大使用回数', + publicLinkDescription: 'このセッションにアクセスするための共有リンクを作成します', + expiresIn: 'リンクの有効期限', + requireConsentDescription: 'アクセス前にユーザーの同意が必要です', + linkToken: 'リンクトークン', + expiresOn: '有効期限', + usageCount: '使用回数', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used} / ${max} 回使用`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} 回使用`, + }, }, commandPalette: { @@ -366,6 +424,8 @@ export const ja: TranslationStructure = { deleteSessionWarning: 'この操作は取り消せません。このセッションに関連するすべてのメッセージとデータが完全に削除されます。', failedToDeleteSession: 'セッションの削除に失敗しました', sessionDeleted: 'セッションが正常に削除されました', + manageSharing: '共有を管理', + manageSharingSubtitle: '友達とセッションを共有するか、公開リンクを作成', }, @@ -835,6 +895,41 @@ export const ja: TranslationStructure = { cancelRequestConfirm: ({ name }: { name: string }) => `${name}さんへの友達リクエストをキャンセルしますか?`, denyRequest: '友達リクエストを拒否', nowFriendsWith: ({ name }: { name: string }) => `${name}さんと友達になりました`, + searchFriends: '友達を検索', + noFriendsFound: '友達が見つかりません', + }, + + sessionSharing: { + addShare: '共有を追加', + publicLink: '公開リンク', + publicLinkDescription: 'このセッションにアクセスできる公開リンクを作成します。有効期限と使用回数の制限を設定できます。', + expiresIn: '有効期限', + days7: '7日間', + days30: '30日間', + never: '無期限', + maxUses: '最大使用回数', + unlimited: '無制限', + uses10: '10回', + uses50: '50回', + requireConsent: '同意を要求', + requireConsentDescription: 'アクセス前にユーザーは利用規約に同意する必要があります', + linkToken: 'リンクトークン', + expiresOn: '有効期限', + usageCount: '使用回数', + usageCountWithMax: ({ count, max }: { count: number; max: number }) => `${count} / ${max} 回`, + usageCountUnlimited: ({ count }: { count: number }) => `${count} 回`, + directSharing: '直接共有', + publicLinkActive: '公開リンク有効', + createPublicLink: '公開リンクを作成', + viewOnlyMode: '閲覧専用モード', + noEditPermission: 'このセッションを編集する権限がありません', + shareNotFound: '共有が見つかりません', + shareExpired: 'この共有リンクは有効期限が切れています', + failedToDecrypt: 'セッションデータの復号に失敗しました', + consentRequired: '同意が必要です', + sharedBy: '共有者', + consentDescription: 'セッションの所有者は閲覧前に同意を求めています', + acceptAndView: '同意してセッションを表示', }, usage: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index cc7b1fae0..fac79bb45 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -68,6 +68,9 @@ export const pl: TranslationStructure = { fileViewer: 'Przeglądarka plików', loading: 'Ładowanie...', retry: 'Ponów', + share: 'Udostępnij', + sharing: 'Udostępnianie', + sharedSessions: 'Udostępnione sesje', }, profile: { @@ -257,6 +260,13 @@ export const pl: TranslationStructure = { failedToRemoveFriend: 'Nie udało się usunąć przyjaciela', searchFailed: 'Wyszukiwanie nie powiodło się. Spróbuj ponownie.', failedToSendRequest: 'Nie udało się wysłać zaproszenia do znajomych', + cannotShareWithSelf: 'Nie możesz udostępnić sobie', + canOnlyShareWithFriends: 'Można udostępniać tylko znajomym', + shareNotFound: 'Udostępnienie nie zostało znalezione', + publicShareNotFound: 'Publiczne udostępnienie nie zostało znalezione lub wygasło', + consentRequired: 'Wymagana zgoda na dostęp', + maxUsesReached: 'Osiągnięto maksymalną liczbę użyć', + invalidShareLink: 'Nieprawidłowy lub wygasły link do udostępnienia', }, newSession: { @@ -303,6 +313,54 @@ export const pl: TranslationStructure = { session: { inputPlaceholder: 'Wpisz wiadomość...', + sharing: { + title: 'Udostępnianie sesji', + shareWith: 'Udostępnij...', + sharedWith: 'Udostępniono', + shareSession: 'Udostępnij sesję', + stopSharing: 'Zatrzymaj udostępnianie', + accessLevel: 'Poziom dostępu', + publicLink: 'Link publiczny', + createPublicLink: 'Utwórz link publiczny', + deletePublicLink: 'Usuń link publiczny', + copyLink: 'Kopiuj link', + linkCopied: 'Link skopiowany!', + viewOnly: 'Tylko podgląd', + canEdit: 'Może edytować', + canManage: 'Może zarządzać', + sharedBy: ({ name }: { name: string }) => `Udostępnione przez ${name}`, + expiresAt: ({ date }: { date: string }) => `Wygasa: ${date}`, + maxUses: ({ count, used }: { count: number; used: number }) => `${used} / ${count} użyć`, + unlimited: 'Bez limitu', + requireConsent: 'Wymagaj zgody na logowanie dostępu', + consentRequired: 'Ten link wymaga Twojej zgody na rejestrowanie informacji o dostępie (adres IP i user agent)', + giveConsent: 'Wyrażam zgodę na logowanie dostępu', + shareWithFriends: 'Udostępnij tylko znajomym', + friendsOnly: 'Można dodać tylko znajomych', + noShares: 'Brak udostępnień', + viewOnlyDescription: 'Może przeglądać wiadomości i metadane', + canEditDescription: 'Może wysyłać wiadomości, ale nie zarządzać udostępnianiem', + canManageDescription: 'Pełny dostęp, w tym zarządzanie udostępnianiem', + shareNotFound: 'Link udostępniania nie został znaleziony lub został cofnięty', + shareExpired: 'Ten link udostępniania wygasł', + failedToDecrypt: 'Nie udało się odszyfrować informacji o udostępnieniu', + consentDescription: 'Akceptując, wyrażasz zgodę na rejestrowanie informacji o Twoim dostępie', + acceptAndView: 'Zaakceptuj i wyświetl', + days7: '7 dni', + days30: '30 dni', + never: 'Nigdy nie wygasa', + uses10: '10 użyć', + uses50: '50 użyć', + maxUsesLabel: 'Maksymalna liczba użyć', + publicLinkDescription: 'Utwórz link, który każdy może użyć, aby uzyskać dostęp do tej sesji', + expiresIn: 'Link wygasa za', + requireConsentDescription: 'Użytkownicy muszą wyrazić zgodę przed uzyskaniem dostępu', + linkToken: 'Token linku', + expiresOn: 'Wygasa', + usageCount: 'Użycia', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used} / ${max} użyć`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} użyć`, + }, }, commandPalette: { @@ -377,6 +435,8 @@ export const pl: TranslationStructure = { deleteSessionWarning: 'Ta operacja jest nieodwracalna. Wszystkie wiadomości i dane powiązane z tą sesją zostaną trwale usunięte.', failedToDeleteSession: 'Nie udało się usunąć sesji', sessionDeleted: 'Sesja została pomyślnie usunięta', + manageSharing: 'Zarządzanie udostępnianiem', + manageSharingSubtitle: 'Udostępnij tę sesję znajomym lub utwórz publiczny link', }, components: { @@ -863,6 +923,41 @@ export const pl: TranslationStructure = { cancelRequestConfirm: ({ name }: { name: string }) => `Anulować zaproszenie do znajomych wysłane do ${name}?`, denyRequest: 'Odrzuć zaproszenie', nowFriendsWith: ({ name }: { name: string }) => `Teraz jesteś w gronie znajomych z ${name}`, + searchFriends: 'Szukaj znajomych', + noFriendsFound: 'Nie znaleziono znajomych', + }, + + sessionSharing: { + addShare: 'Dodaj udostępnienie', + publicLink: 'Link publiczny', + publicLinkDescription: 'Utwórz publiczny link, za pomocą którego każdy może uzyskać dostęp do tej sesji. Możesz ustawić datę wygaśnięcia i limit użyć.', + expiresIn: 'Wygasa za', + days7: '7 dni', + days30: '30 dni', + never: 'Nigdy', + maxUses: 'Maksymalna liczba użyć', + unlimited: 'Bez limitu', + uses10: '10 użyć', + uses50: '50 użyć', + requireConsent: 'Wymagaj zgody', + requireConsentDescription: 'Użytkownicy muszą zaakceptować warunki przed uzyskaniem dostępu', + linkToken: 'Token linku', + expiresOn: 'Wygasa', + usageCount: 'Liczba użyć', + usageCountWithMax: ({ count, max }: { count: number; max: number }) => `${count} / ${max} użyć`, + usageCountUnlimited: ({ count }: { count: number }) => `${count} użyć`, + directSharing: 'Bezpośrednie udostępnianie', + publicLinkActive: 'Link publiczny aktywny', + createPublicLink: 'Utwórz link publiczny', + viewOnlyMode: 'Tryb tylko do odczytu', + noEditPermission: 'Nie masz uprawnień do edycji tej sesji', + shareNotFound: 'Udostępnienie nie zostało znalezione', + shareExpired: 'Ten link udostępniania wygasł', + failedToDecrypt: 'Nie udało się odszyfrować danych sesji', + consentRequired: 'Wymagana zgoda', + sharedBy: 'Udostępnione przez', + consentDescription: 'Właściciel sesji wymaga Twojej zgody przed przeglądaniem', + acceptAndView: 'Zaakceptuj i wyświetl sesję', }, usage: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 63c417026..8ee8bdfa3 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -57,6 +57,9 @@ export const pt: TranslationStructure = { fileViewer: 'Visualizador de arquivos', loading: 'Carregando...', retry: 'Tentar novamente', + share: 'Compartilhar', + sharing: 'Compartilhando', + sharedSessions: 'Sessões compartilhadas', }, profile: { @@ -246,6 +249,13 @@ export const pt: TranslationStructure = { failedToRemoveFriend: 'Falha ao remover amigo', searchFailed: 'A busca falhou. Por favor, tente novamente.', failedToSendRequest: 'Falha ao enviar solicitação de amizade', + cannotShareWithSelf: 'Não é possível compartilhar consigo mesmo', + canOnlyShareWithFriends: 'Só é possível compartilhar com amigos', + shareNotFound: 'Compartilhamento não encontrado', + publicShareNotFound: 'Link público não encontrado ou expirado', + consentRequired: 'Consentimento necessário para acesso', + maxUsesReached: 'Máximo de usos atingido', + invalidShareLink: 'Link de compartilhamento inválido ou expirado', }, newSession: { @@ -292,6 +302,54 @@ export const pt: TranslationStructure = { session: { inputPlaceholder: 'Digite uma mensagem ...', + sharing: { + title: 'Compartilhamento de sessão', + shareWith: 'Compartilhar com...', + sharedWith: 'Compartilhado com', + shareSession: 'Compartilhar sessão', + stopSharing: 'Parar de compartilhar', + accessLevel: 'Nível de acesso', + publicLink: 'Link público', + createPublicLink: 'Criar link público', + deletePublicLink: 'Excluir link público', + copyLink: 'Copiar link', + linkCopied: 'Link copiado!', + viewOnly: 'Somente visualização', + canEdit: 'Pode editar', + canManage: 'Pode gerenciar', + sharedBy: ({ name }: { name: string }) => `Compartilhado por ${name}`, + expiresAt: ({ date }: { date: string }) => `Expira em: ${date}`, + maxUses: ({ count, used }: { count: number; used: number }) => `${used} / ${count} usos`, + unlimited: 'Ilimitado', + requireConsent: 'Exigir consentimento para registro de acesso', + consentRequired: 'Este link requer seu consentimento para registrar informações de acesso (endereço IP e user agent)', + giveConsent: 'Eu consinto com o registro de acesso', + shareWithFriends: 'Compartilhar apenas com amigos', + friendsOnly: 'Apenas amigos podem ser adicionados', + noShares: 'Ainda nao ha compartilhamentos', + viewOnlyDescription: 'Pode visualizar mensagens e metadados', + canEditDescription: 'Pode enviar mensagens, mas nao gerenciar compartilhamento', + canManageDescription: 'Acesso completo incluindo gerenciamento de compartilhamento', + shareNotFound: 'Link de compartilhamento nao encontrado ou foi revogado', + shareExpired: 'Este link de compartilhamento expirou', + failedToDecrypt: 'Falha ao descriptografar informacoes de compartilhamento', + consentDescription: 'Ao aceitar, voce consente com o registro de suas informacoes de acesso', + acceptAndView: 'Aceitar e visualizar', + days7: '7 dias', + days30: '30 dias', + never: 'Nunca expira', + uses10: '10 usos', + uses50: '50 usos', + maxUsesLabel: 'Usos maximos', + publicLinkDescription: 'Crie um link compartilhavel que qualquer pessoa pode usar para acessar esta sessao', + expiresIn: 'O link expira em', + requireConsentDescription: 'Os usuarios devem consentir antes de acessar', + linkToken: 'Token do link', + expiresOn: 'Expira em', + usageCount: 'Usos', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used} / ${max} usos`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} usos`, + }, }, commandPalette: { @@ -366,7 +424,9 @@ export const pt: TranslationStructure = { deleteSessionWarning: 'Esta ação não pode ser desfeita. Todas as mensagens e dados associados a esta sessão serão excluídos permanentemente.', failedToDeleteSession: 'Falha ao excluir sessão', sessionDeleted: 'Sessão excluída com sucesso', - + manageSharing: 'Gerenciar compartilhamento', + manageSharingSubtitle: 'Compartilhe esta sessão com amigos ou crie um link público', + }, components: { @@ -839,6 +899,41 @@ export const pt: TranslationStructure = { cancelRequestConfirm: ({ name }: { name: string }) => `Cancelar sua solicitação de amizade para ${name}?`, denyRequest: 'Recusar solicitação', nowFriendsWith: ({ name }: { name: string }) => `Agora você é amigo de ${name}`, + searchFriends: 'Buscar amigos', + noFriendsFound: 'Nenhum amigo encontrado', + }, + + sessionSharing: { + addShare: 'Adicionar compartilhamento', + publicLink: 'Link público', + publicLinkDescription: 'Crie um link público que qualquer pessoa pode usar para acessar esta sessão. Você pode definir uma data de expiração e um limite de uso.', + expiresIn: 'Expira em', + days7: '7 dias', + days30: '30 dias', + never: 'Nunca', + maxUses: 'Usos máximos', + unlimited: 'Ilimitado', + uses10: '10 usos', + uses50: '50 usos', + requireConsent: 'Requer consentimento', + requireConsentDescription: 'Os usuários devem aceitar os termos antes de acessar', + linkToken: 'Token do link', + expiresOn: 'Expira em', + usageCount: 'Quantidade de usos', + usageCountWithMax: ({ count, max }: { count: number; max: number }) => `${count} / ${max} usos`, + usageCountUnlimited: ({ count }: { count: number }) => `${count} usos`, + directSharing: 'Compartilhamento direto', + publicLinkActive: 'Link público ativo', + createPublicLink: 'Criar link público', + viewOnlyMode: 'Modo somente leitura', + noEditPermission: 'Você não tem permissão para editar esta sessão', + shareNotFound: 'Compartilhamento não encontrado', + shareExpired: 'Este link de compartilhamento expirou', + failedToDecrypt: 'Falha ao descriptografar os dados da sessão', + consentRequired: 'Consentimento necessário', + sharedBy: 'Compartilhado por', + consentDescription: 'O proprietário da sessão requer seu consentimento antes de visualizar', + acceptAndView: 'Aceitar e visualizar sessão', }, usage: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index a6ec750be..a64a124ca 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -68,6 +68,9 @@ export const ru: TranslationStructure = { fileViewer: 'Просмотр файла', loading: 'Загрузка...', retry: 'Повторить', + share: 'Поделиться', + sharing: 'Общий доступ', + sharedSessions: 'Общие сессии', }, connect: { @@ -229,6 +232,13 @@ export const ru: TranslationStructure = { failedToRemoveFriend: 'Не удалось удалить друга', searchFailed: 'Поиск не удался. Пожалуйста, попробуйте снова.', failedToSendRequest: 'Не удалось отправить запрос в друзья', + cannotShareWithSelf: 'Нельзя поделиться с самим собой', + canOnlyShareWithFriends: 'Можно делиться только с друзьями', + shareNotFound: 'Общий доступ не найден', + publicShareNotFound: 'Публичная ссылка не найдена или истекла', + consentRequired: 'Требуется согласие для доступа', + maxUsesReached: 'Достигнут лимит использований', + invalidShareLink: 'Недействительная или просроченная ссылка для обмена', }, newSession: { @@ -341,6 +351,8 @@ export const ru: TranslationStructure = { deleteSessionWarning: 'Это действие нельзя отменить. Все сообщения и данные, связанные с этой сессией, будут удалены навсегда.', failedToDeleteSession: 'Не удалось удалить сессию', sessionDeleted: 'Сессия успешно удалена', + manageSharing: 'Управление доступом', + manageSharingSubtitle: 'Поделиться сессией с друзьями или создать публичную ссылку', }, components: { @@ -384,6 +396,54 @@ export const ru: TranslationStructure = { session: { inputPlaceholder: 'Введите сообщение...', + sharing: { + title: 'Общий доступ к сессии', + shareWith: 'Поделиться с...', + sharedWith: 'Доступ предоставлен', + shareSession: 'Поделиться сессией', + stopSharing: 'Прекратить доступ', + accessLevel: 'Уровень доступа', + publicLink: 'Публичная ссылка', + createPublicLink: 'Создать публичную ссылку', + deletePublicLink: 'Удалить публичную ссылку', + copyLink: 'Скопировать ссылку', + linkCopied: 'Ссылка скопирована!', + viewOnly: 'Только просмотр', + canEdit: 'Редактирование', + canManage: 'Управление', + sharedBy: ({ name }: { name: string }) => `Поделился ${name}`, + expiresAt: ({ date }: { date: string }) => `Истекает: ${date}`, + maxUses: ({ count, used }: { count: number; used: number }) => `${used} / ${count} использований`, + unlimited: 'Без ограничений', + requireConsent: 'Требовать согласие на логирование доступа', + consentRequired: 'Эта ссылка требует вашего согласия на запись информации о доступе (IP-адрес и user agent)', + giveConsent: 'Я согласен на логирование доступа', + shareWithFriends: 'Поделиться только с друзьями', + friendsOnly: 'Можно добавить только друзей', + noShares: 'Пока нет общего доступа', + viewOnlyDescription: 'Может просматривать сообщения и метаданные', + canEditDescription: 'Может отправлять сообщения, но не управлять доступом', + canManageDescription: 'Полный доступ, включая управление общим доступом', + shareNotFound: 'Ссылка не найдена или была отозвана', + shareExpired: 'Срок действия этой ссылки истёк', + failedToDecrypt: 'Не удалось расшифровать данные доступа', + consentDescription: 'Принимая, вы соглашаетесь на запись информации о вашем доступе', + acceptAndView: 'Принять и просмотреть', + days7: '7 дней', + days30: '30 дней', + never: 'Без срока', + uses10: '10 использований', + uses50: '50 использований', + maxUsesLabel: 'Максимум использований', + publicLinkDescription: 'Создайте ссылку, по которой любой сможет получить доступ к этой сессии', + expiresIn: 'Истекает через', + requireConsentDescription: 'Пользователи должны дать согласие перед доступом', + linkToken: 'Токен ссылки', + expiresOn: 'Дата истечения', + usageCount: 'Использования', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used} / ${max} использований`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} использований`, + }, }, commandPalette: { @@ -862,6 +922,41 @@ export const ru: TranslationStructure = { cancelRequestConfirm: ({ name }: { name: string }) => `Отменить ваш запрос в друзья к ${name}?`, denyRequest: 'Отклонить запрос', nowFriendsWith: ({ name }: { name: string }) => `Теперь вы друзья с ${name}`, + searchFriends: 'Поиск друзей', + noFriendsFound: 'Друзья не найдены', + }, + + sessionSharing: { + addShare: 'Добавить доступ', + publicLink: 'Публичная ссылка', + publicLinkDescription: 'Создайте публичную ссылку, по которой любой сможет получить доступ к этой сессии. Вы можете установить срок действия и лимит использования.', + expiresIn: 'Истекает через', + days7: '7 дней', + days30: '30 дней', + never: 'Никогда', + maxUses: 'Максимум использований', + unlimited: 'Без ограничений', + uses10: '10 использований', + uses50: '50 использований', + requireConsent: 'Требовать согласие', + requireConsentDescription: 'Пользователи должны принять условия перед доступом', + linkToken: 'Токен ссылки', + expiresOn: 'Истекает', + usageCount: 'Количество использований', + usageCountWithMax: ({ count, max }: { count: number; max: number }) => `${count} / ${max} использований`, + usageCountUnlimited: ({ count }: { count: number }) => `${count} использований`, + directSharing: 'Прямой доступ', + publicLinkActive: 'Публичная ссылка активна', + createPublicLink: 'Создать публичную ссылку', + viewOnlyMode: 'Режим просмотра', + noEditPermission: 'У вас нет прав на редактирование этой сессии', + shareNotFound: 'Доступ не найден', + shareExpired: 'Срок действия ссылки истёк', + failedToDecrypt: 'Не удалось расшифровать данные сессии', + consentRequired: 'Требуется согласие', + sharedBy: 'Поделился', + consentDescription: 'Владелец сессии требует вашего согласия перед просмотром', + acceptAndView: 'Принять и просмотреть сессию', }, usage: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 0fa65e9b4..fa58bb7e9 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -59,6 +59,9 @@ export const zhHans: TranslationStructure = { fileViewer: '文件查看器', loading: '加载中...', retry: '重试', + share: '分享', + sharing: '分享中', + sharedSessions: '共享会话', }, profile: { @@ -248,6 +251,13 @@ export const zhHans: TranslationStructure = { failedToRemoveFriend: '删除好友失败', searchFailed: '搜索失败。请重试。', failedToSendRequest: '发送好友请求失败', + cannotShareWithSelf: '不能与自己分享', + canOnlyShareWithFriends: '只能与好友分享', + shareNotFound: '未找到分享', + publicShareNotFound: '公开分享未找到或已过期', + consentRequired: '需要同意才能访问', + maxUsesReached: '已达到最大使用次数', + invalidShareLink: '无效或已过期的共享链接', }, newSession: { @@ -294,6 +304,54 @@ export const zhHans: TranslationStructure = { session: { inputPlaceholder: '输入消息...', + sharing: { + title: '会话共享', + shareWith: '分享给...', + sharedWith: '已分享给', + shareSession: '分享会话', + stopSharing: '停止分享', + accessLevel: '访问级别', + publicLink: '公开链接', + createPublicLink: '创建公开链接', + deletePublicLink: '删除公开链接', + copyLink: '复制链接', + linkCopied: '链接已复制!', + viewOnly: '仅查看', + canEdit: '可编辑', + canManage: '可管理', + sharedBy: ({ name }: { name: string }) => `由 ${name} 分享`, + expiresAt: ({ date }: { date: string }) => `过期时间:${date}`, + maxUses: ({ count, used }: { count: number; used: number }) => `${used} / ${count} 次使用`, + unlimited: '无限制', + requireConsent: '需要同意访问日志记录', + consentRequired: '此链接需要您同意记录访问信息(IP 地址和用户代理)', + giveConsent: '我同意访问日志记录', + shareWithFriends: '仅与好友分享', + friendsOnly: '只能添加好友', + noShares: '暂无分享', + viewOnlyDescription: '可以查看消息和元数据', + canEditDescription: '可以发送消息,但不能管理分享', + canManageDescription: '包括分享管理在内的完全访问权限', + shareNotFound: '分享链接未找到或已被撤销', + shareExpired: '此分享链接已过期', + failedToDecrypt: '无法解密分享信息', + consentDescription: '接受即表示您同意记录您的访问信息', + acceptAndView: '接受并查看', + days7: '7 天', + days30: '30 天', + never: '永不过期', + uses10: '10 次使用', + uses50: '50 次使用', + maxUsesLabel: '最大使用次数', + publicLinkDescription: '创建一个任何人都可以用来访问此会话的可分享链接', + expiresIn: '链接过期时间', + requireConsentDescription: '用户在访问前必须同意', + linkToken: '链接令牌', + expiresOn: '过期日期', + usageCount: '使用次数', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used} / ${max} 次使用`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} 次使用`, + }, }, commandPalette: { @@ -368,7 +426,9 @@ export const zhHans: TranslationStructure = { deleteSessionWarning: '此操作无法撤销。与此会话相关的所有消息和数据将被永久删除。', failedToDeleteSession: '删除会话失败', sessionDeleted: '会话删除成功', - + manageSharing: '管理共享', + manageSharingSubtitle: '与好友共享此会话或创建公开链接', + }, components: { @@ -841,6 +901,41 @@ export const zhHans: TranslationStructure = { cancelRequestConfirm: ({ name }: { name: string }) => `取消发送给 ${name} 的好友请求?`, denyRequest: '拒绝请求', nowFriendsWith: ({ name }: { name: string }) => `您现在与 ${name} 是好友了`, + searchFriends: '搜索好友', + noFriendsFound: '未找到好友', + }, + + sessionSharing: { + addShare: '添加分享', + publicLink: '公开链接', + publicLinkDescription: '创建一个公开链接,任何人都可以用它来访问此会话。您可以设置过期日期和使用次数限制。', + expiresIn: '过期时间', + days7: '7 天', + days30: '30 天', + never: '永不过期', + maxUses: '最大使用次数', + unlimited: '不限', + uses10: '10 次', + uses50: '50 次', + requireConsent: '需要同意', + requireConsentDescription: '用户必须在访问前接受条款', + linkToken: '链接令牌', + expiresOn: '过期日期', + usageCount: '使用次数', + usageCountWithMax: ({ count, max }: { count: number; max: number }) => `${count} / ${max} 次`, + usageCountUnlimited: ({ count }: { count: number }) => `${count} 次`, + directSharing: '直接分享', + publicLinkActive: '公开链接已激活', + createPublicLink: '创建公开链接', + viewOnlyMode: '仅查看模式', + noEditPermission: '您没有编辑此会话的权限', + shareNotFound: '未找到分享', + shareExpired: '此分享链接已过期', + failedToDecrypt: '解密会话数据失败', + consentRequired: '需要同意', + sharedBy: '分享者', + consentDescription: '会话所有者要求您在查看前同意', + acceptAndView: '同意并查看会话', }, usage: {