Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions apps/fluux/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useToastStore } from './stores/toastStore'
import { UnlockEncryptionDialog } from './components/UnlockEncryptionDialog'
import { IdentityChoiceDialog } from './components/IdentityChoiceDialog'
import { RestorePassphraseDialog } from './components/RestorePassphraseDialog'
import { useWebUnlockDialogStore } from './stores/webUnlockDialogStore'
import { detectRenderLoop } from '@/utils/renderLoopDetector'
import { LoginScreen } from './components/LoginScreen'
import { ChatLayout } from './components/ChatLayout'
Expand Down Expand Up @@ -154,7 +155,9 @@ function App() {
// Used to distinguish initial page load reconnect from wake-from-sleep reconnect
const [hasBeenOnline, setHasBeenOnline] = useState(false)

const [showWebUnlockDialog, setShowWebUnlockDialog] = useState(false)
const showWebUnlockDialog = useWebUnlockDialogStore((s) => s.isOpen)
const openWebUnlockDialog = useWebUnlockDialogStore((s) => s.openWebUnlockDialog)
const closeWebUnlockDialog = useWebUnlockDialogStore((s) => s.closeWebUnlockDialog)
// Set when auto-init detects a server-side OpenPGP identity for this
// account but no local key. Forces the user through IdentityChoiceDialog
// instead of the standard unlock dialog (which would otherwise reach
Expand Down Expand Up @@ -226,7 +229,7 @@ function App() {
}
}
if (isKeyLocked()) {
setShowWebUnlockDialog(true)
openWebUnlockDialog()
}
})
} else if (status !== 'connecting') {
Expand All @@ -237,7 +240,7 @@ function App() {
setIsAutoReconnecting(false)
}
}
}, [status, client, jid])
}, [status, client, jid, openWebUnlockDialog])

