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
1,709 changes: 361 additions & 1,348 deletions components/compose/compose-modal.tsx

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions components/compose/thread-post-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { FormatButton, CharacterCounter } from './compose-sub-components'
import { MentionAutocomplete } from './mention-autocomplete'
import { EmojiPicker } from './emoji-picker'

const CHARACTER_LIMIT = 500
import { CHARACTER_LIMIT } from '@/lib/compose/limits'

interface ThreadPostEditorProps {
post: ThreadPost
Expand Down Expand Up @@ -403,4 +403,3 @@ export function ThreadPostEditor({
)
}

export { CHARACTER_LIMIT }
124 changes: 124 additions & 0 deletions hooks/use-compose-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
'use client'

import { useCallback, useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { logger } from '@/lib/logger'
import { useImageUpload } from '@/hooks/use-image-upload'
import type { UploadResult } from '@/lib/upload'

export interface AttachedImage {
file: File
/** Object URL for the preview; revoked when the image is dropped. */
preview: string
uploadResult?: UploadResult
}

const MAX_IMAGE_BYTES = 10 * 1024 * 1024

/**
* The composer's single image attachment: pick or paste a file, upload it in
* the background, keep the preview URL tidy. Attaching without a storage
* provider opens the provider modal instead.
*/
export function useComposeImage(isOpen: boolean) {
const { upload, isUploading, progress, isProviderConnected, checkProvider } = useImageUpload()
const [attached, setAttached] = useState<AttachedImage | null>(null)
const [showProviderModal, setShowProviderModal] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)

useEffect(() => {
if (isOpen) checkProvider().catch((err) => logger.error('Failed to check upload provider:', err))
}, [isOpen, checkProvider])

useEffect(() => {
return () => {
if (attached?.preview) URL.revokeObjectURL(attached.preview)
}
}, [attached?.preview])

const attach = useCallback(
(file: File) => {
if (!file.type.startsWith('image/')) {
toast.error('Only images are supported')
return
}
if (file.size > MAX_IMAGE_BYTES) {
toast.error('Image must be under 10MB')
return
}
setAttached({ file, preview: URL.createObjectURL(file) })
upload(file)
.then((result) => setAttached((prev) => (prev && prev.file === file ? { ...prev, uploadResult: result } : prev)))
.catch((err) => {
logger.error('Failed to upload image:', err)
toast.error('Failed to upload image')
})
},
[upload]
)

const onFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
// Reset so the same file can be picked again.
e.target.value = ''
if (file) attach(file)
},
[attach]
)

const onPaste = useCallback(
(e: React.ClipboardEvent) => {
const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.type.startsWith('image/'))
if (!item) return
if (attached) {
toast.error('Only one image can be attached per post')
return
}
if (!isProviderConnected) {
setShowProviderModal(true)
return
}
const file = item.getAsFile()
if (!file) return
// Otherwise the browser pastes a data URL into the textarea.
e.preventDefault()
attach(file)
},
[attached, isProviderConnected, attach]
)

const openPicker = useCallback(() => {
if (!isProviderConnected) {
setShowProviderModal(true)
return
}
fileInputRef.current?.click()
}, [isProviderConnected])

// The preview-URL effect above revokes the object URL on change.
const remove = useCallback(() => setAttached(null), [])

/** Upload now if the attachment has not finished uploading; returns the URL, or null when nothing is attached. */
const ensureUploaded = useCallback(async (): Promise<string | null> => {
if (!attached) return null
if (attached.uploadResult) return attached.uploadResult.url
const result = await upload(attached.file)
setAttached((prev) => (prev ? { ...prev, uploadResult: result } : null))
return result.url
}, [attached, upload])

return {
attached,
isUploading,
progress,
fileInputRef,
showProviderModal,
setShowProviderModal,
onFileSelect,
onPaste,
openPicker,
remove,
ensureUploaded,
}
}
46 changes: 46 additions & 0 deletions hooks/use-compose-poll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client'

import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { pollrPollUrl } from '@/lib/poll-embed'
import { createPollDraft, type PollDraft } from '@/components/compose/poll-editor'

