From d9aab321f63535fbb2da0b4253b9397f4e14dc45 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:18:24 +0900 Subject: [PATCH 01/39] feat: Add session sharing translations for all languages Add comprehensive translations for session sharing feature including: - Session sharing UI labels and actions - Access level descriptions (view, edit, manage) - Public link management - Consent and access logging - Error messages for sharing operations Translations added for all 9 supported languages: - English, Japanese, Russian, Polish, Spanish - Portuguese, Chinese (Simplified), Italian, Catalan - sources/text/_default.ts - sources/text/translations/ja.ts - sources/text/translations/ru.ts - sources/text/translations/pl.ts - sources/text/translations/es.ts - sources/text/translations/pt.ts - sources/text/translations/zh-Hans.ts - sources/text/translations/it.ts - sources/text/translations/ca.ts --- sources/text/_default.ts | 34 ++++++++++++++++++++++++++++ sources/text/translations/ca.ts | 34 ++++++++++++++++++++++++++++ sources/text/translations/es.ts | 34 ++++++++++++++++++++++++++++ sources/text/translations/it.ts | 34 ++++++++++++++++++++++++++++ sources/text/translations/ja.ts | 34 ++++++++++++++++++++++++++++ sources/text/translations/pl.ts | 34 ++++++++++++++++++++++++++++ sources/text/translations/pt.ts | 34 ++++++++++++++++++++++++++++ sources/text/translations/ru.ts | 34 ++++++++++++++++++++++++++++ sources/text/translations/zh-Hans.ts | 34 ++++++++++++++++++++++++++++ 9 files changed, 306 insertions(+) diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 0e5df6366..940703fb3 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: { @@ -246,6 +249,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 +301,31 @@ 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', + }, }, commandPalette: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index e7a4d8cce..492eb54b6 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,12 @@ 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', }, newSession: { @@ -292,6 +301,31 @@ 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', + }, }, commandPalette: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 03210817e..4e14100fe 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,12 @@ 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', }, newSession: { @@ -292,6 +301,31 @@ 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', + }, }, commandPalette: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index b78a3e579..12c762959 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,12 @@ 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', }, newSession: { @@ -289,6 +298,31 @@ 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', + }, }, commandPalette: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 3dda1dcf8..c1afaea63 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,12 @@ export const ja: TranslationStructure = { failedToRemoveFriend: '友達の削除に失敗しました', searchFailed: '検索に失敗しました。再試行してください。', failedToSendRequest: '友達リクエストの送信に失敗しました', + cannotShareWithSelf: '自分自身とは共有できません', + canOnlyShareWithFriends: '友達とのみ共有できます', + shareNotFound: '共有が見つかりません', + publicShareNotFound: '公開共有が見つからないか期限切れです', + consentRequired: 'アクセスには同意が必要です', + maxUsesReached: '最大使用回数に達しました', }, newSession: { @@ -292,6 +301,31 @@ 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: '友達のみ追加可能', + }, }, commandPalette: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index cc7b1fae0..c7900a8c5 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,12 @@ 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ć', }, newSession: { @@ -303,6 +312,31 @@ 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', + }, }, commandPalette: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 63c417026..5f04da670 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,12 @@ 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', }, newSession: { @@ -292,6 +301,31 @@ 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', + }, }, commandPalette: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index a6ec750be..59fa7c524 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,12 @@ export const ru: TranslationStructure = { failedToRemoveFriend: 'Не удалось удалить друга', searchFailed: 'Поиск не удался. Пожалуйста, попробуйте снова.', failedToSendRequest: 'Не удалось отправить запрос в друзья', + cannotShareWithSelf: 'Нельзя поделиться с самим собой', + canOnlyShareWithFriends: 'Можно делиться только с друзьями', + shareNotFound: 'Общий доступ не найден', + publicShareNotFound: 'Публичная ссылка не найдена или истекла', + consentRequired: 'Требуется согласие для доступа', + maxUsesReached: 'Достигнут лимит использований', }, newSession: { @@ -384,6 +393,31 @@ 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: 'Можно добавить только друзей', + }, }, commandPalette: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 0fa65e9b4..752efeea4 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,12 @@ export const zhHans: TranslationStructure = { failedToRemoveFriend: '删除好友失败', searchFailed: '搜索失败。请重试。', failedToSendRequest: '发送好友请求失败', + cannotShareWithSelf: '不能与自己分享', + canOnlyShareWithFriends: '只能与好友分享', + shareNotFound: '未找到分享', + publicShareNotFound: '公开分享未找到或已过期', + consentRequired: '需要同意才能访问', + maxUsesReached: '已达到最大使用次数', }, newSession: { @@ -294,6 +303,31 @@ 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: '只能添加好友', + }, }, commandPalette: { From ab6ac0240a2836ed9643ce9df9514eb5a57fb582 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:27:16 +0900 Subject: [PATCH 02/39] feat: Add session sharing types and API client Add comprehensive TypeScript types and API client for session sharing: Types (`sharingTypes.ts`): - ShareAccessLevel: view/edit/admin permissions - SessionShare: Direct user-to-user sharing - PublicSessionShare: Link-based public sharing - Request/Response types for all API operations - Custom error classes for error handling - Detailed TSDoc for all types and interfaces API Client (`apiSharing.ts`): - Direct sharing: create, update, delete, list shares - Public links: create, delete, access, manage - Shared sessions: list and retrieve - Access logs and blocked users management - Detailed TSDoc with @param, @returns, @throws tags - Automatic retry with backoff on failures - Type-safe error handling - sources/sync/sharingTypes.ts - sources/sync/apiSharing.ts --- sources/sync/apiSharing.ts | 531 +++++++++++++++++++++++++++++++++++ sources/sync/sharingTypes.ts | 496 ++++++++++++++++++++++++++++++++ 2 files changed, 1027 insertions(+) create mode 100644 sources/sync/apiSharing.ts create mode 100644 sources/sync/sharingTypes.ts diff --git a/sources/sync/apiSharing.ts b/sources/sync/apiSharing.ts new file mode 100644 index 000000000..306bbbcdb --- /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, accessLevel, and encryptedDataKey + * @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 and encrypted key. + * + * The `encryptedDataKey` should be the session's data encryption key encrypted + * 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 +): 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/sharingTypes.ts b/sources/sync/sharingTypes.ts new file mode 100644 index 000000000..41331bab6 --- /dev/null +++ b/sources/sync/sharingTypes.ts @@ -0,0 +1,496 @@ +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 `encryptedDataKey` is the session's data encryption + * key encrypted with the recipient's public key. + */ +export interface CreateSessionShareRequest { + /** ID of the user to share with */ + userId: string; + /** Access level to grant */ + accessLevel: ShareAccessLevel; + /** + * Session data encryption key, encrypted with recipient's public key + * + * @remarks + * Base64 encoded. This allows the recipient to decrypt the session data. + */ + encryptedDataKey: string; +} + +/** 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'; + } +} From f6217107188d9efb28166cd0d0720a6ebc5a178d Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:17:33 +0900 Subject: [PATCH 03/39] add: Add session share management dialog component Implements SessionShareDialog for managing session sharing with users. Displays current shares, access levels, and provides UI for adding/removing shares. - sources/components/SessionSharing/SessionShareDialog.tsx --- .../SessionSharing/SessionShareDialog.tsx | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 sources/components/SessionSharing/SessionShareDialog.tsx diff --git a/sources/components/SessionSharing/SessionShareDialog.tsx b/sources/components/SessionSharing/SessionShareDialog.tsx new file mode 100644 index 000000000..ac085b462 --- /dev/null +++ b/sources/components/SessionSharing/SessionShareDialog.tsx @@ -0,0 +1,272 @@ +import React, { memo, useCallback, useState } from 'react'; +import { View, Text, ScrollView } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +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 && ( + + )} + + {/* Public link management */} + {canManage && ( + + )} + + {/* 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.firstName, share.sharedWithUser.lastName] + .filter(Boolean) + .join(' ') || share.sharedWithUser.username; + + return ( + + + } + onPress={canManage ? onPress : undefined} + chevron={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.background, + borderRadius: 12, + overflow: 'hidden', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: theme.margins.md, + paddingVertical: theme.margins.sm, + borderBottomWidth: 1, + borderBottomColor: theme.colors.separator, + }, + title: { + fontSize: 18, + fontWeight: '600', + color: theme.colors.typography, + }, + content: { + flex: 1, + }, + section: { + marginTop: theme.margins.md, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: theme.colors.secondaryTypography, + paddingHorizontal: theme.margins.md, + paddingVertical: theme.margins.sm, + textTransform: 'uppercase', + }, + options: { + paddingLeft: theme.margins.lg, + backgroundColor: theme.colors.secondaryBackground, + }, + emptyState: { + padding: theme.margins.lg, + alignItems: 'center', + }, + emptyText: { + fontSize: 16, + color: theme.colors.secondaryTypography, + textAlign: 'center', + }, +})); From f983fda09b19a55801bca841d9a06892fb22337f Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:17:58 +0900 Subject: [PATCH 04/39] feat: Add session sharing translations for all languages Adds translations for session sharing UI elements across all 9 supported languages. Includes strings for access levels, share management, and dialog text. - sources/text/_default.ts - sources/text/translations/ca.ts - sources/text/translations/es.ts - sources/text/translations/it.ts - sources/text/translations/ja.ts - sources/text/translations/pl.ts - sources/text/translations/pt.ts - sources/text/translations/ru.ts - sources/text/translations/zh-Hans.ts --- sources/text/_default.ts | 4 ++++ sources/text/translations/ca.ts | 4 ++++ sources/text/translations/es.ts | 4 ++++ sources/text/translations/it.ts | 4 ++++ sources/text/translations/ja.ts | 4 ++++ sources/text/translations/pl.ts | 4 ++++ sources/text/translations/pt.ts | 4 ++++ sources/text/translations/ru.ts | 4 ++++ sources/text/translations/zh-Hans.ts | 4 ++++ 9 files changed, 36 insertions(+) diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 940703fb3..85a0e824a 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -325,6 +325,10 @@ export const en = { 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', }, }, diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 492eb54b6..935d1d030 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -325,6 +325,10 @@ export const ca: TranslationStructure = { 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ó', }, }, diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 4e14100fe..81ae6c373 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -325,6 +325,10 @@ export const es: TranslationStructure = { 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', }, }, diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 12c762959..d980dd425 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -322,6 +322,10 @@ export const it: TranslationStructure = { 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', }, }, diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index c1afaea63..993563ef1 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -325,6 +325,10 @@ export const ja: TranslationStructure = { giveConsent: 'アクセスログの記録に同意します', shareWithFriends: '友達のみと共有', friendsOnly: '友達のみ追加可能', + noShares: 'まだ共有されていません', + viewOnlyDescription: 'メッセージとメタデータを閲覧可能', + canEditDescription: 'メッセージ送信可能、共有管理は不可', + canManageDescription: '共有管理を含む全てのアクセス権限', }, }, diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index c7900a8c5..2d297ac4d 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -336,6 +336,10 @@ export const pl: TranslationStructure = { 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', }, }, diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 5f04da670..cc29825ac 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -325,6 +325,10 @@ export const pt: TranslationStructure = { 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', }, }, diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 59fa7c524..18df20e77 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -417,6 +417,10 @@ export const ru: TranslationStructure = { giveConsent: 'Я согласен на логирование доступа', shareWithFriends: 'Поделиться только с друзьями', friendsOnly: 'Можно добавить только друзей', + noShares: 'Пока нет общего доступа', + viewOnlyDescription: 'Может просматривать сообщения и метаданные', + canEditDescription: 'Может отправлять сообщения, но не управлять доступом', + canManageDescription: 'Полный доступ, включая управление общим доступом', }, }, diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 752efeea4..23e612d8a 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -327,6 +327,10 @@ export const zhHans: TranslationStructure = { giveConsent: '我同意访问日志记录', shareWithFriends: '仅与好友分享', friendsOnly: '只能添加好友', + noShares: '暂无分享', + viewOnlyDescription: '可以查看消息和元数据', + canEditDescription: '可以发送消息,但不能管理分享', + canManageDescription: '包括分享管理在内的完全访问权限', }, }, From 8321516658a70704d2fb77c51ccd4e61f4af1e99 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:40:22 +0900 Subject: [PATCH 05/39] add: Add friend selector for session sharing Implements FriendSelector component for choosing friends to share sessions with. Features searchable friend list and access level selection. - sources/components/SessionSharing/FriendSelector.tsx --- .../SessionSharing/FriendSelector.tsx | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 sources/components/SessionSharing/FriendSelector.tsx diff --git a/sources/components/SessionSharing/FriendSelector.tsx b/sources/components/SessionSharing/FriendSelector.tsx new file mode 100644 index 000000000..55a5239e7 --- /dev/null +++ b/sources/components/SessionSharing/FriendSelector.tsx @@ -0,0 +1,240 @@ +import React, { memo, useState, useMemo } from 'react'; +import { View, Text, TextInput, FlatList } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { UserProfile, getDisplayName } from '@/sync/friendTypes'; +import { ShareAccessLevel } from '@/sync/sharingTypes'; +import { UserCard } from '@/components/UserCard'; +import { Item } from '@/components/Item'; +import { t } from '@/text'; +import { CustomModal } from '@/components/CustomModal'; + +/** + * Props for FriendSelector component + */ +export interface FriendSelectorProps { + /** List of friends to choose from */ + friends: UserProfile[]; + /** IDs of users already having access */ + excludedUserIds: string[]; + /** Callback when a friend is selected */ + onSelect: (userId: string, accessLevel: ShareAccessLevel) => void; + /** Callback when cancelled */ + onCancel: () => void; +} + +/** + * Modal for selecting a friend to share with + * + * @remarks + * Displays a searchable list of friends and allows selecting + * an access level before confirming the share. + */ +export const FriendSelector = memo(function FriendSelector({ + friends, + excludedUserIds, + onSelect, + onCancel +}: FriendSelectorProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedUserId, setSelectedUserId] = useState(null); + const [selectedAccessLevel, setSelectedAccessLevel] = useState('view'); + + // 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 handleConfirm = () => { + if (selectedUserId) { + onSelect(selectedUserId, selectedAccessLevel); + } + }; + + const selectedFriend = useMemo(() => { + return friends.find(f => f.id === selectedUserId); + }, [friends, selectedUserId]); + + return ( + + + {/* Search input */} + + + {/* Friend list */} + + item.id} + renderItem={({ item }) => ( + + setSelectedUserId(item.id)} + /> + {selectedUserId === item.id && ( + + )} + + )} + ListEmptyComponent={ + + + {searchQuery + ? t('friends.noFriendsFound') + : t('friends.noFriendsYet') + } + + + } + /> + + + {/* Access level selection (only shown when friend is selected) */} + {selectedFriend && ( + + + {t('sessionSharing.accessLevel')} + + setSelectedAccessLevel('view')} + rightElement={ + selectedAccessLevel === 'view' ? ( + + ) : ( + + ) + } + /> + setSelectedAccessLevel('edit')} + rightElement={ + selectedAccessLevel === 'edit' ? ( + + ) : ( + + ) + } + /> + setSelectedAccessLevel('admin')} + rightElement={ + selectedAccessLevel === 'admin' ? ( + + ) : ( + + ) + } + /> + + )} + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 400, + maxHeight: 600, + }, + searchInput: { + height: 40, + borderRadius: 8, + backgroundColor: theme.colors.backgroundSecondary, + paddingHorizontal: 12, + marginBottom: 16, + fontSize: 16, + color: theme.colors.typography, + }, + friendList: { + flex: 1, + marginBottom: 16, + }, + friendItem: { + position: 'relative', + }, + selectedIndicator: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: 4, + backgroundColor: theme.colors.primary, + }, + emptyState: { + padding: 32, + alignItems: 'center', + }, + emptyText: { + fontSize: 16, + color: theme.colors.textSecondary, + textAlign: 'center', + }, + accessLevelSection: { + borderTopWidth: 1, + borderTopColor: theme.colors.border, + paddingTop: 16, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: theme.colors.typography, + marginBottom: 12, + }, + radioSelected: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: theme.colors.primary, + borderWidth: 2, + borderColor: theme.colors.primary, + }, + radioUnselected: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: 'transparent', + borderWidth: 2, + borderColor: theme.colors.textSecondary, + }, +})); From 312b76199714bcfa7b30ca0e0a1a6547709f4abf Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:40:43 +0900 Subject: [PATCH 06/39] feat: Add friend selector translations Adds translation keys for friend search and selection in session sharing. Includes searchFriends, noFriendsFound, and addShare across all languages. - sources/text/_default.ts - sources/text/translations/ca.ts - sources/text/translations/es.ts - sources/text/translations/it.ts - sources/text/translations/ja.ts - sources/text/translations/pl.ts - sources/text/translations/pt.ts - sources/text/translations/ru.ts - sources/text/translations/zh-Hans.ts --- sources/text/_default.ts | 6 ++++++ sources/text/translations/ca.ts | 6 ++++++ sources/text/translations/es.ts | 6 ++++++ sources/text/translations/it.ts | 6 ++++++ sources/text/translations/ja.ts | 6 ++++++ sources/text/translations/pl.ts | 6 ++++++ sources/text/translations/pt.ts | 6 ++++++ sources/text/translations/ru.ts | 6 ++++++ sources/text/translations/zh-Hans.ts | 6 ++++++ 9 files changed, 54 insertions(+) diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 85a0e824a..b8f373bdd 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -878,6 +878,12 @@ 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', }, usage: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 935d1d030..c201bde46 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -877,6 +877,12 @@ 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ó', }, usage: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 81ae6c373..639d88fa8 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -878,6 +878,12 @@ 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', }, usage: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index d980dd425..e6dc2ef38 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -870,6 +870,12 @@ 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', }, usage: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 993563ef1..a16d282f0 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -873,6 +873,12 @@ export const ja: TranslationStructure = { cancelRequestConfirm: ({ name }: { name: string }) => `${name}さんへの友達リクエストをキャンセルしますか?`, denyRequest: '友達リクエストを拒否', nowFriendsWith: ({ name }: { name: string }) => `${name}さんと友達になりました`, + searchFriends: '友達を検索', + noFriendsFound: '友達が見つかりません', + }, + + sessionSharing: { + addShare: '共有を追加', }, usage: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 2d297ac4d..2c43d3b8d 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -901,6 +901,12 @@ 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', }, usage: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index cc29825ac..ccccea9da 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -877,6 +877,12 @@ 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', }, usage: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 18df20e77..743b51059 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -900,6 +900,12 @@ export const ru: TranslationStructure = { cancelRequestConfirm: ({ name }: { name: string }) => `Отменить ваш запрос в друзья к ${name}?`, denyRequest: 'Отклонить запрос', nowFriendsWith: ({ name }: { name: string }) => `Теперь вы друзья с ${name}`, + searchFriends: 'Поиск друзей', + noFriendsFound: 'Друзья не найдены', + }, + + sessionSharing: { + addShare: 'Добавить доступ', }, usage: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 23e612d8a..2a04e5e1a 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -879,6 +879,12 @@ export const zhHans: TranslationStructure = { cancelRequestConfirm: ({ name }: { name: string }) => `取消发送给 ${name} 的好友请求?`, denyRequest: '拒绝请求', nowFriendsWith: ({ name }: { name: string }) => `您现在与 ${name} 是好友了`, + searchFriends: '搜索好友', + noFriendsFound: '未找到好友', + }, + + sessionSharing: { + addShare: '添加分享', }, usage: { From adbcb34f2e50d902e89bca5ec48f79eb4eebfdae Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:07:45 +0900 Subject: [PATCH 07/39] add: Add public link management dialog with QR code Implements PublicLinkDialog for creating and managing public share links. Features QR code generation, expiration settings, usage limits, and consent options. - sources/components/SessionSharing/PublicLinkDialog.tsx --- .../SessionSharing/PublicLinkDialog.tsx | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 sources/components/SessionSharing/PublicLinkDialog.tsx diff --git a/sources/components/SessionSharing/PublicLinkDialog.tsx b/sources/components/SessionSharing/PublicLinkDialog.tsx new file mode 100644 index 000000000..8cea5df69 --- /dev/null +++ b/sources/components/SessionSharing/PublicLinkDialog.tsx @@ -0,0 +1,330 @@ +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 { t } from '@/text'; +import { CustomModal } from '@/components/CustomModal'; +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: 300, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF', + }, + }) + .then(setQrDataUrl) + .catch(console.error); + }, [publicShare]); + + const handleCreate = () => { + onCreate({ + expiresInDays, + maxUses, + isConsentRequired, + }); + setIsCreating(false); + }; + + const formatDate = (timestamp: number) => { + return new Date(timestamp).toLocaleDateString(); + }; + + return ( + + + {isCreating ? ( + // Create new public share form + + + {t('sessionSharing.publicLinkDescription')} + + + {/* Expiration */} + + + {t('sessionSharing.expiresIn')} + + setExpiresInDays(7)} + rightElement={ + expiresInDays === 7 ? ( + + ) : ( + + ) + } + /> + setExpiresInDays(30)} + rightElement={ + expiresInDays === 30 ? ( + + ) : ( + + ) + } + /> + setExpiresInDays(undefined)} + rightElement={ + expiresInDays === undefined ? ( + + ) : ( + + ) + } + /> + + + {/* Max uses */} + + + {t('sessionSharing.maxUses')} + + setMaxUses(undefined)} + rightElement={ + maxUses === undefined ? ( + + ) : ( + + ) + } + /> + setMaxUses(10)} + rightElement={ + maxUses === 10 ? ( + + ) : ( + + ) + } + /> + setMaxUses(50)} + rightElement={ + maxUses === 50 ? ( + + ) : ( + + ) + } + /> + + + {/* Consent required */} + + + } + /> + + + ) : publicShare ? ( + // Display existing public share + + {/* QR Code */} + {qrDataUrl && ( + + + + )} + + {/* Link info */} + + + {publicShare.expiresAt && ( + + )} + + + + + ) : null} + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + minHeight: 400, + maxHeight: 600, + }, + createForm: { + padding: 16, + }, + description: { + fontSize: 14, + color: theme.colors.textSecondary, + marginBottom: 24, + lineHeight: 20, + }, + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: theme.colors.typography, + marginBottom: 12, + }, + radioSelected: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: theme.colors.primary, + borderWidth: 2, + borderColor: theme.colors.primary, + }, + radioUnselected: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: 'transparent', + borderWidth: 2, + borderColor: theme.colors.textSecondary, + }, + existingShare: { + padding: 16, + }, + qrContainer: { + alignItems: 'center', + marginBottom: 24, + padding: 16, + backgroundColor: theme.colors.background, + borderRadius: 12, + }, + infoSection: { + borderTopWidth: 1, + borderTopColor: theme.colors.border, + paddingTop: 16, + }, +})); From a40e3567bac159b31279761208e7469145049823 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:08:04 +0900 Subject: [PATCH 08/39] feat: Add public link management translations Adds translation keys for public link creation and management UI. Includes expiration, usage limits, and consent options across all languages. - sources/text/_default.ts - sources/text/translations/ca.ts - sources/text/translations/es.ts - sources/text/translations/it.ts - sources/text/translations/ja.ts - sources/text/translations/pl.ts - sources/text/translations/pt.ts - sources/text/translations/ru.ts - sources/text/translations/zh-Hans.ts --- sources/text/_default.ts | 17 +++++++++++++++++ sources/text/translations/ca.ts | 17 +++++++++++++++++ sources/text/translations/es.ts | 17 +++++++++++++++++ sources/text/translations/it.ts | 17 +++++++++++++++++ sources/text/translations/ja.ts | 17 +++++++++++++++++ sources/text/translations/pl.ts | 17 +++++++++++++++++ sources/text/translations/pt.ts | 17 +++++++++++++++++ sources/text/translations/ru.ts | 17 +++++++++++++++++ sources/text/translations/zh-Hans.ts | 17 +++++++++++++++++ 9 files changed, 153 insertions(+) diff --git a/sources/text/_default.ts b/sources/text/_default.ts index b8f373bdd..9aad5e05f 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -884,6 +884,23 @@ export const en = { 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`, }, usage: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index c201bde46..2424cf367 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -883,6 +883,23 @@ export const ca: TranslationStructure = { 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`, }, usage: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 639d88fa8..55f4ad28b 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -884,6 +884,23 @@ export const es: TranslationStructure = { 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`, }, usage: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index e6dc2ef38..750398a89 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -876,6 +876,23 @@ export const it: TranslationStructure = { 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`, }, usage: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index a16d282f0..41535a387 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -879,6 +879,23 @@ export const ja: TranslationStructure = { 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} 回`, }, usage: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 2c43d3b8d..2ed0b45dd 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -907,6 +907,23 @@ export const pl: TranslationStructure = { 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ć`, }, usage: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index ccccea9da..4b1418a89 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -883,6 +883,23 @@ export const pt: TranslationStructure = { 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`, }, usage: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 743b51059..dfd3361b4 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -906,6 +906,23 @@ export const ru: TranslationStructure = { 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} использований`, }, usage: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 2a04e5e1a..062e20e94 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -885,6 +885,23 @@ export const zhHans: TranslationStructure = { 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} 次`, }, usage: { From 91950b22d7d94677d93e33c39005c0bfb0570d06 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:06:38 +0900 Subject: [PATCH 09/39] refactor: Remove client-side encryption from share API Align frontend with server-side encryption implementation. The server now handles data key encryption automatically using recipient's public keys. Clients no longer provide encryptedDataKey when creating shares. Files: - sources/sync/sharingTypes.ts - sources/sync/apiSharing.ts - sources/app/(app)/session/[id]/sharing.tsx --- sources/app/(app)/session/[id]/sharing.tsx | 274 +++++++++++++++++++++ sources/sync/apiSharing.ts | 8 +- sources/sync/sharingTypes.ts | 11 +- 3 files changed, 280 insertions(+), 13 deletions(-) create mode 100644 sources/app/(app)/session/[id]/sharing.tsx diff --git a/sources/app/(app)/session/[id]/sharing.tsx b/sources/app/(app)/session/[id]/sharing.tsx new file mode 100644 index 000000000..312f6b325 --- /dev/null +++ b/sources/app/(app)/session/[id]/sharing.tsx @@ -0,0 +1,274 @@ +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'; + +function SharingManagementContent({ sessionId }: { sessionId: string }) { + const { theme } = useUnistyles(); + const router = useRouter(); + const session = useSession(sessionId); + + const [shares, setShares] = useState([]); + 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.friends); + } 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 + // NOTE: Public share encryption is not yet implemented + // Public shares will be added in a future update + const handleCreatePublicShare = useCallback(async (options: { + expiresInDays?: number; + maxUses?: number; + isConsentRequired: boolean; + }) => { + throw new HappyError(t('errors.notImplemented'), false); + }, []); + + // 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 && ( + setShowFriendSelector(false)} + /> + )} + + {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/sync/apiSharing.ts b/sources/sync/apiSharing.ts index 306bbbcdb..e7e15e059 100644 --- a/sources/sync/apiSharing.ts +++ b/sources/sync/apiSharing.ts @@ -66,7 +66,7 @@ export async function getSessionShares( * * @param credentials - User authentication credentials * @param sessionId - ID of the session to share - * @param request - Share creation request containing userId, accessLevel, and encryptedDataKey + * @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 @@ -74,10 +74,10 @@ export async function getSessionShares( * @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 and encrypted key. + * for the user, it will be updated with the new access level. * - * The `encryptedDataKey` should be the session's data encryption key encrypted - * with the recipient's public key, allowing them to decrypt the session data. + * 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, diff --git a/sources/sync/sharingTypes.ts b/sources/sync/sharingTypes.ts index 41331bab6..b5c5cd76e 100644 --- a/sources/sync/sharingTypes.ts +++ b/sources/sync/sharingTypes.ts @@ -217,21 +217,14 @@ export interface PublicShareBlockedUser { * * @remarks * Used when sharing a session with a specific user. The user must be a friend - * of the session owner. The `encryptedDataKey` is the session's data encryption - * key encrypted with the recipient's public key. + * 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; - /** - * Session data encryption key, encrypted with recipient's public key - * - * @remarks - * Base64 encoded. This allows the recipient to decrypt the session data. - */ - encryptedDataKey: string; } /** Response containing a single session share */ From 1c8e29c5c23b2b528b561a673bcc8ecf66b5a271 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:06:53 +0900 Subject: [PATCH 10/39] feat: Add publicKey to UserProfile and sync methods Add publicKey field to UserProfile for encryption support. Add getUserID and getUserPublicKey helper methods to sync class for accessing user info. Files: - sources/sync/friendTypes.ts - sources/sync/sync.ts --- sources/sync/friendTypes.ts | 3 ++- sources/sync/sync.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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/sync.ts b/sources/sync/sync.ts index 49d9c07fd..ddb9812cd 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -554,6 +554,25 @@ class Sync { return this.credentials; } + public getUserID(): string { + return this.serverID; + } + + public getSessionDataKey(sessionId: string): Uint8Array | null { + const sessionEncryption = this.encryption.getSessionEncryption(sessionId); + if (!sessionEncryption) { + return null; + } + // Access the private encryptor field through the public interface + // SessionEncryption has the data key through its encryptor + // For now, we'll need to expose this through the Encryption class + return null; // TODO: Expose session data key through Encryption API + } + + public getUserPublicKey(): Uint8Array { + return this.encryption.contentDataKey; + } + // Artifact methods public fetchArtifactsList = async (): Promise => { log.log('📦 fetchArtifactsList: Starting artifact sync'); From 1bfd2f6a9d0bd325a9bf8983917d0654715ae086 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:07:25 +0900 Subject: [PATCH 11/39] feat: Add session sharing translations Add translations for session sharing UI including direct sharing, public links, access levels, and error messages across all languages. Files: - sources/text/_default.ts - sources/text/translations/ca.ts - sources/text/translations/es.ts - sources/text/translations/it.ts - sources/text/translations/ja.ts - sources/text/translations/pl.ts - sources/text/translations/pt.ts - sources/text/translations/ru.ts - sources/text/translations/zh-Hans.ts --- sources/text/_default.ts | 7 ++++++- sources/text/translations/ca.ts | 7 ++++++- sources/text/translations/es.ts | 7 ++++++- sources/text/translations/it.ts | 7 ++++++- sources/text/translations/ja.ts | 5 +++++ sources/text/translations/pl.ts | 5 +++++ sources/text/translations/pt.ts | 7 ++++++- sources/text/translations/ru.ts | 5 +++++ sources/text/translations/zh-Hans.ts | 7 ++++++- 9 files changed, 51 insertions(+), 6 deletions(-) diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 9aad5e05f..1b0508a9f 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -404,7 +404,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: { @@ -901,6 +903,9 @@ export const en = { 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', }, usage: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 2424cf367..32d9bd5f6 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -404,7 +404,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: { @@ -900,6 +902,9 @@ export const ca: TranslationStructure = { 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', }, usage: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 55f4ad28b..416534727 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -404,7 +404,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: { @@ -901,6 +903,9 @@ export const es: TranslationStructure = { 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', }, usage: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 750398a89..afd785a56 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -401,7 +401,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: { @@ -893,6 +895,9 @@ export const it: TranslationStructure = { 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', }, usage: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 41535a387..05b2adc56 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -404,6 +404,8 @@ export const ja: TranslationStructure = { deleteSessionWarning: 'この操作は取り消せません。このセッションに関連するすべてのメッセージとデータが完全に削除されます。', failedToDeleteSession: 'セッションの削除に失敗しました', sessionDeleted: 'セッションが正常に削除されました', + manageSharing: '共有を管理', + manageSharingSubtitle: '友達とセッションを共有するか、公開リンクを作成', }, @@ -896,6 +898,9 @@ export const ja: TranslationStructure = { usageCount: '使用回数', usageCountWithMax: ({ count, max }: { count: number; max: number }) => `${count} / ${max} 回`, usageCountUnlimited: ({ count }: { count: number }) => `${count} 回`, + directSharing: '直接共有', + publicLinkActive: '公開リンク有効', + createPublicLink: '公開リンクを作成', }, usage: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 2ed0b45dd..4063d013d 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -415,6 +415,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: { @@ -924,6 +926,9 @@ export const pl: TranslationStructure = { 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', }, usage: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 4b1418a89..b89549275 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -404,7 +404,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: { @@ -900,6 +902,9 @@ export const pt: TranslationStructure = { 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', }, usage: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index dfd3361b4..2f857a1c9 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -350,6 +350,8 @@ export const ru: TranslationStructure = { deleteSessionWarning: 'Это действие нельзя отменить. Все сообщения и данные, связанные с этой сессией, будут удалены навсегда.', failedToDeleteSession: 'Не удалось удалить сессию', sessionDeleted: 'Сессия успешно удалена', + manageSharing: 'Управление доступом', + manageSharingSubtitle: 'Поделиться сессией с друзьями или создать публичную ссылку', }, components: { @@ -923,6 +925,9 @@ export const ru: TranslationStructure = { usageCount: 'Количество использований', usageCountWithMax: ({ count, max }: { count: number; max: number }) => `${count} / ${max} использований`, usageCountUnlimited: ({ count }: { count: number }) => `${count} использований`, + directSharing: 'Прямой доступ', + publicLinkActive: 'Публичная ссылка активна', + createPublicLink: 'Создать публичную ссылку', }, usage: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 062e20e94..9335f9055 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -406,7 +406,9 @@ export const zhHans: TranslationStructure = { deleteSessionWarning: '此操作无法撤销。与此会话相关的所有消息和数据将被永久删除。', failedToDeleteSession: '删除会话失败', sessionDeleted: '会话删除成功', - + manageSharing: '管理共享', + manageSharingSubtitle: '与好友共享此会话或创建公开链接', + }, components: { @@ -902,6 +904,9 @@ export const zhHans: TranslationStructure = { usageCount: '使用次数', usageCountWithMax: ({ count, max }: { count: number; max: number }) => `${count} / ${max} 次`, usageCountUnlimited: ({ count }: { count: number }) => `${count} 次`, + directSharing: '直接分享', + publicLinkActive: '公开链接已激活', + createPublicLink: '创建公开链接', }, usage: { From 13f7117fa6e2cb1bcd048cd96c29f4457a4e7a3e Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:07:40 +0900 Subject: [PATCH 12/39] feat: Add manage sharing button to session info Add navigation button to session sharing management screen in the quick actions section of session info page. Files: - sources/app/(app)/session/[id]/info.tsx --- sources/app/(app)/session/[id]/info.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx index 631df7f39..03e77bb07 100644 --- a/sources/app/(app)/session/[id]/info.tsx +++ b/sources/app/(app)/session/[id]/info.tsx @@ -257,6 +257,12 @@ function SessionInfoContent({ session }: { session: Session }) { onPress={() => router.push(`/machine/${session.metadata?.machineId}`)} /> )} + } + onPress={() => router.push(`/session/${session.id}/sharing`)} + /> {sessionStatus.isConnected && ( Date: Fri, 9 Jan 2026 21:44:56 +0900 Subject: [PATCH 13/39] feat: Add shared session fetching and decryption Implement client-side logic to fetch and decrypt sessions shared with the current user. Uses existing Box decryption to decrypt encryptedDataKey with recipient's private key, enabling E2E encrypted session sharing. Files: - sources/sync/sync.ts --- sources/sync/sync.ts | 118 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 2 deletions(-) diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index ddb9812cd..005b8e49e 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(); @@ -542,6 +546,101 @@ 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); + } + } + 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 + 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(); } @@ -1603,6 +1702,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) { From 624686d3857fec25bf73d878d1aeab97b24cbe3b Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 02:37:55 +0900 Subject: [PATCH 14/39] feat: Implement client-side public share encryption Add token-based encryption for public shares with client-side key generation. Clients generate random tokens, encrypt data keys with derived keys, and send both to server for E2E security. Files: - sources/sync/publicShareEncryption.ts - sources/sync/sync.ts - sources/sync/apiSharing.ts - sources/app/(app)/session/[id]/sharing.tsx --- sources/app/(app)/session/[id]/sharing.tsx | 41 +++++++++++-- sources/sync/apiSharing.ts | 2 +- sources/sync/publicShareEncryption.ts | 69 ++++++++++++++++++++++ sources/sync/sync.ts | 11 +--- 4 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 sources/sync/publicShareEncryption.ts diff --git a/sources/app/(app)/session/[id]/sharing.tsx b/sources/app/(app)/session/[id]/sharing.tsx index 312f6b325..f0bc7d9e8 100644 --- a/sources/app/(app)/session/[id]/sharing.tsx +++ b/sources/app/(app)/session/[id]/sharing.tsx @@ -27,6 +27,8 @@ 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(); @@ -111,15 +113,46 @@ function SharingManagementContent({ sessionId }: { sessionId: string }) { }, [sessionId, loadSharingData]); // Handle creating public share - // NOTE: Public share encryption is not yet implemented - // Public shares will be added in a future update const handleCreatePublicShare = useCallback(async (options: { expiresInDays?: number; maxUses?: number; isConsentRequired: boolean; }) => { - throw new HappyError(t('errors.notImplemented'), false); - }, []); + 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 () => { diff --git a/sources/sync/apiSharing.ts b/sources/sync/apiSharing.ts index e7e15e059..9924943b8 100644 --- a/sources/sync/apiSharing.ts +++ b/sources/sync/apiSharing.ts @@ -275,7 +275,7 @@ export async function getSharedSessionDetails( export async function createPublicShare( credentials: AuthCredentials, sessionId: string, - request: CreatePublicShareRequest + request: CreatePublicShareRequest & { token: string } ): Promise { return await backoff(async () => { const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share`, { 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/sync.ts b/sources/sync/sync.ts index 005b8e49e..34641e492 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -507,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); } @@ -595,6 +596,7 @@ class Sync { continue; } sessionKeys.set(share.session.id, decrypted); + this.sessionDataKeys.set(share.session.id, decrypted); // Store for later use } } await this.encryption.initializeSessions(sessionKeys); @@ -658,14 +660,7 @@ class Sync { } public getSessionDataKey(sessionId: string): Uint8Array | null { - const sessionEncryption = this.encryption.getSessionEncryption(sessionId); - if (!sessionEncryption) { - return null; - } - // Access the private encryptor field through the public interface - // SessionEncryption has the data key through its encryptor - // For now, we'll need to expose this through the Encryption class - return null; // TODO: Expose session data key through Encryption API + return this.sessionDataKeys.get(sessionId) || null; } public getUserPublicKey(): Uint8Array { From 4542f76e06f55c0ca87a78cd49c7566cc41078d9 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 03:42:56 +0900 Subject: [PATCH 15/39] feat: Add owner profile and access level to Session type Store owner profile information and access level for shared sessions. This enables UI to display who shared the session and enforce permissions. - sources/sync/storageTypes.ts - sources/sync/sync.ts --- sources/sync/storageTypes.ts | 10 ++++++++++ sources/sync/sync.ts | 2 ++ 2 files changed, 12 insertions(+) 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 34641e492..b822cd25b 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -633,6 +633,8 @@ class Sync { 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); From 48f5ca4bd5aaae3edfcc32fd82dd3d3b1026afa2 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 03:43:36 +0900 Subject: [PATCH 16/39] feat: Add session sharing permission translations Add translations for view-only mode and permission error messages. Covers all supported languages for access control feedback. - sources/text/_default.ts - sources/text/translations/ca.ts - sources/text/translations/es.ts - sources/text/translations/it.ts - sources/text/translations/ja.ts - sources/text/translations/pl.ts - sources/text/translations/pt.ts - sources/text/translations/ru.ts - sources/text/translations/zh-Hans.ts --- sources/text/_default.ts | 2 ++ sources/text/translations/ca.ts | 2 ++ sources/text/translations/es.ts | 2 ++ sources/text/translations/it.ts | 2 ++ sources/text/translations/ja.ts | 2 ++ sources/text/translations/pl.ts | 2 ++ sources/text/translations/pt.ts | 2 ++ sources/text/translations/ru.ts | 2 ++ sources/text/translations/zh-Hans.ts | 2 ++ 9 files changed, 18 insertions(+) diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 1b0508a9f..7a09424ba 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -906,6 +906,8 @@ export const en = { 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', }, usage: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 32d9bd5f6..fc17a053a 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -905,6 +905,8 @@ export const ca: TranslationStructure = { 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ó', }, usage: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 416534727..6f3458b5b 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -906,6 +906,8 @@ export const es: TranslationStructure = { 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', }, usage: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index afd785a56..7246ccb63 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -898,6 +898,8 @@ export const it: TranslationStructure = { 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', }, usage: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 05b2adc56..c80339345 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -901,6 +901,8 @@ export const ja: TranslationStructure = { directSharing: '直接共有', publicLinkActive: '公開リンク有効', createPublicLink: '公開リンクを作成', + viewOnlyMode: '閲覧専用モード', + noEditPermission: 'このセッションを編集する権限がありません', }, usage: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 4063d013d..d415225d2 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -929,6 +929,8 @@ export const pl: TranslationStructure = { 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', }, usage: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index b89549275..3c688c2ef 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -905,6 +905,8 @@ export const pt: TranslationStructure = { 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', }, usage: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 2f857a1c9..fc684e689 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -928,6 +928,8 @@ export const ru: TranslationStructure = { directSharing: 'Прямой доступ', publicLinkActive: 'Публичная ссылка активна', createPublicLink: 'Создать публичную ссылку', + viewOnlyMode: 'Режим просмотра', + noEditPermission: 'У вас нет прав на редактирование этой сессии', }, usage: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 9335f9055..c2ec06172 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -907,6 +907,8 @@ export const zhHans: TranslationStructure = { directSharing: '直接分享', publicLinkActive: '公开链接已激活', createPublicLink: '创建公开链接', + viewOnlyMode: '仅查看模式', + noEditPermission: '您没有编辑此会话的权限', }, usage: { From 49bba47e5122431825776c5aa73ac1ddf5963590 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 03:48:13 +0900 Subject: [PATCH 17/39] feat: Display shared session indicators in session list Show owner badge with icon and name for shared sessions. Helps users quickly identify which sessions are shared with them. - sources/components/ActiveSessionsGroup.tsx --- sources/components/ActiveSessionsGroup.tsx | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/sources/components/ActiveSessionsGroup.tsx b/sources/components/ActiveSessionsGroup.tsx index d567b9fb9..725294755 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.firstName || session.ownerProfile.username) + : 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 */} From 5c944a5266d4176a32ae7c651539fb25bf01ab23 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 03:49:44 +0900 Subject: [PATCH 18/39] feat: Add disabled prop to AgentInput component Support disabling input field and send button for read-only sessions. Implements UI enforcement of view-only access level. - sources/components/AgentInput.tsx --- sources/components/AgentInput.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 ? ( Date: Sat, 10 Jan 2026 03:49:58 +0900 Subject: [PATCH 19/39] feat: Enforce access level permissions in session view Disable input and block message sending for view-only sessions. Show appropriate placeholder and error messages based on access level. - sources/-session/SessionView.tsx --- sources/-session/SessionView.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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, From 65140e90865864c63a00bc01eebd3d197bac3645 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 03:50:21 +0900 Subject: [PATCH 20/39] feat: Restrict sharing management to admin users only Hide sharing management button for non-admin access levels. Only session owners and admin-level users can modify sharing settings. - sources/app/(app)/session/[id]/info.tsx --- sources/app/(app)/session/[id]/info.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx index 03e77bb07..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,12 +260,14 @@ function SessionInfoContent({ session }: { session: Session }) { onPress={() => router.push(`/machine/${session.metadata?.machineId}`)} /> )} - } - onPress={() => router.push(`/session/${session.id}/sharing`)} - /> + {canManageSharing && ( + } + onPress={() => router.push(`/session/${session.id}/sharing`)} + /> + )} {sessionStatus.isConnected && ( Date: Sat, 10 Jan 2026 09:58:00 +0900 Subject: [PATCH 21/39] feat: Add public share key storage in sync manager Add method to store decrypted data keys from public shares. Expose server URL getter for public share API access. - sources/sync/sync.ts - sources/sync/publicShareEncryption.ts --- sources/sync/sync.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index b822cd25b..10a7bf25b 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -665,10 +665,18 @@ class Sync { 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; } + public getServerUrl(): string { + return getServerUrl(); + } + // Artifact methods public fetchArtifactsList = async (): Promise => { log.log('📦 fetchArtifactsList: Starting artifact sync'); From f422d3771ab14eeed24b13fd20da2ca2a9dc736b Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 09:58:24 +0900 Subject: [PATCH 22/39] feat: Add public share access translations Add translations for share access errors and consent flow. Covers not found, expired, decryption failure, and consent UI. - sources/text/_default.ts - sources/text/translations/ca.ts - sources/text/translations/es.ts - sources/text/translations/it.ts - sources/text/translations/ja.ts - sources/text/translations/pl.ts - sources/text/translations/pt.ts - sources/text/translations/ru.ts - sources/text/translations/zh-Hans.ts --- sources/text/_default.ts | 7 +++++++ sources/text/translations/ca.ts | 7 +++++++ sources/text/translations/es.ts | 7 +++++++ sources/text/translations/it.ts | 7 +++++++ sources/text/translations/ja.ts | 7 +++++++ sources/text/translations/pl.ts | 7 +++++++ sources/text/translations/pt.ts | 7 +++++++ sources/text/translations/ru.ts | 7 +++++++ sources/text/translations/zh-Hans.ts | 7 +++++++ 9 files changed, 63 insertions(+) diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 7a09424ba..cdf25c303 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -908,6 +908,13 @@ export const en = { 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 fc17a053a..49aeb73e8 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -907,6 +907,13 @@ export const ca: TranslationStructure = { 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 6f3458b5b..3f481c3cc 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -908,6 +908,13 @@ export const es: TranslationStructure = { 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 7246ccb63..480e42da3 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -900,6 +900,13 @@ export const it: TranslationStructure = { 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 c80339345..266a75458 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -903,6 +903,13 @@ export const ja: TranslationStructure = { 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 d415225d2..6c1c1dffe 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -931,6 +931,13 @@ export const pl: TranslationStructure = { 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 3c688c2ef..40b50ba38 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -907,6 +907,13 @@ export const pt: TranslationStructure = { 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 fc684e689..2bff28349 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -930,6 +930,13 @@ export const ru: TranslationStructure = { 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 c2ec06172..fea092ed2 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -909,6 +909,13 @@ export const zhHans: TranslationStructure = { createPublicLink: '创建公开链接', viewOnlyMode: '仅查看模式', noEditPermission: '您没有编辑此会话的权限', + shareNotFound: '未找到分享', + shareExpired: '此分享链接已过期', + failedToDecrypt: '解密会话数据失败', + consentRequired: '需要同意', + sharedBy: '分享者', + consentDescription: '会话所有者要求您在查看前同意', + acceptAndView: '同意并查看会话', }, usage: { From 43dedc1f154af9de8d5b9041bad5c5dfa81dc3d9 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 09:58:37 +0900 Subject: [PATCH 23/39] feat: Implement public share access screen Add screen for accessing sessions via public share links. Handles token-based decryption and consent flow with owner display. - sources/app/(app)/share/[token].tsx --- sources/app/(app)/share/[token].tsx | 193 ++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 sources/app/(app)/share/[token].tsx diff --git a/sources/app/(app)/share/[token].tsx b/sources/app/(app)/share/[token].tsx new file mode 100644 index 000000000..4d269f898 --- /dev/null +++ b/sources/app/(app)/share/[token].tsx @@ -0,0 +1,193 @@ +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 { Modal } from '@/modal'; +import { sync } from '@/sync/sync'; +import { decryptDataKeyFromPublicShare } from '@/sync/publicShareEncryption'; +import { Ionicons } from '@expo/vector-icons'; + +/** + * 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 = sync.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('sessionSharing.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('sessionSharing.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('sessionSharing.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; +} From 2a4a04c21356e1e2cfa752333323d4c3fad0cdd1 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 09:59:33 +0900 Subject: [PATCH 24/39] update: Prioritize username over firstName for display Use username as primary display name with firstName as fallback. Maintains consistency across user profile displays. - sources/components/ActiveSessionsGroup.tsx --- sources/components/ActiveSessionsGroup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sources/components/ActiveSessionsGroup.tsx b/sources/components/ActiveSessionsGroup.tsx index 725294755..29a0af685 100644 --- a/sources/components/ActiveSessionsGroup.tsx +++ b/sources/components/ActiveSessionsGroup.tsx @@ -365,7 +365,7 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi // Check if this is a shared session const isSharedSession = !!session.owner; const ownerName = session.ownerProfile - ? (session.ownerProfile.firstName || session.ownerProfile.username) + ? (session.ownerProfile.username || session.ownerProfile.firstName) : null; const [archivingSession, performArchive] = useHappyAction(async () => { From 24463024ff7072d60647a68fe12e482402739ef8 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 10:48:52 +0900 Subject: [PATCH 25/39] fix: Remove CustomModal from sharing components Remove non-existent CustomModal dependency from FriendSelector and PublicLinkDialog. Convert to standard View components without modal wrapper logic. - sources/components/SessionSharing/FriendSelector.tsx - sources/components/SessionSharing/PublicLinkDialog.tsx --- .../SessionSharing/FriendSelector.tsx | 226 ++++++++---------- .../SessionSharing/PublicLinkDialog.tsx | 41 +--- 2 files changed, 108 insertions(+), 159 deletions(-) diff --git a/sources/components/SessionSharing/FriendSelector.tsx b/sources/components/SessionSharing/FriendSelector.tsx index 55a5239e7..0ecdabe37 100644 --- a/sources/components/SessionSharing/FriendSelector.tsx +++ b/sources/components/SessionSharing/FriendSelector.tsx @@ -1,12 +1,11 @@ import React, { memo, useState, useMemo } from 'react'; -import { View, Text, TextInput, FlatList } from 'react-native'; +import { View, Text, TextInput, FlatList, ScrollView } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import { UserProfile, getDisplayName } from '@/sync/friendTypes'; import { ShareAccessLevel } from '@/sync/sharingTypes'; import { UserCard } from '@/components/UserCard'; import { Item } from '@/components/Item'; import { t } from '@/text'; -import { CustomModal } from '@/components/CustomModal'; /** * Props for FriendSelector component @@ -18,26 +17,30 @@ export interface FriendSelectorProps { excludedUserIds: string[]; /** Callback when a friend is selected */ onSelect: (userId: string, accessLevel: ShareAccessLevel) => void; - /** Callback when cancelled */ - onCancel: () => void; + /** Currently selected user ID (optional) */ + selectedUserId?: string | null; + /** Currently selected access level (optional) */ + selectedAccessLevel?: ShareAccessLevel; } /** - * Modal for selecting a friend to share with + * Friend selector component for sharing * * @remarks * Displays a searchable list of friends and allows selecting - * an access level before confirming the share. + * an access level. This is a controlled component - parent + * manages the modal and button states. */ export const FriendSelector = memo(function FriendSelector({ friends, excludedUserIds, onSelect, - onCancel + selectedUserId: initialSelectedUserId = null, + selectedAccessLevel: initialSelectedAccessLevel = 'view', }: FriendSelectorProps) { const [searchQuery, setSearchQuery] = useState(''); - const [selectedUserId, setSelectedUserId] = useState(null); - const [selectedAccessLevel, setSelectedAccessLevel] = useState('view'); + const [selectedUserId, setSelectedUserId] = useState(initialSelectedUserId); + const [selectedAccessLevel, setSelectedAccessLevel] = useState(initialSelectedAccessLevel); // Filter friends based on search and exclusions const filteredFriends = useMemo(() => { @@ -54,128 +57,110 @@ export const FriendSelector = memo(function FriendSelector({ }); }, [friends, excludedUserIds, searchQuery]); - const handleConfirm = () => { - if (selectedUserId) { - onSelect(selectedUserId, selectedAccessLevel); - } - }; - 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 */} - + {/* Search input */} + + + {/* Friend list */} + + item.id} + renderItem={({ item }) => ( + + setSelectedUserId(item.id)} + /> + {selectedUserId === item.id && ( + + )} + + )} + ListEmptyComponent={ + + + {searchQuery + ? t('friends.noFriendsFound') + : t('friends.noFriendsYet') + } + + + } + scrollEnabled={false} /> + - {/* Friend list */} - - item.id} - renderItem={({ item }) => ( - - setSelectedUserId(item.id)} - /> - {selectedUserId === item.id && ( - - )} - - )} - ListEmptyComponent={ - - - {searchQuery - ? t('friends.noFriendsFound') - : t('friends.noFriendsYet') - } - - + {/* Access level selection (only shown when friend is selected) */} + {selectedFriend && ( + + + {t('sessionSharing.accessLevel')} + + setSelectedAccessLevel('view')} + rightElement={ + selectedAccessLevel === 'view' ? ( + + ) : ( + + ) + } + /> + setSelectedAccessLevel('edit')} + rightElement={ + selectedAccessLevel === 'edit' ? ( + + ) : ( + + ) + } + /> + setSelectedAccessLevel('admin')} + rightElement={ + selectedAccessLevel === 'admin' ? ( + + ) : ( + + ) } /> - - {/* Access level selection (only shown when friend is selected) */} - {selectedFriend && ( - - - {t('sessionSharing.accessLevel')} - - setSelectedAccessLevel('view')} - rightElement={ - selectedAccessLevel === 'view' ? ( - - ) : ( - - ) - } - /> - setSelectedAccessLevel('edit')} - rightElement={ - selectedAccessLevel === 'edit' ? ( - - ) : ( - - ) - } - /> - setSelectedAccessLevel('admin')} - rightElement={ - selectedAccessLevel === 'admin' ? ( - - ) : ( - - ) - } - /> - - )} - - + )} + ); }); const styles = StyleSheet.create((theme) => ({ container: { flex: 1, - minHeight: 400, - maxHeight: 600, + padding: 16, }, searchInput: { height: 40, @@ -187,7 +172,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.typography, }, friendList: { - flex: 1, marginBottom: 16, }, friendItem: { @@ -211,15 +195,14 @@ const styles = StyleSheet.create((theme) => ({ textAlign: 'center', }, accessLevelSection: { - borderTopWidth: 1, - borderTopColor: theme.colors.border, - paddingTop: 16, + marginTop: 8, }, sectionTitle: { - fontSize: 16, + fontSize: 17, fontWeight: '600', - color: theme.colors.typography, + color: theme.colors.text, marginBottom: 12, + paddingHorizontal: 4, }, radioSelected: { width: 20, @@ -233,7 +216,6 @@ const styles = StyleSheet.create((theme) => ({ width: 20, height: 20, borderRadius: 10, - backgroundColor: 'transparent', borderWidth: 2, borderColor: theme.colors.textSecondary, }, diff --git a/sources/components/SessionSharing/PublicLinkDialog.tsx b/sources/components/SessionSharing/PublicLinkDialog.tsx index 8cea5df69..e3ab51934 100644 --- a/sources/components/SessionSharing/PublicLinkDialog.tsx +++ b/sources/components/SessionSharing/PublicLinkDialog.tsx @@ -5,8 +5,8 @@ import QRCode from 'qrcode'; import { Image } from 'expo-image'; import { PublicSessionShare } from '@/sync/sharingTypes'; import { Item } from '@/components/Item'; +import { ItemGroup } from '@/components/ItemGroup'; import { t } from '@/text'; -import { CustomModal } from '@/components/CustomModal'; import { getServerUrl } from '@/sync/serverConfig'; /** @@ -83,40 +83,8 @@ export const PublicLinkDialog = memo(function PublicLinkDialog({ }; return ( - - - {isCreating ? ( + + {isCreating ? ( // Create new public share form @@ -268,8 +236,7 @@ export const PublicLinkDialog = memo(function PublicLinkDialog({ ) : null} - - + ); }); From 8d401b846762dbbe3088b1d21b3751b7b860a19b Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 11:53:59 +0900 Subject: [PATCH 26/39] fix: Replace non-existent theme properties in sharing components Updated theme property references to match existing codebase patterns. Changed background->surface, typography->text, primary->textLink, margins to fixed pixel values. Applied consistent radio button styling using theme.colors.radio. - sources/components/SessionSharing/SessionShareDialog.tsx - sources/components/SessionSharing/FriendSelector.tsx - sources/components/SessionSharing/PublicLinkDialog.tsx --- .../SessionSharing/FriendSelector.tsx | 46 +++++++---- .../SessionSharing/PublicLinkDialog.tsx | 78 ++++++++++++------- .../SessionSharing/SessionShareDialog.tsx | 38 +++++---- 3 files changed, 97 insertions(+), 65 deletions(-) diff --git a/sources/components/SessionSharing/FriendSelector.tsx b/sources/components/SessionSharing/FriendSelector.tsx index 0ecdabe37..b8a274612 100644 --- a/sources/components/SessionSharing/FriendSelector.tsx +++ b/sources/components/SessionSharing/FriendSelector.tsx @@ -113,39 +113,45 @@ export const FriendSelector = memo(function FriendSelector({ {selectedFriend && ( - {t('sessionSharing.accessLevel')} + {t('session.sharing.accessLevel')} setSelectedAccessLevel('view')} rightElement={ selectedAccessLevel === 'view' ? ( - + + + ) : ( ) } /> setSelectedAccessLevel('edit')} rightElement={ selectedAccessLevel === 'edit' ? ( - + + + ) : ( ) } /> setSelectedAccessLevel('admin')} rightElement={ selectedAccessLevel === 'admin' ? ( - + + + ) : ( ) @@ -165,11 +171,11 @@ const styles = StyleSheet.create((theme) => ({ searchInput: { height: 40, borderRadius: 8, - backgroundColor: theme.colors.backgroundSecondary, + backgroundColor: theme.colors.surfaceHigh, paddingHorizontal: 12, marginBottom: 16, fontSize: 16, - color: theme.colors.typography, + color: theme.colors.text, }, friendList: { marginBottom: 16, @@ -183,7 +189,7 @@ const styles = StyleSheet.create((theme) => ({ top: 0, bottom: 0, width: 4, - backgroundColor: theme.colors.primary, + backgroundColor: theme.colors.textLink, }, emptyState: { padding: 32, @@ -208,15 +214,23 @@ const styles = StyleSheet.create((theme) => ({ width: 20, height: 20, borderRadius: 10, - backgroundColor: theme.colors.primary, + backgroundColor: 'transparent', borderWidth: 2, - borderColor: theme.colors.primary, + 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.textSecondary, + borderColor: theme.colors.radio.inactive, }, })); diff --git a/sources/components/SessionSharing/PublicLinkDialog.tsx b/sources/components/SessionSharing/PublicLinkDialog.tsx index e3ab51934..7550f4678 100644 --- a/sources/components/SessionSharing/PublicLinkDialog.tsx +++ b/sources/components/SessionSharing/PublicLinkDialog.tsx @@ -88,42 +88,48 @@ export const PublicLinkDialog = memo(function PublicLinkDialog({ // Create new public share form - {t('sessionSharing.publicLinkDescription')} + {t('session.sharing.publicLinkDescription')} {/* Expiration */} - {t('sessionSharing.expiresIn')} + {t('session.sharing.expiresIn')} setExpiresInDays(7)} rightElement={ expiresInDays === 7 ? ( - + + + ) : ( ) } /> setExpiresInDays(30)} rightElement={ expiresInDays === 30 ? ( - + + + ) : ( ) } /> setExpiresInDays(undefined)} rightElement={ expiresInDays === undefined ? ( - + + + ) : ( ) @@ -134,36 +140,42 @@ export const PublicLinkDialog = memo(function PublicLinkDialog({ {/* Max uses */} - {t('sessionSharing.maxUses')} + {t('session.sharing.maxUses')} setMaxUses(undefined)} rightElement={ maxUses === undefined ? ( - + + + ) : ( ) } /> setMaxUses(10)} rightElement={ maxUses === 10 ? ( - + + + ) : ( ) } /> setMaxUses(50)} rightElement={ maxUses === 50 ? ( - + + + ) : ( ) @@ -174,8 +186,8 @@ export const PublicLinkDialog = memo(function PublicLinkDialog({ {/* Consent required */} {publicShare.expiresAt && ( )} ({ sectionTitle: { fontSize: 16, fontWeight: '600', - color: theme.colors.typography, + color: theme.colors.text, marginBottom: 12, }, radioSelected: { width: 20, height: 20, borderRadius: 10, - backgroundColor: theme.colors.primary, + backgroundColor: 'transparent', borderWidth: 2, - borderColor: theme.colors.primary, + borderColor: theme.colors.radio.active, + alignItems: 'center', + justifyContent: 'center', + }, + radioDot: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: theme.colors.radio.dot, }, radioUnselected: { width: 20, @@ -277,7 +297,7 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: 10, backgroundColor: 'transparent', borderWidth: 2, - borderColor: theme.colors.textSecondary, + borderColor: theme.colors.radio.inactive, }, existingShare: { padding: 16, @@ -286,12 +306,12 @@ const styles = StyleSheet.create((theme) => ({ alignItems: 'center', marginBottom: 24, padding: 16, - backgroundColor: theme.colors.background, + backgroundColor: theme.colors.surfaceHigh, borderRadius: 12, }, infoSection: { borderTopWidth: 1, - borderTopColor: theme.colors.border, + borderTopColor: theme.colors.divider, paddingTop: 16, }, })); diff --git a/sources/components/SessionSharing/SessionShareDialog.tsx b/sources/components/SessionSharing/SessionShareDialog.tsx index ac085b462..bfeabe65b 100644 --- a/sources/components/SessionSharing/SessionShareDialog.tsx +++ b/sources/components/SessionSharing/SessionShareDialog.tsx @@ -74,7 +74,6 @@ export const SessionShareDialog = memo(function SessionShareDialog({ @@ -152,9 +151,9 @@ const ShareItem = memo(function ShareItem({ onRemove }: ShareItemProps) { const accessLevelLabel = getAccessLevelLabel(share.accessLevel); - const userName = [share.sharedWithUser.firstName, share.sharedWithUser.lastName] + const userName = share.sharedWithUser.username || [share.sharedWithUser.firstName, share.sharedWithUser.lastName] .filter(Boolean) - .join(' ') || share.sharedWithUser.username; + .join(' '); return ( @@ -163,14 +162,13 @@ const ShareItem = memo(function ShareItem({ subtitle={accessLevelLabel} icon={ } onPress={canManage ? onPress : undefined} - chevron={canManage} + showChevron={canManage} /> {/* Access level options (shown when selected) */} @@ -224,7 +222,7 @@ const styles = StyleSheet.create((theme) => ({ width: 600, maxWidth: '90%', maxHeight: '80%', - backgroundColor: theme.colors.background, + backgroundColor: theme.colors.surface, borderRadius: 12, overflow: 'hidden', }, @@ -232,41 +230,41 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingHorizontal: theme.margins.md, - paddingVertical: theme.margins.sm, + paddingHorizontal: 16, + paddingVertical: 12, borderBottomWidth: 1, - borderBottomColor: theme.colors.separator, + borderBottomColor: theme.colors.divider, }, title: { fontSize: 18, fontWeight: '600', - color: theme.colors.typography, + color: theme.colors.text, }, content: { flex: 1, }, section: { - marginTop: theme.margins.md, + marginTop: 16, }, sectionTitle: { fontSize: 14, fontWeight: '600', - color: theme.colors.secondaryTypography, - paddingHorizontal: theme.margins.md, - paddingVertical: theme.margins.sm, + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingVertical: 8, textTransform: 'uppercase', }, options: { - paddingLeft: theme.margins.lg, - backgroundColor: theme.colors.secondaryBackground, + paddingLeft: 24, + backgroundColor: theme.colors.surfaceHigh, }, emptyState: { - padding: theme.margins.lg, + padding: 32, alignItems: 'center', }, emptyText: { fontSize: 16, - color: theme.colors.secondaryTypography, + color: theme.colors.textSecondary, textAlign: 'center', }, })); From 1b1f6caf2300c467a3a12935c9d6fb0394645b47 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 11:54:23 +0900 Subject: [PATCH 27/39] add: Add missing translation keys for session sharing Added translation keys for share error messages, public link options, and consent flow. Includes shareNotFound, shareExpired, failedToDecrypt, and various UI labels. - sources/text/_default.ts --- sources/text/_default.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/sources/text/_default.ts b/sources/text/_default.ts index cdf25c303..a06d4c0d1 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -229,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 }) => @@ -329,6 +330,25 @@ export const en = { 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`, }, }, From 22992def49d1923c79985011866fe574789af4d3 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 11:54:36 +0900 Subject: [PATCH 28/39] fix: Update sharing screen implementations Fixed component props to match actual interfaces (Avatar, Item). Updated translation key format from sessionSharing to session.sharing. Prioritized username display over firstName+lastName concatenation. - sources/app/(app)/session/[id]/sharing.tsx - sources/app/(app)/share/[token].tsx --- sources/app/(app)/session/[id]/sharing.tsx | 9 ++++---- sources/app/(app)/share/[token].tsx | 26 +++++++++++----------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/sources/app/(app)/session/[id]/sharing.tsx b/sources/app/(app)/session/[id]/sharing.tsx index f0bc7d9e8..a95cb9081 100644 --- a/sources/app/(app)/session/[id]/sharing.tsx +++ b/sources/app/(app)/session/[id]/sharing.tsx @@ -63,7 +63,7 @@ function SharingManagementContent({ sessionId }: { sessionId: string }) { // Load friends list const friendsData = await getFriendsList(credentials); - setFriends(friendsData.friends); + setFriends(friendsData); } catch (error) { console.error('Failed to load sharing data:', error); } @@ -196,15 +196,15 @@ function SharingManagementContent({ sessionId }: { sessionId: string }) { shares.map(share => ( } onPress={() => setShowShareDialog(true)} /> )) ) : ( } showChevron={false} /> @@ -266,7 +266,6 @@ function SharingManagementContent({ sessionId }: { sessionId: string }) { friends={friends} excludedUserIds={excludedUserIds} onSelect={handleAddShare} - onCancel={() => setShowFriendSelector(false)} /> )} diff --git a/sources/app/(app)/share/[token].tsx b/sources/app/(app)/share/[token].tsx index 4d269f898..e0c995279 100644 --- a/sources/app/(app)/share/[token].tsx +++ b/sources/app/(app)/share/[token].tsx @@ -60,7 +60,7 @@ export default function PublicShareAccessScreen() { if (!response.ok) { if (response.status === 404) { - setError(t('sessionSharing.shareNotFound')); + setError(t('session.sharing.shareNotFound')); setLoading(false); return; } else if (response.status === 403) { @@ -76,7 +76,7 @@ export default function PublicShareAccessScreen() { setLoading(false); return; } - setError(t('sessionSharing.shareExpired')); + setError(t('session.sharing.shareExpired')); setLoading(false); return; } else { @@ -95,7 +95,7 @@ export default function PublicShareAccessScreen() { ); if (!decryptedKey) { - setError(t('sessionSharing.failedToDecrypt')); + setError(t('session.sharing.failedToDecrypt')); setLoading(false); return; } @@ -127,8 +127,8 @@ export default function PublicShareAccessScreen() { if (loading) { return ( - - + + {t('common.loading')} @@ -138,8 +138,8 @@ export default function PublicShareAccessScreen() { if (error) { return ( - - + + {t('common.error')} @@ -152,23 +152,23 @@ export default function PublicShareAccessScreen() { if (shareInfo && shareInfo.requiresConsent) { return ( - + - + } showChevron={false} /> } onPress={handleAcceptConsent} /> From 9b973d860ccc980079c714466aef17926ed68b99 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 12:02:44 +0900 Subject: [PATCH 29/39] add: Add session sharing translations for all languages Added missing translation keys for session sharing feature. Includes error messages, public link options, and consent flow strings. - sources/text/translations/ru.ts - sources/text/translations/pl.ts - sources/text/translations/es.ts - sources/text/translations/pt.ts - sources/text/translations/ca.ts - sources/text/translations/it.ts - sources/text/translations/ja.ts - sources/text/translations/zh-Hans.ts --- sources/text/translations/ca.ts | 1 + sources/text/translations/es.ts | 1 + sources/text/translations/it.ts | 1 + sources/text/translations/ja.ts | 1 + sources/text/translations/pl.ts | 1 + sources/text/translations/pt.ts | 1 + sources/text/translations/ru.ts | 1 + sources/text/translations/zh-Hans.ts | 1 + 8 files changed, 8 insertions(+) diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 49aeb73e8..2c370861a 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -255,6 +255,7 @@ export const ca: TranslationStructure = { 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: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 3f481c3cc..3ded6c237 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -255,6 +255,7 @@ export const es: TranslationStructure = { 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: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 480e42da3..740b7c11b 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -252,6 +252,7 @@ export const it: TranslationStructure = { 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: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 266a75458..9f6b54c3f 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -255,6 +255,7 @@ export const ja: TranslationStructure = { publicShareNotFound: '公開共有が見つからないか期限切れです', consentRequired: 'アクセスには同意が必要です', maxUsesReached: '最大使用回数に達しました', + invalidShareLink: '無効または期限切れの共有リンク', }, newSession: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 6c1c1dffe..ed697ff34 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -266,6 +266,7 @@ export const pl: TranslationStructure = { 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: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 40b50ba38..1ab53deea 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -255,6 +255,7 @@ export const pt: TranslationStructure = { 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: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 2bff28349..87700317d 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -238,6 +238,7 @@ export const ru: TranslationStructure = { publicShareNotFound: 'Публичная ссылка не найдена или истекла', consentRequired: 'Требуется согласие для доступа', maxUsesReached: 'Достигнут лимит использований', + invalidShareLink: 'Недействительная или просроченная ссылка для обмена', }, newSession: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index fea092ed2..825b3d865 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -257,6 +257,7 @@ export const zhHans: TranslationStructure = { publicShareNotFound: '公开分享未找到或已过期', consentRequired: '需要同意才能访问', maxUsesReached: '已达到最大使用次数', + invalidShareLink: '无效或已过期的共享链接', }, newSession: { From 64625d0c03975d6ffab7de585aba9a1b1e0f4efd Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 12:08:08 +0900 Subject: [PATCH 30/39] add: Complete session.sharing translations for all languages Added remaining translation keys for public link options, error messages, and consent flow. Includes expiration options, usage limits, and dynamic count functions. - sources/text/translations/ru.ts - sources/text/translations/pl.ts - sources/text/translations/es.ts - sources/text/translations/pt.ts - sources/text/translations/ca.ts - sources/text/translations/it.ts - sources/text/translations/ja.ts - sources/text/translations/zh-Hans.ts --- sources/text/translations/ca.ts | 19 +++++++++++++++++++ sources/text/translations/es.ts | 19 +++++++++++++++++++ sources/text/translations/it.ts | 19 +++++++++++++++++++ sources/text/translations/ja.ts | 19 +++++++++++++++++++ sources/text/translations/pl.ts | 19 +++++++++++++++++++ sources/text/translations/pt.ts | 19 +++++++++++++++++++ sources/text/translations/ru.ts | 19 +++++++++++++++++++ sources/text/translations/zh-Hans.ts | 19 +++++++++++++++++++ 8 files changed, 152 insertions(+) diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 2c370861a..d0ec30f4b 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -330,6 +330,25 @@ export const ca: TranslationStructure = { 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`, }, }, diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 3ded6c237..114de8e95 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -330,6 +330,25 @@ export const es: TranslationStructure = { 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`, }, }, diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 740b7c11b..272635e40 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -327,6 +327,25 @@ export const it: TranslationStructure = { 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`, }, }, diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 9f6b54c3f..bda802e21 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -330,6 +330,25 @@ export const ja: TranslationStructure = { 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} 回使用`, }, }, diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index ed697ff34..fac79bb45 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -341,6 +341,25 @@ export const pl: TranslationStructure = { 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ć`, }, }, diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 1ab53deea..8ee8bdfa3 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -330,6 +330,25 @@ export const pt: TranslationStructure = { 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`, }, }, diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 87700317d..a64a124ca 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -424,6 +424,25 @@ export const ru: TranslationStructure = { 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} использований`, }, }, diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 825b3d865..fa58bb7e9 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -332,6 +332,25 @@ export const zhHans: TranslationStructure = { 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} 次使用`, }, }, From 03e57ff33f823a7f2042479ddad07986e3fbf96b Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 12:12:34 +0900 Subject: [PATCH 31/39] fix: Correct translation function parameters Fixed parameter names in translation function calls. Changed count->used for usage count display functions. Changed common.close->common.cancel for consistency. Removed non-existent ownerUsername property reference. - sources/app/(app)/share/[token].tsx - sources/components/SessionSharing/PublicLinkDialog.tsx - sources/components/SessionSharing/SessionShareDialog.tsx --- .idea/.gitignore | 8 ++++++++ .idea/bright-cloud.iml | 12 ++++++++++++ .idea/modules.xml | 8 ++++++++ .idea/vcs.xml | 6 ++++++ sources/app/(app)/share/[token].tsx | 1 - .../components/SessionSharing/PublicLinkDialog.tsx | 4 ++-- .../components/SessionSharing/SessionShareDialog.tsx | 2 +- 7 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/bright-cloud.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..13566b81b --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/bright-cloud.iml b/.idea/bright-cloud.iml new file mode 100644 index 000000000..24643cc37 --- /dev/null +++ b/.idea/bright-cloud.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..00fc1a22c --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/sources/app/(app)/share/[token].tsx b/sources/app/(app)/share/[token].tsx index e0c995279..a19c05447 100644 --- a/sources/app/(app)/share/[token].tsx +++ b/sources/app/(app)/share/[token].tsx @@ -157,7 +157,6 @@ export default function PublicShareAccessScreen() { } showChevron={false} /> diff --git a/sources/components/SessionSharing/PublicLinkDialog.tsx b/sources/components/SessionSharing/PublicLinkDialog.tsx index 7550f4678..ed59068aa 100644 --- a/sources/components/SessionSharing/PublicLinkDialog.tsx +++ b/sources/components/SessionSharing/PublicLinkDialog.tsx @@ -229,11 +229,11 @@ export const PublicLinkDialog = memo(function PublicLinkDialog({ subtitle={ publicShare.maxUses ? t('session.sharing.usageCountWithMax', { - count: publicShare.useCount, + used: publicShare.useCount, max: publicShare.maxUses, }) : t('session.sharing.usageCountUnlimited', { - count: publicShare.useCount, + used: publicShare.useCount, }) } /> diff --git a/sources/components/SessionSharing/SessionShareDialog.tsx b/sources/components/SessionSharing/SessionShareDialog.tsx index bfeabe65b..a01b4c519 100644 --- a/sources/components/SessionSharing/SessionShareDialog.tsx +++ b/sources/components/SessionSharing/SessionShareDialog.tsx @@ -72,7 +72,7 @@ export const SessionShareDialog = memo(function SessionShareDialog({ {t('session.sharing.title')} From e8f5ba454a7a3ec1b09f7eb0b334c7fa37b07754 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:09:53 +0900 Subject: [PATCH 32/39] refactor: Use `getServerUrl` directly in sharing screen Replaced `sync.getServerUrl` with direct `getServerUrl` call. Removed redundant `getServerUrl` method from `sync`. - sources/app/(app)/share/[token].tsx - sources/sync/sync.ts --- sources/app/(app)/share/[token].tsx | 3 ++- sources/sync/sync.ts | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/sources/app/(app)/share/[token].tsx b/sources/app/(app)/share/[token].tsx index a19c05447..6a02e105e 100644 --- a/sources/app/(app)/share/[token].tsx +++ b/sources/app/(app)/share/[token].tsx @@ -10,6 +10,7 @@ import { Modal } from '@/modal'; 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 @@ -45,7 +46,7 @@ export default function PublicShareAccessScreen() { setError(null); const credentials = sync.getCredentials(); - const serverUrl = sync.getServerUrl(); + const serverUrl = getServerUrl(); // Build URL with consent parameter if user has accepted const url = withConsent diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index 10a7bf25b..a141bdf93 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -673,10 +673,6 @@ class Sync { return this.encryption.contentDataKey; } - public getServerUrl(): string { - return getServerUrl(); - } - // Artifact methods public fetchArtifactsList = async (): Promise => { log.log('📦 fetchArtifactsList: Starting artifact sync'); From 5be0cc6e66f0e2eea329b8ad85ca360e09486ca7 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:10:57 +0900 Subject: [PATCH 33/39] refactor: delete unnecessary things Remove unused Modal import from sharing screen - sources/app/(app)/share/[token].tsx --- sources/app/(app)/share/[token].tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/sources/app/(app)/share/[token].tsx b/sources/app/(app)/share/[token].tsx index 6a02e105e..8bf025c85 100644 --- a/sources/app/(app)/share/[token].tsx +++ b/sources/app/(app)/share/[token].tsx @@ -6,7 +6,6 @@ import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; import { useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; -import { Modal } from '@/modal'; import { sync } from '@/sync/sync'; import { decryptDataKeyFromPublicShare } from '@/sync/publicShareEncryption'; import { Ionicons } from '@expo/vector-icons'; From 97510cd062a55a1690e58df120711afc4c59980c Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:38:04 +0900 Subject: [PATCH 34/39] refactor: Simplify PublicLinkDialog layout and styling Replaced `ItemGroup` with `ItemList` for better structure. Adjusted styles and removed unused `isCreating` state logic. Consolidated common sections and updated QR code dimensions for consistency. - sources/components/SessionSharing/PublicLinkDialog.tsx --- .../SessionSharing/PublicLinkDialog.tsx | 178 +++++++++++------- 1 file changed, 108 insertions(+), 70 deletions(-) diff --git a/sources/components/SessionSharing/PublicLinkDialog.tsx b/sources/components/SessionSharing/PublicLinkDialog.tsx index ed59068aa..1675ed37d 100644 --- a/sources/components/SessionSharing/PublicLinkDialog.tsx +++ b/sources/components/SessionSharing/PublicLinkDialog.tsx @@ -5,7 +5,7 @@ import QRCode from 'qrcode'; import { Image } from 'expo-image'; import { PublicSessionShare } from '@/sync/sharingTypes'; import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; +import { ItemList } from '@/components/ItemList'; import { t } from '@/text'; import { getServerUrl } from '@/sync/serverConfig'; @@ -58,7 +58,7 @@ export const PublicLinkDialog = memo(function PublicLinkDialog({ const url = `${serverUrl}/share/${publicShare.token}`; QRCode.toDataURL(url, { - width: 300, + width: 250, margin: 2, color: { dark: '#000000', @@ -75,7 +75,6 @@ export const PublicLinkDialog = memo(function PublicLinkDialog({ maxUses, isConsentRequired, }); - setIsCreating(false); }; const formatDate = (timestamp: number) => { @@ -84,16 +83,24 @@ export const PublicLinkDialog = memo(function PublicLinkDialog({ return ( - {isCreating ? ( - // Create new public share form - + + {t('session.sharing.publicLink')} + + + + + {isCreating ? ( + {t('session.sharing.publicLinkDescription')} {/* Expiration */} - - + + {t('session.sharing.expiresIn')} {/* Max uses */} - - - {t('session.sharing.maxUses')} + + + {t('session.sharing.maxUsesLabel')} - {/* Consent required */} - + {/* Consent */} + - + + {/* Create button */} + + + + ) : publicShare ? ( - // Display existing public share - + {/* QR Code */} {qrDataUrl && ( )} - {/* Link info */} - + {/* Info */} + + {publicShare.expiresAt && ( - {publicShare.expiresAt && ( - - )} - + )} + + + + {/* Delete button */} + - + ) : null} + ); }); const styles = StyleSheet.create((theme) => ({ container: { - minHeight: 400, - maxHeight: 600, + width: 600, + maxWidth: '90%', + maxHeight: '80%', + backgroundColor: theme.colors.surface, + borderRadius: 12, + overflow: 'hidden', }, - createForm: { - padding: 16, + 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, - marginBottom: 24, + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 8, lineHeight: 20, }, - section: { - marginBottom: 24, + optionGroup: { + marginTop: 16, }, - sectionTitle: { - fontSize: 16, + groupTitle: { + fontSize: 14, fontWeight: '600', - color: theme.colors.text, - marginBottom: 12, + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingBottom: 8, + textTransform: 'uppercase', }, radioSelected: { width: 20, @@ -299,19 +343,13 @@ const styles = StyleSheet.create((theme) => ({ borderWidth: 2, borderColor: theme.colors.radio.inactive, }, - existingShare: { - padding: 16, - }, qrContainer: { alignItems: 'center', - marginBottom: 24, - padding: 16, - backgroundColor: theme.colors.surfaceHigh, - borderRadius: 12, + padding: 24, + backgroundColor: theme.colors.surface, }, - infoSection: { - borderTopWidth: 1, - borderTopColor: theme.colors.divider, - paddingTop: 16, + buttonContainer: { + marginTop: 24, + marginBottom: 16, }, })); From 3f4cf956dbe0c44991436cf8bd6835f84aa92eaa Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:38:29 +0900 Subject: [PATCH 35/39] refactor: Replace icons with Ionicons components Updated icons in SessionShareDialog to use Ionicons for consistency and styling improvements. Adjusted size and color to align with design guidelines. - sources/components/SessionSharing/SessionShareDialog.tsx --- sources/components/SessionSharing/SessionShareDialog.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sources/components/SessionSharing/SessionShareDialog.tsx b/sources/components/SessionSharing/SessionShareDialog.tsx index a01b4c519..550996376 100644 --- a/sources/components/SessionSharing/SessionShareDialog.tsx +++ b/sources/components/SessionSharing/SessionShareDialog.tsx @@ -1,6 +1,7 @@ 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'; @@ -83,7 +84,7 @@ export const SessionShareDialog = memo(function SessionShareDialog({ {canManage && ( } onPress={onAddShare} /> )} @@ -92,7 +93,7 @@ export const SessionShareDialog = memo(function SessionShareDialog({ {canManage && ( } onPress={onManagePublicLink} /> )} From 40ac3e2dbc8d7df8bad6c801c049bc58ebb147f3 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:33:43 +0900 Subject: [PATCH 36/39] feat: Add session sharing event schemas Add three event schemas for real-time session sharing notifications. These schemas enable clients to receive updates when sessions are shared, share permissions are modified, or shares are revoked. - sources/sync/apiTypes.ts --- sources/sync/apiTypes.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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; From 6c10d014efc21894cefa18b4014f24e89049baa1 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 22:32:43 +0900 Subject: [PATCH 37/39] update: Improve public link creation button Replace plain Item component with RoundButton for the create button. Makes the primary action more visible and easier to recognize in the dialog. - sources/components/SessionSharing/PublicLinkDialog.tsx --- sources/components/SessionSharing/PublicLinkDialog.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sources/components/SessionSharing/PublicLinkDialog.tsx b/sources/components/SessionSharing/PublicLinkDialog.tsx index 1675ed37d..61404ad6f 100644 --- a/sources/components/SessionSharing/PublicLinkDialog.tsx +++ b/sources/components/SessionSharing/PublicLinkDialog.tsx @@ -6,6 +6,7 @@ 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'; @@ -206,9 +207,11 @@ export const PublicLinkDialog = memo(function PublicLinkDialog({ {/* Create button */} - @@ -351,5 +354,7 @@ const styles = StyleSheet.create((theme) => ({ buttonContainer: { marginTop: 24, marginBottom: 16, + paddingHorizontal: 16, + alignItems: 'center', }, })); From 83b268f51b6ada49d013457cfc33ae1a8a0de646 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 22:59:15 +0900 Subject: [PATCH 38/39] delete: unnecessary files delete: unnecessary file delete: unnecessary file delete: unnecessary file --- .idea/.gitignore | 8 -------- .idea/bright-cloud.iml | 12 ------------ .idea/modules.xml | 8 -------- .idea/vcs.xml | 6 ------ 4 files changed, 34 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/bright-cloud.iml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b81b..000000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/bright-cloud.iml b/.idea/bright-cloud.iml deleted file mode 100644 index 24643cc37..000000000 --- a/.idea/bright-cloud.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 00fc1a22c..000000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddfb..000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From a7a2a6fff75f35d5184eee416f2cd96011ed5cf0 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 23:19:11 +0900 Subject: [PATCH 39/39] update: add .idea --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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/*