// --- Identity-choice handlers (web first-login safety net) ---
// Each resolves `pendingIdentityChoice` with one explicit recovery
Expand Down Expand Up @@ -395,7 +398,7 @@ function App() {
{showWebUnlockDialog && (
<UnlockEncryptionDialog
client={client}
onClose={() => setShowWebUnlockDialog(false)}
onClose={() => closeWebUnlockDialog()}
/>
)}
{pendingIdentityChoice && (
Expand Down
7 changes: 7 additions & 0 deletions apps/fluux/src/components/BackupPassphraseDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Copy, Check, AlertTriangle, Loader2, RefreshCw } from 'lucide-react'
import { generateBackupPassphrase, generateBackupCode, USE_V6_KEYS } from '@/e2ee/passphraseGenerator'
import { SaveToPasswordManagerButton } from './SaveToPasswordManagerButton'

// Draw a fresh passphrase in the user's UI language. 8 words ×
// 11 bits (BIP-39) = 88 bits, which matches the acceptability gate
Expand Down Expand Up @@ -218,6 +219,12 @@ export function BackupPassphraseDialog({
? t('settings.encryption.backupCopied')
: t('settings.encryption.backupCopy')}
</button>
<SaveToPasswordManagerButton
id="openpgp-passphrase"
name={t('settings.encryption.savePassphraseManagerLabel')}
passphrase={passphrase}
disabled={isPublishing}
/>
<button
onClick={handleRegenerate}
disabled={isPublishing || !passphrase}
Expand Down
31 changes: 31 additions & 0 deletions apps/fluux/src/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getTranslatedStatusText } from '@/utils/statusText'
import { Tooltip } from './Tooltip'
import { ArrowLeft, Clock, Hash, Lock, LockOpen, Loader2, Search, ShieldAlert, ShieldCheck, ShieldOff, ShieldX } from 'lucide-react'
import type { ConversationEncryptionState } from '@/hooks/useConversationEncryptionState'
import { useWebUnlockDialogStore } from '@/stores/webUnlockDialogStore'

export interface ChatHeaderProps {
name: string
Expand Down Expand Up @@ -156,6 +157,32 @@ function formatFingerprint(fp: string): string {
return fp.match(/.{1,4}/g)?.join(' ') ?? fp
}

function KeyLockedIcon({ fingerprint }: { fingerprint?: string }) {
const { t } = useTranslation()
const openWebUnlockDialog = useWebUnlockDialogStore((s) => s.openWebUnlockDialog)
const btnClass = 'p-1.5 rounded transition-colors'
const tooltip = (
<div>
<div>{t('chat.encryption.keyLockedTooltip')}</div>
{fingerprint && (
<div className="font-mono text-xs mt-0.5 opacity-75">{formatFingerprint(fingerprint)}</div>
)}
</div>
)
return (
<Tooltip content={tooltip} position="bottom">
<button
type="button"
onClick={() => openWebUnlockDialog()}
className={`${btnClass} text-yellow-500 hover:text-yellow-600 cursor-pointer`}
aria-label={t('chat.encryption.keyLocked')}
>
<Lock className="w-4 h-4" />
</button>
</Tooltip>
)
}

function EncryptionIcon({
state,
peerName,
Expand Down Expand Up @@ -212,6 +239,10 @@ function EncryptionIcon({
)
}

if (state.kind === 'keyLocked') {
return <KeyLockedIcon fingerprint={state.fingerprint} />
}

if (state.kind === 'rejected') {
return (
<div ref={containerRef} className="relative">
Expand Down
15 changes: 15 additions & 0 deletions apps/fluux/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { MessageBubble, MessageList as MessageListComponent, shouldShowAvatar, b
import { FindOnPageBar } from './conversation/FindOnPageBar'
import { useFindOnPage, type FindOnPageHandle } from '@/hooks/useFindOnPage'
import { useConversationEncryptionState, type ConversationEncryptionState } from '@/hooks/useConversationEncryptionState'
import { useWebUnlockDialogStore } from '@/stores/webUnlockDialogStore'
import { ChristmasAnimation } from './ChristmasAnimation'
import { ChatHeader } from './ChatHeader'
import { MessageComposer, type ReplyInfo, type EditInfo, type MessageComposerHandle, type PendingAttachment } from './MessageComposer'
Expand Down Expand Up @@ -945,6 +946,7 @@ function MessageInput({
}) {
const { t } = useTranslation()
const { sendMessage, sendChatState, isArchived, unarchiveConversation, setDraft, getDraft, clearDraft, clearFirstNewMessageId } = useChatActive()
const openWebUnlockDialog = useWebUnlockDialogStore((s) => s.openWebUnlockDialog)

// Draft persistence - saves on conversation change, restores on load
const [text, setText] = useConversationDraft({
Expand Down Expand Up @@ -983,6 +985,19 @@ function MessageInput({
}

const handleSend = async (text: string): Promise<boolean> => {
// Refuse to send while the local OpenPGP key is locked for an
// encrypted conversation. Without the guard, the file upload would
// happen plaintext-or-ciphertext-with-no-recipient (depending on
// `encryptAttachment` below) and the send would fail at encrypt
// time, leaving an orphaned upload on the server. Opening the
// unlock dialog routes the user to the passphrase prompt; the
// typed text stays in the composer (we return `false` here, which
// skips the `setText('')` clear in MessageComposer).
if (encryptionState.kind === 'keyLocked') {
openWebUnlockDialog()
return false
}

// Unarchive conversation if archived (user is actively chatting)
// and switch to Messages view to see it in the main list
if (type === 'chat' && isArchived(conversationId)) {
Expand Down
81 changes: 81 additions & 0 deletions apps/fluux/src/components/SaveToPasswordManagerButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { KeyRound, Check } from 'lucide-react'
import { saveCredentialToManager } from '@/utils/saveCredentialToManager'
import { USE_V6_KEYS } from '@/e2ee/passphraseGenerator'
import { isTauri } from '@/utils/tauri'

interface SaveToPasswordManagerButtonProps {
/** Stable credential identifier; matches the dialog's hidden username so save and autofill share an entry. */
id: string
/** Human-readable label persisted in the PM entry list. Selected in the user's UI language. */
name: string
/** Passphrase to save. Button is disabled while this is null. */
passphrase: string | null
/** External disable (e.g. while the dialog is publishing). */
disabled?: boolean
}

/**
* Explicit "save to password manager" affordance for Tauri.
*
* Web is intentionally not handled here — the hidden form inputs +
* `autoComplete="new-password"` already drive the browser PM detection on
* form submission, so a button would be redundant. In Tauri the embedded
* webview doesn't reliably trigger that detection, hence this explicit path.
*
* Gated on `USE_V6_KEYS`: V4 backup codes don't fit the PM model because
* the restore flow uses a formatted input with `autoComplete="off"`, so we
* don't offer to save them. Returns null in V4 mode and on the web.
*/
export function SaveToPasswordManagerButton({
id,
name,
passphrase,
disabled,
}: SaveToPasswordManagerButtonProps) {
const { t } = useTranslation()
const [feedback, setFeedback] = useState<'saved' | 'fallback' | null>(null)

const handleClick = useCallback(async () => {
if (!passphrase) return
const outcome = await saveCredentialToManager({ id, name, password: passphrase })
if (outcome === 'saved') {
setFeedback('saved')
} else {
try {
await navigator.clipboard.writeText(passphrase)
} catch {
// Clipboard may also be unavailable (no user gesture, secure context, etc.);
// we still show the fallback hint so the user knows the save didn't go through.
}
setFeedback('fallback')
}
setTimeout(() => setFeedback(null), 2000)
}, [id, name, passphrase])

if (!isTauri() || !USE_V6_KEYS) return null

const isFallback = feedback === 'fallback'
const isSaved = feedback === 'saved'

return (
<button
type="button"
onClick={handleClick}
disabled={disabled || !passphrase}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-sm text-fluux-text bg-fluux-hover hover:bg-fluux-active rounded-lg transition-colors disabled:opacity-50"
>
{isSaved ? (
<Check className="w-3.5 h-3.5 text-green-500" />
) : (
<KeyRound className="w-3.5 h-3.5" />
)}
{isSaved
? t('settings.encryption.savePassphraseManagerSaved')
: isFallback
? t('settings.encryption.savePassphraseManagerFallback')
: t('settings.encryption.savePassphraseManager')}
</button>
)
}
9 changes: 7 additions & 2 deletions apps/fluux/src/components/UnlockEncryptionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ export function UnlockEncryptionDialog({ client, onClose }: UnlockEncryptionDial
return (
<div
data-modal="true"
role="dialog"
aria-modal="true"
aria-labelledby="unlock-encryption-dialog-title"
aria-describedby="unlock-encryption-dialog-body"
aria-busy={loading || isWorking || undefined}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onMouseDown={(e) => {
mouseDownTargetRef.current = e.target
Expand All @@ -127,10 +132,10 @@ export function UnlockEncryptionDialog({ client, onClose }: UnlockEncryptionDial
{/* Hidden username distinguishes this entry from the XMPP login in password managers. */}
<input type="text" name="username" autoComplete="section-openpgp username" value="openpgp-passphrase" readOnly aria-hidden="true" className="hidden" />
<div className="px-5 pt-5 pb-3">
<h3 className="text-lg font-semibold text-fluux-text mb-1">
<h3 id="unlock-encryption-dialog-title" className="text-lg font-semibold text-fluux-text mb-1">
{loading ? ' ' : title}
</h3>
<p className="text-sm text-fluux-muted">{loading ? ' ' : body}</p>
<p id="unlock-encryption-dialog-body" className="text-sm text-fluux-muted">{loading ? ' ' : body}</p>
</div>

<div className="flex-1 overflow-y-auto min-h-0 px-5">
Expand Down
61 changes: 61 additions & 0 deletions apps/fluux/src/components/conversation/EncryptedPlaceholder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Lock, LockOpen } from 'lucide-react'
import { useWebKeyLocked } from '@/hooks/useWebKeyLocked'
import { useWebUnlockDialogStore } from '@/stores/webUnlockDialogStore'

export interface EncryptedPlaceholderProps {
/**
* `true` when the failure was almost certainly a wrong-key situation
* (we tried to decrypt and the cipher rejected the unlocked private
* key). The placeholder still renders, but the click affordance is
* dropped — unlocking again won't help.
*
* Left optional for now; consumers don't yet distinguish.
*/
hardFailure?: boolean
}

/**
* Rendered inside a message bubble when {@link BaseMessage.encryptedPayload}
* is set — meaning the SDK received an E2EE-claimed stanza but could not
* decrypt it. Two visual states:
*
* - **Locked** (`useWebKeyLocked()` is true): a click prompts for the
* session passphrase. Once unlocked, the SDK's
* `retryPendingDecrypts()` re-runs and the placeholder is replaced
* by the real body on success.
* - **Unlocked**: the cipher rejected the unlocked key (revoked
* identity, wrong recipient, corrupt payload). The placeholder is
* static — clicking again won't help.
*/
export const EncryptedPlaceholder = memo(function EncryptedPlaceholder(
_props: EncryptedPlaceholderProps,
) {
const { t } = useTranslation()
const locked = useWebKeyLocked()
const openWebUnlockDialog = useWebUnlockDialogStore((s) => s.openWebUnlockDialog)

if (locked) {
return (
<button
type="button"
onClick={() => openWebUnlockDialog()}
className="flex items-center gap-2 text-fluux-muted italic hover:text-fluux-text transition-colors cursor-pointer text-start"
aria-label={t('chat.encryption.encryptedClickToUnlock')}
>
<Lock className="w-3.5 h-3.5 flex-shrink-0 text-yellow-500" aria-hidden="true" />
<span className="underline underline-offset-2 decoration-dotted">
{t('chat.encryption.encryptedClickToUnlock')}
</span>
</button>
)
}

return (
<div className="flex items-center gap-2 text-fluux-muted italic">
<LockOpen className="w-3.5 h-3.5 flex-shrink-0" aria-hidden="true" />
<span>{t('chat.encryption.encryptedCouldNotDecrypt')}</span>
</div>
)
})
44 changes: 25 additions & 19 deletions apps/fluux/src/components/conversation/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Avatar } from '../Avatar'
import { AvatarLightbox } from '../AvatarLightbox'
import { MessageToolbar } from './MessageToolbar'
import { MessageBody } from './MessageBody'
import { EncryptedPlaceholder } from './EncryptedPlaceholder'
import { MessageReactions } from './MessageReactions'
import { scrollToMessage, isActionMessage } from './messageGrouping'
import { MessageAttachments } from '../MessageAttachments'
Expand Down Expand Up @@ -446,25 +447,30 @@ export const MessageBubble = memo(function MessageBubble({

{/* Collapsible wrapper for long messages */}
<CollapsibleContent messageId={message.id} isSelected={isSelected} isHovered={isHovered}>
{/* Message body (SDK already strips OOB URL from body for non-XEP-0428 clients) */}
<MessageBody
body={message.body}
isEdited={message.isEdited}
originalBody={message.originalBody}
isRetracted={message.isRetracted}
isModerated={message.isModerated}
moderatedBy={message.moderatedBy}
moderationReason={message.moderationReason}
noStyling={message.noStyling}
senderName={senderName}
senderColor={senderColor}
mentions={mentions}
nickname={nickname}
knownNicks={knownNicks}
isDarkMode={isDarkMode}
highlightTerms={highlightTerms}
isCurrentMatch={isCurrentMatch}
/>
{/* Encrypted-payload placeholder takes precedence over body text
so the SDK's English fallback string never reaches the UI. */}
{message.encryptedPayload ? (
<EncryptedPlaceholder />
) : (
<MessageBody
body={message.body}
isEdited={message.isEdited}
originalBody={message.originalBody}
isRetracted={message.isRetracted}
isModerated={message.isModerated}
moderatedBy={message.moderatedBy}
moderationReason={message.moderationReason}
noStyling={message.noStyling}
senderName={senderName}
senderColor={senderColor}
mentions={mentions}
nickname={nickname}
knownNicks={knownNicks}
isDarkMode={isDarkMode}
highlightTerms={highlightTerms}
isCurrentMatch={isCurrentMatch}
/>
)}

{/* File attachments (image, video, audio, text preview, document card) - hidden for retracted */}
{!message.isRetracted && <MessageAttachments attachment={message.attachment} onMediaLoad={onMediaLoad} isSelected={isSelected} isHovered={isHovered} />}
Expand Down
Loading
Loading