/**
* A poll attached to the post being composed. `createdPollId` survives a
* failed post attempt so the retry re-uses the poll that already landed
* instead of paying for a second, orphaned one; `unconfirmed` marks a poll
* whose broadcast succeeded but was never seen queryable.
*/
export function useComposePoll(canAttach: boolean) {
const [draft, setDraft] = useState<PollDraft | null>(null)
const [createdPollId, setCreatedPollId] = useState<string | null>(null)
const [unconfirmed, setUnconfirmed] = useState(false)

/** Drop the poll silently: after a successful post, where nothing is orphaned. */
const forget = useCallback(() => {
setDraft(null)
setCreatedPollId(null)
setUnconfirmed(false)
}, [])

/** Detach the poll; one that already landed is now orphaned, so say where it lives. */
const clear = useCallback(() => {
if (createdPollId) {
const url = pollrPollUrl(createdPollId)
toast(url ? `Your poll stays live on Pollr: ${url}` : 'Your poll document stays live on the Pollr contract.', { duration: 8000, icon: '📊' })
}
forget()
}, [createdPollId, forget])

const toggle = useCallback(() => {
if (draft) clear()
else setDraft(createPollDraft())
}, [draft, clear])

// A private visibility or a reply cannot carry a poll; drop it if one is attached.
useEffect(() => {
if (draft && !canAttach) clear()
}, [draft, canAttach, clear])

return { draft, setDraft, createdPollId, setCreatedPollId, unconfirmed, setUnconfirmed, forget, clear, toggle }
}
108 changes: 108 additions & 0 deletions hooks/use-compose-private-feed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use client'

import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { logger } from '@/lib/logger'
import type { PostVisibility } from '@/lib/store'
import type { AuthUser } from '@/contexts/auth-context'

/**
* Whether the composer may post privately, and the flow that turns a private
* feed on when the author picks a private visibility without one: add an
* encryption key to the identity if it has none, enter the key, enable the
* feed, then apply the visibility they asked for.
*/
export function useComposePrivateFeed(isOpen: boolean, user: AuthUser | null, applyVisibility: (v: PostVisibility) => void) {
const [hasPrivateFeed, setHasPrivateFeed] = useState(false)
const [loading, setLoading] = useState(true)
const [followerCount, setFollowerCount] = useState(0)
const [hasEncryptionKeyOnIdentity, setHasEncryptionKeyOnIdentity] = useState(false)
const [showAddKeyModal, setShowAddKeyModal] = useState(false)
const [pendingVisibility, setPendingVisibility] = useState<PostVisibility | null>(null)

useEffect(() => {
if (!isOpen || !user) return
setLoading(true)
const check = async () => {
try {
const { privateFeedService, privateFeedKeyStore, identityService } = await import('@/lib/services')
// Local keys are proof enough; only ask Platform when there are none.
const hasPrivate = privateFeedKeyStore.hasFeedSeed() || (await privateFeedService.hasPrivateFeed(user.identityId))
setHasPrivateFeed(hasPrivate)
setFollowerCount(hasPrivate ? Object.keys(privateFeedKeyStore.getRecipientMap()).length : 0)
if (!hasPrivate) {
try {
const { hasEncryptionKeyOnIdentity } = await import('@/lib/crypto/encryption-key-lookup')
const identity = await identityService.getIdentity(user.identityId)
setHasEncryptionKeyOnIdentity(identity?.publicKeys ? hasEncryptionKeyOnIdentity(identity.publicKeys) : false)
} catch {
setHasEncryptionKeyOnIdentity(false)
}
}
} catch (error) {
logger.error('Failed to check private feed status:', error)
setHasPrivateFeed(false)
} finally {
setLoading(false)
}
}
check().catch((err) => logger.error('Failed to check private feed:', err))
}, [isOpen, user])

const enableAfterKeyEntry = useCallback(
async (targetVisibility: PostVisibility) => {
if (!user) return
try {
const { privateFeedService, privateFeedKeyStore } = await import('@/lib/services')
const { getEncryptionKeyBytes } = await import('@/lib/secure-storage')
const encryptionPrivateKey = getEncryptionKeyBytes(user.identityId)
if (!encryptionPrivateKey) {
toast.error('No encryption key found. Please try again.')
return
}
const result = await privateFeedService.enablePrivateFeed(user.identityId, encryptionPrivateKey)
if (!result.success) {
toast.error(result.error || 'Failed to enable private feed')
return
}
setHasPrivateFeed(true)
applyVisibility(targetVisibility)
toast.success('Private feed enabled!')
setFollowerCount(Object.keys(privateFeedKeyStore.getRecipientMap()).length)
} catch (error) {
logger.error('Error enabling private feed:', error)
toast.error('Failed to enable private feed')
} finally {
setPendingVisibility(null)
}
},
[user, applyVisibility]
)

const requestEnable = useCallback(
async (targetVisibility: PostVisibility) => {
if (!user) return
setPendingVisibility(targetVisibility)
if (!hasEncryptionKeyOnIdentity) {
setShowAddKeyModal(true)
return
}
const { useEncryptionKeyModal } = await import('@/hooks/use-encryption-key-modal')
useEncryptionKeyModal.getState().open('manage_private_feed', () => enableAfterKeyEntry(targetVisibility))
},
[user, hasEncryptionKeyOnIdentity, enableAfterKeyEntry]
)

const onKeyAdded = useCallback(async () => {
setShowAddKeyModal(false)
setHasEncryptionKeyOnIdentity(true)
if (pendingVisibility) await enableAfterKeyEntry(pendingVisibility)
}, [pendingVisibility, enableAfterKeyEntry])

const cancelAddKey = useCallback(() => {
setShowAddKeyModal(false)
setPendingVisibility(null)
}, [])

return { hasPrivateFeed, loading, followerCount, requestEnable, showAddKeyModal, onKeyAdded, cancelAddKey }
}
61 changes: 61 additions & 0 deletions hooks/use-inherited-encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use client'

