Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
d9aab32
feat: Add session sharing translations for all languages
54m Jan 9, 2026
ab6ac02
feat: Add session sharing types and API client
54m Jan 9, 2026
f621710
add: Add session share management dialog component
54m Jan 9, 2026
f983fda
feat: Add session sharing translations for all languages
54m Jan 9, 2026
8321516
add: Add friend selector for session sharing
54m Jan 9, 2026
312b761
feat: Add friend selector translations
54m Jan 9, 2026
adbcb34
add: Add public link management dialog with QR code
54m Jan 9, 2026
a40e356
feat: Add public link management translations
54m Jan 9, 2026
91950b2
refactor: Remove client-side encryption from share API
54m Jan 9, 2026
1c8e29c
feat: Add publicKey to UserProfile and sync methods
54m Jan 9, 2026
1bfd2f6
feat: Add session sharing translations
54m Jan 9, 2026
13f7117
feat: Add manage sharing button to session info
54m Jan 9, 2026
00f0209
feat: Add shared session fetching and decryption
54m Jan 9, 2026
624686d
feat: Implement client-side public share encryption
54m Jan 9, 2026
4542f76
feat: Add owner profile and access level to Session type
54m Jan 9, 2026
48f5ca4
feat: Add session sharing permission translations
54m Jan 9, 2026
49bba47
feat: Display shared session indicators in session list
54m Jan 9, 2026
5c944a5
feat: Add disabled prop to AgentInput component
54m Jan 9, 2026
9167de1
feat: Enforce access level permissions in session view
54m Jan 9, 2026
65140e9
feat: Restrict sharing management to admin users only
54m Jan 9, 2026
a3016e6
feat: Add public share key storage in sync manager
54m Jan 10, 2026
f422d37
feat: Add public share access translations
54m Jan 10, 2026
43dedc1
feat: Implement public share access screen
54m Jan 10, 2026
2a4a04c
update: Prioritize username over firstName for display
54m Jan 10, 2026
2446302
fix: Remove CustomModal from sharing components
54m Jan 10, 2026
8d401b8
fix: Replace non-existent theme properties in sharing components
54m Jan 10, 2026
1b1f6ca
add: Add missing translation keys for session sharing
54m Jan 10, 2026
22992de
fix: Update sharing screen implementations
54m Jan 10, 2026
9b973d8
add: Add session sharing translations for all languages
54m Jan 10, 2026
64625d0
add: Complete session.sharing translations for all languages
54m Jan 10, 2026
03e57ff
fix: Correct translation function parameters
54m Jan 10, 2026
e8f5ba4
refactor: Use `getServerUrl` directly in sharing screen
54m Jan 10, 2026
5be0cc6
refactor: delete unnecessary things
54m Jan 10, 2026
97510cd
refactor: Simplify PublicLinkDialog layout and styling
54m Jan 10, 2026
3f4cf95
refactor: Replace icons with Ionicons components
54m Jan 10, 2026
40ac3e2
feat: Add session sharing event schemas
54m Jan 10, 2026
6c10d01
update: Improve public link creation button
54m Jan 10, 2026
83b268f
delete: unnecessary files
54m Jan 10, 2026
a7a2a6f
update: add .idea
54m Jan 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,7 @@ yarn-error.*

CLAUDE.local.md