import { useCallback, useEffect, useState } from 'react'
import { logger } from '@/lib/logger'
import type { Post } from '@/lib/types'
import type { EncryptionSource } from '@/lib/services/post-service'
import { isPrivatePost } from '@/components/post/private-post-content'

/**
* A reply to a private post is encrypted to that post's feed (PRD §5.5). This
* resolves the source while the composer is open on such a reply; an error
* blocks posting until a retry succeeds.
*/
export function useInheritedEncryption(isOpen: boolean, replyingTo: Post | null) {
const [source, setSource] = useState<EncryptionSource | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)

const check = useCallback(async (post: Post, isCurrent: () => boolean = () => true) => {
setLoading(true)
setError(false)
try {
if (!isPrivatePost(post)) {
if (isCurrent()) setSource(null)
return
}
const { getEncryptionSource } = await import('@/lib/services/post-service')
const resolved = await getEncryptionSource(post)
if (!isCurrent()) return
setSource(resolved)
setError(!resolved)
} catch (err) {
logger.error('Failed to check inherited encryption:', err)
if (!isCurrent()) return
setError(isPrivatePost(post))
setSource(null)
} finally {
if (isCurrent()) setLoading(false)
}
}, [])

useEffect(() => {
if (!isOpen || !replyingTo) {
setSource(null)
setLoading(false)
setError(false)
return
}
let cancelled = false
check(replyingTo, () => !cancelled).catch((err) => logger.error('Failed to check inherited encryption:', err))
return () => {
cancelled = true
}
}, [isOpen, replyingTo, check])

const retry = useCallback(() => {
if (replyingTo) check(replyingTo).catch((err) => logger.error('Failed to check inherited encryption:', err))
}, [replyingTo, check])

return { source, loading, error, retry }
}
2 changes: 2 additions & 0 deletions lib/compose/limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Characters allowed in one post's content field. */
export const CHARACTER_LIMIT = 500
24 changes: 24 additions & 0 deletions lib/compose/publish-thread.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { planPosts } from './publish-thread'

const thread = [
{ id: 'a', content: ' first ', visibility: 'public' as const },
{ id: 'b', content: 'second', postedPostId: 'landed' },
{ id: 'c', content: ' ' },
{ id: 'd', content: 'fourth', teaser: ' tease ' },
]

describe('planPosts', () => {
it('keeps unposted posts with content, trimmed, in order', () => {
expect(planPosts(thread, undefined, false).map((p) => [p.threadPostId, p.content, p.teaser])).toEqual([
['a', 'first', undefined],
['d', 'fourth', 'tease'],
])
})

it('appends the image URL to the first post only when the content is encrypted', () => {
expect(planPosts(thread, 'ipfs://cid', true)[0].content).toBe('first\n\nipfs://cid')
expect(planPosts(thread, 'ipfs://cid', true)[1].content).toBe('fourth')
expect(planPosts(thread, 'ipfs://cid', false)[0].content).toBe('first')
})
})
Loading
Loading