.dev/worktree/*
# IDE
.idea

.dev/worktree/*
11 changes: 10 additions & 1 deletion sources/-session/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<AgentInput
placeholder={t('session.inputPlaceholder')}
placeholder={isReadOnly ? t('sessionSharing.viewOnlyMode') : t('session.inputPlaceholder')}
value={message}
onChangeText={setMessage}
sessionId={sessionId}
Expand All @@ -280,6 +284,10 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session:
isPulsing: sessionStatus.isPulsing
}}
onSend={() => {
if (!hasWriteAccess) {
Modal.alert(t('common.error'), t('sessionSharing.noEditPermission'));
return;
}
if (message.trim()) {
setMessage('');
clearDraft();
Expand All @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion sources/app/(app)/session/[id]/info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -257,6 +260,14 @@ function SessionInfoContent({ session }: { session: Session }) {
onPress={() => router.push(`/machine/${session.metadata?.machineId}`)}
/>
)}
{canManageSharing && (
<Item
title={t('sessionInfo.manageSharing')}
subtitle={t('sessionInfo.manageSharingSubtitle')}
icon={<Ionicons name="share-outline" size={29} color="#007AFF" />}
onPress={() => router.push(`/session/${session.id}/sharing`)}
/>
)}
{sessionStatus.isConnected && (
<Item
title={t('sessionInfo.archiveSession')}
Expand Down
306 changes: 306 additions & 0 deletions sources/app/(app)/session/[id]/sharing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import React, { memo, useState, useCallback, useEffect } from 'react';
import { View, Text } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Item } from '@/components/Item';
import { ItemGroup } from '@/components/ItemGroup';
import { ItemList } from '@/components/ItemList';
import { useSession, useIsDataReady } from '@/sync/storage';
import { useUnistyles } from 'react-native-unistyles';
import { t } from '@/text';
import { Typography } from '@/constants/Typography';
import { SessionShareDialog } from '@/components/SessionSharing/SessionShareDialog';
import { FriendSelector } from '@/components/SessionSharing/FriendSelector';
import { PublicLinkDialog } from '@/components/SessionSharing/PublicLinkDialog';
import { SessionShare, PublicSessionShare, ShareAccessLevel } from '@/sync/sharingTypes';
import {
getSessionShares,
createSessionShare,
updateSessionShare,
deleteSessionShare,
getPublicShare,
createPublicShare,
deletePublicShare
} from '@/sync/apiSharing';
import { sync } from '@/sync/sync';
import { useHappyAction } from '@/hooks/useHappyAction';
import { HappyError } from '@/utils/errors';
import { getFriendsList } from '@/sync/apiFriends';
import { UserProfile } from '@/sync/friendTypes';
import { encryptDataKeyForPublicShare } from '@/sync/publicShareEncryption';
import { getRandomBytes } from 'expo-crypto';

function SharingManagementContent({ sessionId }: { sessionId: string }) {
const { theme } = useUnistyles();
const router = useRouter();
const session = useSession(sessionId);

const [shares, setShares] = useState<SessionShare[]>([]);
const [publicShare, setPublicShare] = useState<PublicSessionShare | null>(null);
const [friends, setFriends] = useState<UserProfile[]>([]);

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 (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Ionicons name="trash-outline" size={48} color={theme.colors.textSecondary} />
<Text style={{
color: theme.colors.text,
fontSize: 20,
marginTop: 16,
...Typography.default('semiBold')
}}>
{t('errors.sessionDeleted')}
</Text>
</View>
);
}

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 (
<>
<ItemList>
{/* Current Shares */}
<ItemGroup title={t('sessionSharing.directSharing')}>
{shares.length > 0 ? (
shares.map(share => (
<Item
key={share.id}
title={share.sharedWithUser.username || [share.sharedWithUser.firstName, share.sharedWithUser.lastName].filter(Boolean).join(' ')}
subtitle={`@${share.sharedWithUser.username} • ${t(`session.sharing.${share.accessLevel === 'view' ? 'viewOnly' : share.accessLevel === 'edit' ? 'canEdit' : 'canManage'}`)}`}
icon={<Ionicons name="person-outline" size={29} color="#007AFF" />}
onPress={() => setShowShareDialog(true)}
/>
))
) : (
<Item
title={t('session.sharing.noShares')}
icon={<Ionicons name="people-outline" size={29} color="#8E8E93" />}
showChevron={false}
/>
)}
{canManage && (
<Item
title={t('sessionSharing.addShare')}
icon={<Ionicons name="person-add-outline" size={29} color="#34C759" />}
onPress={() => setShowFriendSelector(true)}
/>
)}
</ItemGroup>

{/* Public Link */}
<ItemGroup title={t('sessionSharing.publicLink')}>
{publicShare ? (
<Item
title={t('sessionSharing.publicLinkActive')}
subtitle={publicShare.expiresAt
? t('sessionSharing.expiresOn') + ': ' + new Date(publicShare.expiresAt).toLocaleDateString()
: t('sessionSharing.never')
}
icon={<Ionicons name="link-outline" size={29} color="#34C759" />}
onPress={() => setShowPublicLinkDialog(true)}
/>
) : (
<Item
title={t('sessionSharing.createPublicLink')}
subtitle={t('sessionSharing.publicLinkDescription')}
icon={<Ionicons name="link-outline" size={29} color="#007AFF" />}
onPress={() => setShowPublicLinkDialog(true)}
/>
)}
</ItemGroup>
</ItemList>

{/* Dialogs */}
{showShareDialog && (
<SessionShareDialog
sessionId={sessionId}
shares={shares}
canManage={canManage}
onAddShare={() => {
setShowShareDialog(false);
setShowFriendSelector(true);
}}
onUpdateShare={handleUpdateShare}
onRemoveShare={handleRemoveShare}
onManagePublicLink={() => {
setShowShareDialog(false);
setShowPublicLinkDialog(true);
}}
onClose={() => setShowShareDialog(false)}
/>
)}

{showFriendSelector && (
<FriendSelector
friends={friends}
excludedUserIds={excludedUserIds}
onSelect={handleAddShare}
/>
)}

{showPublicLinkDialog && (
<PublicLinkDialog
publicShare={publicShare}
onCreate={handleCreatePublicShare}
onDelete={handleDeletePublicShare}
onCancel={() => setShowPublicLinkDialog(false)}
/>
)}
</>
);
}

export default memo(() => {
const { theme } = useUnistyles();
const { id } = useLocalSearchParams<{ id: string }>();
const isDataReady = useIsDataReady();

if (!isDataReady) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Ionicons name="hourglass-outline" size={48} color={theme.colors.textSecondary} />
<Text style={{
color: theme.colors.textSecondary,
fontSize: 17,
marginTop: 16,
...Typography.default('semiBold')
}}>
{t('common.loading')}
</Text>
</View>
);
}

return <SharingManagementContent sessionId={id} />;
});
Loading