Browse Source

include media and artilce kinds

imwald
Silberengel 4 months ago
parent
commit
a1c0837b05
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 622
      src/components/PostEditor/PostContent.tsx
  4. 32
      src/components/PostEditor/PostOptions.tsx
  5. 19
      src/components/PostEditor/PostTextarea/index.tsx
  6. 5
      src/constants.ts
  7. 700
      src/lib/draft-event.ts
  8. 78
      src/lib/media-kind-detection.ts
  9. 93
      src/lib/private-relays.ts

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
{
"name": "jumble-imwald",
"version": "13.7",
"version": "14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jumble-imwald",
"version": "13.7",
"version": "14.0",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "jumble-imwald",
"version": "13.7",
"version": "14.0",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true,
"type": "module",

622
src/components/PostEditor/PostContent.tsx

@ -1,6 +1,12 @@ @@ -1,6 +1,12 @@
import Note from '@/components/Note'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
createCommentDraftEvent,
createPollDraftEvent,
@ -8,7 +14,19 @@ import { @@ -8,7 +14,19 @@ import {
createPublicMessageReplyDraftEvent,
createShortTextNoteDraftEvent,
createHighlightDraftEvent,
deleteDraftEventCache
deleteDraftEventCache,
createVoiceDraftEvent,
createVoiceCommentDraftEvent,
createPictureDraftEvent,
createVideoDraftEvent,
createLongFormArticleDraftEvent,
createWikiArticleDraftEvent,
createWikiArticleMarkdownDraftEvent,
createPublicationContentDraftEvent,
createCitationInternalDraftEvent,
createCitationExternalDraftEvent,
createCitationHardcopyDraftEvent,
createCitationPromptDraftEvent
} from '@/lib/draft-event'
import { ExtendedKind } from '@/constants'
import { isTouchDevice } from '@/lib/utils'
@ -20,7 +38,12 @@ import logger from '@/lib/logger' @@ -20,7 +38,12 @@ import logger from '@/lib/logger'
import postEditorCache from '@/services/post-editor-cache.service'
import storage from '@/services/local-storage.service'
import { TPollCreateData } from '@/types'
import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter } from 'lucide-react'
import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter, FileText, Quote, Upload } from 'lucide-react'
import { getMediaKindFromFile } from '@/lib/media-kind-detection'
import { hasPrivateRelays, getPrivateRelayUrls, hasCacheRelays, getCacheRelayUrls } from '@/lib/private-relays'
import mediaUpload from '@/services/media-upload.service'
import { StorageKey } from '@/constants'
import { isProtectedEvent as isEventProtected, isReplyNoteEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -80,13 +103,29 @@ export default function PostContent({ @@ -80,13 +103,29 @@ export default function PostContent({
relays: []
})
const [minPow, setMinPow] = useState(0)
const [mediaNoteKind, setMediaNoteKind] = useState<number | null>(null)
const [mediaImetaTags, setMediaImetaTags] = useState<string[][]>([])
const [mediaUrl, setMediaUrl] = useState<string>('')
const [isLongFormArticle, setIsLongFormArticle] = useState(false)
const [isWikiArticle, setIsWikiArticle] = useState(false)
const [isWikiArticleMarkdown, setIsWikiArticleMarkdown] = useState(false)
const [isPublicationContent, setIsPublicationContent] = useState(false)
const [isCitationInternal, setIsCitationInternal] = useState(false)
const [isCitationExternal, setIsCitationExternal] = useState(false)
const [isCitationHardcopy, setIsCitationHardcopy] = useState(false)
const [isCitationPrompt, setIsCitationPrompt] = useState(false)
const [hasPrivateRelaysAvailable, setHasPrivateRelaysAvailable] = useState(false)
const [hasCacheRelaysAvailable, setHasCacheRelaysAvailable] = useState(false)
const [useCacheOnlyForPrivateNotes, setUseCacheOnlyForPrivateNotes] = useState(true) // Default ON
const uploadedMediaFileMap = useRef<Map<string, File>>(new Map())
const isFirstRender = useRef(true)
const canPost = useMemo(() => {
const result = (
!!pubkey &&
!!text &&
!posting &&
!uploadProgresses.length &&
// For media notes, text is optional - just need media
((mediaNoteKind !== null && mediaUrl) || !!text) &&
(!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) &&
(!isPublicMessage || extractedMentions.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) &&
(!isProtectedEvent || additionalRelayUrls.length > 0) &&
@ -99,6 +138,8 @@ export default function PostContent({ @@ -99,6 +138,8 @@ export default function PostContent({
text,
posting,
uploadProgresses,
mediaNoteKind,
mediaUrl,
isPoll,
pollCreateData,
isPublicMessage,
@ -190,6 +231,30 @@ export default function PostContent({ @@ -190,6 +231,30 @@ export default function PostContent({
}
}, [text, extractMentionsFromContent])
// Check for private relays availability
useEffect(() => {
if (!pubkey) {
setHasPrivateRelaysAvailable(false)
setHasCacheRelaysAvailable(false)
return
}
hasPrivateRelays(pubkey).then(setHasPrivateRelaysAvailable).catch(() => {
setHasPrivateRelaysAvailable(false)
})
hasCacheRelays(pubkey).then(setHasCacheRelaysAvailable).catch(() => {
setHasCacheRelaysAvailable(false)
})
}, [pubkey])
// Load cache-only preference from localStorage
useEffect(() => {
const stored = window.localStorage.getItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES)
// Default to true (ON) if not set
setUseCacheOnlyForPrivateNotes(stored === null ? true : stored === 'true')
}, [])
const post = async (e?: React.MouseEvent) => {
e?.stopPropagation()
checkLogin(async () => {
@ -225,12 +290,171 @@ export default function PostContent({ @@ -225,12 +290,171 @@ export default function PostContent({
)
// Get expiration and quiet settings
// Only add expiration tags to chatting kinds: 1, 1111, 1222, 1244
const isChattingKind = (kind: number) =>
kind === kinds.ShortTextNote ||
kind === ExtendedKind.COMMENT ||
kind === ExtendedKind.VOICE ||
kind === ExtendedKind.VOICE_COMMENT
const addExpirationTag = storage.getDefaultExpirationEnabled()
const expirationMonths = storage.getDefaultExpirationMonths()
const addQuietTag = storage.getDefaultQuietEnabled()
const quietDays = storage.getDefaultQuietDays()
// Determine if we should use protected event tag
// Only use it when replying to an OP event that also has the "-" tag
let shouldUseProtectedEvent = false
if (parentEvent) {
// Check if parent event is an OP (not a reply itself) and has the "-" tag
const isParentOP = !isReplyNoteEvent(parentEvent)
const parentHasProtectedTag = isEventProtected(parentEvent)
shouldUseProtectedEvent = isParentOP && parentHasProtectedTag
}
// Determine relay URLs for private events
let privateRelayUrls: string[] = []
const isPrivateEvent = isPublicationContent || isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt
if (isPrivateEvent) {
if (useCacheOnlyForPrivateNotes && hasCacheRelaysAvailable) {
// Use only cache relays if toggle is ON
privateRelayUrls = await getCacheRelayUrls(pubkey!)
} else {
// Use all private relays (outbox + cache)
privateRelayUrls = await getPrivateRelayUrls(pubkey!)
}
}
if (isHighlight) {
if (mediaNoteKind !== null && mediaUrl) {
// Media notes
if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) {
// Voice comment
draftEvent = await createVoiceCommentDraftEvent(
cleanedText,
parentEvent,
mediaUrl,
mediaImetaTags,
mentions,
{
addClientTag,
protectedEvent: shouldUseProtectedEvent,
isNsfw,
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE_COMMENT),
expirationMonths,
addQuietTag,
quietDays
}
)
} else if (mediaNoteKind === ExtendedKind.VOICE) {
// Voice note
draftEvent = await createVoiceDraftEvent(
cleanedText,
mediaUrl,
mediaImetaTags,
mentions,
{
addClientTag,
isNsfw,
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE),
expirationMonths,
addQuietTag,
quietDays
}
)
} else if (mediaNoteKind === ExtendedKind.PICTURE) {
// Picture note
draftEvent = await createPictureDraftEvent(
cleanedText,
mediaImetaTags,
mentions,
{
addClientTag,
isNsfw,
addExpirationTag: false, // Picture notes are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
}
)
} else if (mediaNoteKind === ExtendedKind.VIDEO || mediaNoteKind === ExtendedKind.SHORT_VIDEO) {
// Video note
draftEvent = await createVideoDraftEvent(
cleanedText,
mediaImetaTags,
mentions,
mediaNoteKind,
{
addClientTag,
isNsfw,
addExpirationTag: false, // Video notes are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
}
)
}
} else if (isLongFormArticle) {
draftEvent = await createLongFormArticleDraftEvent(cleanedText, mentions, {
addClientTag,
isNsfw,
addExpirationTag: false, // Articles are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else if (isWikiArticle) {
draftEvent = await createWikiArticleDraftEvent(cleanedText, mentions, {
dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content
addClientTag,
isNsfw,
addExpirationTag: false, // Wiki articles are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else if (isWikiArticleMarkdown) {
draftEvent = await createWikiArticleMarkdownDraftEvent(cleanedText, mentions, {
dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content
addClientTag,
isNsfw,
addExpirationTag: false, // Wiki articles are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else if (isPublicationContent) {
draftEvent = await createPublicationContentDraftEvent(cleanedText, mentions, {
dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content
addClientTag,
isNsfw,
addExpirationTag: false, // Publication content is not a chatting kind
expirationMonths,
addQuietTag,
quietDays
})
} else if (isCitationInternal) {
// For now, use a simple format - in a real implementation, this would have a form
draftEvent = createCitationInternalDraftEvent(cleanedText, {
cTag: '', // Would need to be filled from a form
title: cleanedText.substring(0, 100)
})
} else if (isCitationExternal) {
draftEvent = createCitationExternalDraftEvent(cleanedText, {
url: '', // Would need to be filled from a form
accessedOn: new Date().toISOString(),
title: cleanedText.substring(0, 100)
})
} else if (isCitationHardcopy) {
draftEvent = createCitationHardcopyDraftEvent(cleanedText, {
accessedOn: new Date().toISOString(),
title: cleanedText.substring(0, 100)
})
} else if (isCitationPrompt) {
draftEvent = createCitationPromptDraftEvent(cleanedText, {
llm: '', // Would need to be filled from a form
accessedOn: new Date().toISOString()
})
} else if (isHighlight) {
// For highlights, pass the original sourceValue which contains the full identifier
// The createHighlightDraftEvent function will parse it correctly
draftEvent = await createHighlightDraftEvent(
@ -242,7 +466,7 @@ export default function PostContent({ @@ -242,7 +466,7 @@ export default function PostContent({
{
addClientTag,
isNsfw,
addExpirationTag,
addExpirationTag: false, // Highlights are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
@ -252,7 +476,7 @@ export default function PostContent({ @@ -252,7 +476,7 @@ export default function PostContent({
draftEvent = await createPublicMessageDraftEvent(cleanedText, extractedMentions, {
addClientTag,
isNsfw,
addExpirationTag,
addExpirationTag: false, // Public messages are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
@ -261,7 +485,7 @@ export default function PostContent({ @@ -261,7 +485,7 @@ export default function PostContent({
draftEvent = await createPublicMessageReplyDraftEvent(cleanedText, parentEvent, mentions, {
addClientTag,
isNsfw,
addExpirationTag,
addExpirationTag: false, // Public messages are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
@ -269,9 +493,9 @@ export default function PostContent({ @@ -269,9 +493,9 @@ export default function PostContent({
} else if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) {
draftEvent = await createCommentDraftEvent(cleanedText, parentEvent, mentions, {
addClientTag,
protectedEvent: isProtectedEvent,
protectedEvent: shouldUseProtectedEvent,
isNsfw,
addExpirationTag,
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.COMMENT),
expirationMonths,
addQuietTag,
quietDays
@ -280,21 +504,20 @@ export default function PostContent({ @@ -280,21 +504,20 @@ export default function PostContent({
draftEvent = await createPollDraftEvent(pubkey!, cleanedText, mentions, pollCreateData, {
addClientTag,
isNsfw,
addExpirationTag,
addExpirationTag: false, // Polls are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else {
// For regular kind 1 note OPs (no parentEvent), never use protectedEvent
// protectedEvent should only be used for public messages and discussions
const shouldUseProtectedEvent = parentEvent ? isProtectedEvent : false
// protectedEvent should only be used when replying to an OP that has it
draftEvent = await createShortTextNoteDraftEvent(cleanedText, mentions, {
parentEvent,
addClientTag,
protectedEvent: shouldUseProtectedEvent,
isNsfw,
addExpirationTag,
addExpirationTag: addExpirationTag && isChattingKind(kinds.ShortTextNote),
expirationMonths,
addQuietTag,
quietDays
@ -302,11 +525,16 @@ export default function PostContent({ @@ -302,11 +525,16 @@ export default function PostContent({
}
// console.log('Publishing draft event:', draftEvent)
// For private events, only publish to private relays
const relayUrls = isPrivateEvent && privateRelayUrls.length > 0
? privateRelayUrls
: (additionalRelayUrls.length > 0 ? additionalRelayUrls : undefined)
newEvent = await publish(draftEvent, {
specifiedRelayUrls: additionalRelayUrls.length > 0 ? additionalRelayUrls : undefined,
additionalRelayUrls: isPoll ? pollCreateData.relays : additionalRelayUrls,
specifiedRelayUrls: relayUrls,
additionalRelayUrls: isPoll ? pollCreateData.relays : (isPrivateEvent ? privateRelayUrls : additionalRelayUrls),
minPow,
disableFallbacks: additionalRelayUrls.length > 0 // Don't use fallbacks if user explicitly selected relays
disableFallbacks: additionalRelayUrls.length > 0 || isPrivateEvent // Don't use fallbacks if user explicitly selected relays or for private events
})
// console.log('Published event:', newEvent)
@ -438,6 +666,10 @@ export default function PostContent({ @@ -438,6 +666,10 @@ export default function PostContent({
const handleUploadStart = (file: File, cancel: () => void) => {
setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }])
// Track file for media upload
if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type.startsWith('video/')) {
uploadedMediaFileMap.current.set(file.name, file)
}
}
const handleUploadProgress = (file: File, progress: number) => {
@ -448,6 +680,129 @@ export default function PostContent({ @@ -448,6 +680,129 @@ export default function PostContent({
const handleUploadEnd = (file: File) => {
setUploadProgresses((prev) => prev.filter((item) => item.file !== file))
// Keep file in map until upload success is called
}
const handleMediaUploadSuccess = async ({ url, tags }: { url: string; tags: string[][] }) => {
// Find the file from the map - try to match by URL or get the most recent
let uploadingFile: File | undefined
// Try to find by matching URL pattern or get the first available
for (const [, file] of uploadedMediaFileMap.current.entries()) {
uploadingFile = file
break // Get first available
}
if (!uploadingFile) {
// Try to get from uploadProgresses as fallback
const progressItem = uploadProgresses.find(p => p.file)
uploadingFile = progressItem?.file
}
if (!uploadingFile) {
logger.warn('Media upload succeeded but file not found')
return
}
// Determine media kind from file
// For replies, only audio comments are supported (kind 1244)
// For new posts, all media types are supported
if (parentEvent) {
// For replies, only allow audio comments
const fileType = uploadingFile.type
const fileName = uploadingFile.name.toLowerCase()
const isAudio = fileType.startsWith('audio/') || /\.(mp3|m4a|ogg|wav|webm|opus|aac|flac)$/i.test(fileName)
if (isAudio) {
// For replies, always create voice comments, regardless of duration
setMediaNoteKind(ExtendedKind.VOICE_COMMENT)
} else {
// Non-audio media in replies - don't set mediaNoteKind, will be handled as regular comment
// Clear any existing media note kind
setMediaNoteKind(null)
setMediaUrl('')
setMediaImetaTags([])
// Just add the media URL to the text content
textareaRef.current?.appendText(url, true)
return // Don't set media note kind for non-audio in replies
}
} else {
// For new posts, use the detected kind (which handles audio > 60s → video)
const kind = await getMediaKindFromFile(uploadingFile, false)
setMediaNoteKind(kind)
}
setMediaUrl(url)
// Get imeta tag from media upload service
const imetaTag = mediaUpload.getImetaTagByUrl(url)
if (imetaTag) {
// imetaTag is already a string[] like ['imeta', 'url https://...', 'm image/jpeg']
// We need it as string[][] for the draft event functions
setMediaImetaTags([imetaTag])
} else if (tags && tags.length > 0) {
// Use tags from upload result - they should already be in the right format
setMediaImetaTags(tags)
} else {
// Create a basic imeta tag if none exists
const basicImetaTag: string[] = ['imeta', `url ${url}`]
if (uploadingFile.type) {
basicImetaTag.push(`m ${uploadingFile.type}`)
}
setMediaImetaTags([basicImetaTag])
}
// Clear other note types when media is selected
setIsPoll(false)
setIsPublicMessage(false)
setIsHighlight(false)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsPublicationContent(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
setIsCitationPrompt(false)
// Clear uploaded file from map
uploadedMediaFileMap.current.clear()
}
const handleArticleToggle = (type: 'longform' | 'wiki' | 'wiki-markdown' | 'publication') => {
if (parentEvent) return // Can't create articles as replies
setIsLongFormArticle(type === 'longform')
setIsWikiArticle(type === 'wiki')
setIsWikiArticleMarkdown(type === 'wiki-markdown')
setIsPublicationContent(type === 'publication')
// Clear other types
setIsPoll(false)
setIsPublicMessage(false)
setIsHighlight(false)
setMediaNoteKind(null)
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
setIsCitationPrompt(false)
}
const handleCitationToggle = (type: 'internal' | 'external' | 'hardcopy' | 'prompt') => {
if (parentEvent) return // Can't create citations as replies
setIsCitationInternal(type === 'internal')
setIsCitationExternal(type === 'external')
setIsCitationHardcopy(type === 'hardcopy')
setIsCitationPrompt(type === 'prompt')
// Clear other types
setIsPoll(false)
setIsPublicMessage(false)
setIsHighlight(false)
setMediaNoteKind(null)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsPublicationContent(false)
}
return (
@ -459,16 +814,42 @@ export default function PostContent({ @@ -459,16 +814,42 @@ export default function PostContent({
<div className="shrink-0">
{parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE
? t('Reply to Public Message')
: t('Reply to')
: mediaNoteKind === ExtendedKind.VOICE_COMMENT
? t('Voice Comment')
: t('Reply to')
}
</div>
</div>
) : mediaNoteKind === ExtendedKind.VOICE ? (
t('Voice Note')
) : mediaNoteKind === ExtendedKind.PICTURE ? (
t('Picture Note')
) : mediaNoteKind === ExtendedKind.VIDEO ? (
t('Video Note')
) : mediaNoteKind === ExtendedKind.SHORT_VIDEO ? (
t('Short Video Note')
) : isPoll ? (
t('New Poll')
) : isPublicMessage ? (
t('New Public Message')
) : isHighlight ? (
t('New Highlight')
) : isLongFormArticle ? (
t('New Long-form Article')
) : isWikiArticle ? (
t('New Wiki Article')
) : isWikiArticleMarkdown ? (
t('New Wiki Article (Markdown)')
) : isPublicationContent ? (
t('New Publication Content')
) : isCitationInternal ? (
t('New Internal Citation')
) : isCitationExternal ? (
t('New External Citation')
) : isCitationHardcopy ? (
t('New Hardcopy Citation')
) : isCitationPrompt ? (
t('New Prompt Citation')
) : (
t('New Note')
)}
@ -482,20 +863,159 @@ export default function PostContent({ @@ -482,20 +863,159 @@ export default function PostContent({
</ScrollArea>
)}
<PostTextarea
ref={textareaRef}
text={text}
setText={setText}
defaultContent={defaultContent}
parentEvent={parentEvent}
onSubmit={() => post()}
className={isPoll ? 'min-h-20' : 'min-h-52'}
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadEnd={handleUploadEnd}
kind={isHighlight ? kinds.Highlights : isPublicMessage ? ExtendedKind.PUBLIC_MESSAGE : isPoll ? ExtendedKind.POLL : kinds.ShortTextNote}
highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined}
/>
ref={textareaRef}
text={text}
setText={setText}
defaultContent={defaultContent}
parentEvent={parentEvent}
onSubmit={() => post()}
className={isPoll ? 'min-h-20' : 'min-h-52'}
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadEnd={handleUploadEnd}
kind={
mediaNoteKind !== null
? mediaNoteKind
: isHighlight
? kinds.Highlights
: isPublicMessage
? ExtendedKind.PUBLIC_MESSAGE
: isPoll
? ExtendedKind.POLL
: isLongFormArticle
? kinds.LongFormArticle
: isWikiArticle
? ExtendedKind.WIKI_ARTICLE
: isWikiArticleMarkdown
? ExtendedKind.WIKI_ARTICLE_MARKDOWN
: isPublicationContent
? ExtendedKind.PUBLICATION_CONTENT
: kinds.ShortTextNote
}
highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined}
headerActions={
<>
{/* Media button */}
<Uploader
onUploadSuccess={handleMediaUploadSuccess}
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}
accept="image/*,audio/*,video/*"
>
<Button
variant="ghost"
size="icon"
title={t('Upload Media')}
className={mediaNoteKind !== null ? 'bg-accent' : ''}
>
<Upload className="h-4 w-4" />
</Button>
</Uploader>
{/* Note creation buttons - only show when not replying */}
{!parentEvent && (
<>
<Button
variant="ghost"
size="icon"
title={t('Create Highlight')}
className={isHighlight ? 'bg-accent' : ''}
onClick={handleHighlightToggle}
>
<Highlighter className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title={t('Send Public Message')}
className={isPublicMessage ? 'bg-accent' : ''}
onClick={handlePublicMessageToggle}
>
<MessageCircle className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title={t('Create Poll')}
className={isPoll ? 'bg-accent' : ''}
onClick={handlePollToggle}
>
<ListTodo className="h-4 w-4" />
</Button>
{/* Article dropdown - only show if has private relays for publication content */}
{(hasPrivateRelaysAvailable || !isPublicationContent) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
title={t('Create Article')}
className={
isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent
? 'bg-accent'
: ''
}
>
<FileText className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => handleArticleToggle('longform')}>
{t('Long-form Article')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleArticleToggle('wiki')}>
{t('Wiki Article (AsciiDoc)')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleArticleToggle('wiki-markdown')}>
{t('Wiki Article (Markdown)')}
</DropdownMenuItem>
{hasPrivateRelaysAvailable && (
<DropdownMenuItem onClick={() => handleArticleToggle('publication')}>
{t('Publication Content')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Citation dropdown - only show if has private relays */}
{hasPrivateRelaysAvailable && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
title={t('Create Citation')}
className={
isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt
? 'bg-accent'
: ''
}
>
<Quote className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => handleCitationToggle('internal')}>
{t('Internal Citation')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCitationToggle('external')}>
{t('External Citation')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCitationToggle('hardcopy')}>
{t('Hardcopy Citation')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCitationToggle('prompt')}>
{t('Prompt Citation')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)}
</>
}
/>
{isPoll && (
<PollEditor
pollCreateData={pollCreateData}
@ -579,9 +1099,9 @@ export default function PostContent({ @@ -579,9 +1099,9 @@ export default function PostContent({
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}
accept="image/*,video/*,audio/*"
accept="image/*"
>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" title={t('Upload Image')}>
<ImageUp />
</Button>
</Uploader>
@ -600,39 +1120,6 @@ export default function PostContent({ @@ -600,39 +1120,6 @@ export default function PostContent({
</Button>
</EmojiPickerDialog>
)}
{!parentEvent && (
<Button
variant="ghost"
size="icon"
title={t('Create Poll')}
className={isPoll ? 'bg-accent' : ''}
onClick={handlePollToggle}
>
<ListTodo />
</Button>
)}
{!parentEvent && (
<Button
variant="ghost"
size="icon"
title={t('Send Public Message')}
className={isPublicMessage ? 'bg-accent' : ''}
onClick={handlePublicMessageToggle}
>
<MessageCircle />
</Button>
)}
{!parentEvent && (
<Button
variant="ghost"
size="icon"
title={t('Create Highlight')}
className={isHighlight ? 'bg-accent' : ''}
onClick={handleHighlightToggle}
>
<Highlighter />
</Button>
)}
<Button
variant="ghost"
size="icon"
@ -675,6 +1162,9 @@ export default function PostContent({ @@ -675,6 +1162,9 @@ export default function PostContent({
setIsNsfw={setIsNsfw}
minPow={minPow}
setMinPow={setMinPow}
useCacheOnlyForPrivateNotes={useCacheOnlyForPrivateNotes}
setUseCacheOnlyForPrivateNotes={setUseCacheOnlyForPrivateNotes}
hasCacheRelaysAvailable={hasCacheRelaysAvailable}
/>
<div className="flex gap-2 items-center justify-around sm:hidden">
<Button

32
src/components/PostEditor/PostOptions.tsx

@ -13,7 +13,10 @@ export default function PostOptions({ @@ -13,7 +13,10 @@ export default function PostOptions({
isNsfw,
setIsNsfw,
minPow,
setMinPow
setMinPow,
useCacheOnlyForPrivateNotes,
setUseCacheOnlyForPrivateNotes,
hasCacheRelaysAvailable
}: {
posting: boolean
show: boolean
@ -23,6 +26,9 @@ export default function PostOptions({ @@ -23,6 +26,9 @@ export default function PostOptions({
setIsNsfw: Dispatch<SetStateAction<boolean>>
minPow: number
setMinPow: Dispatch<SetStateAction<number>>
useCacheOnlyForPrivateNotes?: boolean
setUseCacheOnlyForPrivateNotes?: Dispatch<SetStateAction<boolean>>
hasCacheRelaysAvailable?: boolean
}) {
const { t } = useTranslation()
@ -42,6 +48,13 @@ export default function PostOptions({ @@ -42,6 +48,13 @@ export default function PostOptions({
setIsNsfw(checked)
}
const onUseCacheOnlyChange = (checked: boolean) => {
if (setUseCacheOnlyForPrivateNotes) {
setUseCacheOnlyForPrivateNotes(checked)
window.localStorage.setItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES, checked.toString())
}
}
return (
<div className="space-y-4">
<div className="space-y-2">
@ -69,6 +82,23 @@ export default function PostOptions({ @@ -69,6 +82,23 @@ export default function PostOptions({
/>
</div>
{hasCacheRelaysAvailable && useCacheOnlyForPrivateNotes !== undefined && setUseCacheOnlyForPrivateNotes && (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="use-cache-only">{t('Use cache relay only for citations and publication content')}</Label>
<Switch
id="use-cache-only"
checked={useCacheOnlyForPrivateNotes}
onCheckedChange={onUseCacheOnlyChange}
disabled={posting}
/>
</div>
<div className="text-muted-foreground text-xs">
{t('When enabled, citations and publication content (kind 30041) will only be published to your cache relay, not to outbox relays')}
</div>
</div>
)}
<div className="grid gap-4 pb-4">
<Label>{t('Proof of Work (difficulty {{minPow}})', { minPow })}</Label>
<Slider

19
src/components/PostEditor/PostTextarea/index.tsx

@ -44,6 +44,7 @@ const PostTextarea = forwardRef< @@ -44,6 +44,7 @@ const PostTextarea = forwardRef<
kind?: number
highlightData?: HighlightData
pollCreateData?: import('@/types').TPollCreateData
headerActions?: React.ReactNode
}
>(
(
@ -59,7 +60,8 @@ const PostTextarea = forwardRef< @@ -59,7 +60,8 @@ const PostTextarea = forwardRef<
onUploadEnd,
kind = 1,
highlightData,
pollCreateData
pollCreateData,
headerActions
},
ref
) => {
@ -167,10 +169,17 @@ const PostTextarea = forwardRef< @@ -167,10 +169,17 @@ const PostTextarea = forwardRef<
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-2">
<TabsList>
<TabsTrigger value="edit">{t('Edit')}</TabsTrigger>
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger>
</TabsList>
<div className="flex items-center justify-between">
<TabsList>
<TabsTrigger value="edit">{t('Edit')}</TabsTrigger>
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger>
</TabsList>
{headerActions && (
<div className="flex gap-1 items-center">
{headerActions}
</div>
)}
</div>
<TabsContent value="edit">
<EditorContent className="tiptap" editor={editor} />
</TabsContent>

5
src/constants.ts

@ -48,6 +48,7 @@ export const StorageKey = { @@ -48,6 +48,7 @@ export const StorageKey = {
SHOWN_CREATE_WALLET_GUIDE_TOAST_PUBKEYS: 'shownCreateWalletGuideToastPubkeys',
SHOW_RECOMMENDED_RELAYS_PANEL: 'showRecommendedRelaysPanel',
DEFAULT_EXPIRATION_ENABLED: 'defaultExpirationEnabled',
USE_CACHE_ONLY_FOR_PRIVATE_NOTES: 'useCacheOnlyForPrivateNotes',
DEFAULT_EXPIRATION_MONTHS: 'defaultExpirationMonths',
DEFAULT_QUIET_ENABLED: 'defaultQuietEnabled',
DEFAULT_QUIET_DAYS: 'defaultQuietDays',
@ -150,6 +151,10 @@ export const ExtendedKind = { @@ -150,6 +151,10 @@ export const ExtendedKind = {
WIKI_ARTICLE: 30818,
WIKI_ARTICLE_MARKDOWN: 30817,
PUBLICATION_CONTENT: 30041,
CITATION_INTERNAL: 30,
CITATION_EXTERNAL: 31,
CITATION_HARDCOPY: 32,
CITATION_PROMPT: 33,
RSS_FEED_LIST: 10895,
// NIP-89 Application Handlers
APPLICATION_HANDLER_RECOMMENDATION: 31989,

700
src/lib/draft-event.ts

@ -1165,3 +1165,703 @@ export async function createHighlightDraftEvent( @@ -1165,3 +1165,703 @@ export async function createHighlightDraftEvent(
content: highlightedText
})
}
// Media note draft event functions
export async function createVoiceDraftEvent(
content: string,
mediaUrl: string,
imetaTags: string[][],
mentions: string[],
options: {
addClientTag?: boolean
isNsfw?: boolean
addExpirationTag?: boolean
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const hashtags = extractHashtags(transformedEmojisContent)
const tags: string[][] = []
tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(...imetaTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
if (options.addExpirationTag && options.expirationMonths) {
tags.push(buildExpirationTag(options.expirationMonths))
}
if (options.addQuietTag && options.quietDays) {
tags.push(buildQuietTag(options.quietDays))
}
return setDraftEventCache({
kind: ExtendedKind.VOICE,
content: transformedEmojisContent || mediaUrl, // Content is optional text, fallback to URL
tags
})
}
export async function createVoiceCommentDraftEvent(
content: string,
parentEvent: Event,
mediaUrl: string,
imetaTags: string[][],
mentions: string[],
options: {
addClientTag?: boolean
protectedEvent?: boolean
isNsfw?: boolean
addExpirationTag?: boolean
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const {
quoteEventHexIds,
quoteReplaceableCoordinates,
rootEventId,
rootCoordinateTag,
rootKind,
rootPubkey,
rootUrl
} = await extractCommentMentions(transformedEmojisContent, parentEvent)
const hashtags = extractHashtags(transformedEmojisContent)
const tags: string[][] = []
tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(...imetaTags)
tags.push(...quoteEventHexIds.map((eventId) => buildQTag(eventId)))
tags.push(...quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
tags.push(
...mentions.filter((pubkey) => pubkey !== parentEvent.pubkey).map((pubkey) => buildPTag(pubkey))
)
if (rootCoordinateTag) {
tags.push(rootCoordinateTag)
} else if (rootEventId) {
tags.push(buildETag(rootEventId, rootPubkey, '', true))
}
if (rootPubkey) {
tags.push(buildPTag(rootPubkey, true))
}
if (rootKind) {
tags.push(buildKTag(rootKind, true))
}
if (rootUrl) {
tags.push(buildITag(rootUrl, true))
}
tags.push(
...[
isReplaceableEvent(parentEvent.kind)
? buildATag(parentEvent)
: buildETag(parentEvent.id, parentEvent.pubkey),
buildKTag(parentEvent.kind),
buildPTag(parentEvent.pubkey)
]
)
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
if (options.protectedEvent) {
tags.push(buildProtectedTag())
}
if (options.addExpirationTag && options.expirationMonths) {
tags.push(buildExpirationTag(options.expirationMonths))
}
if (options.addQuietTag && options.quietDays) {
tags.push(buildQuietTag(options.quietDays))
}
return setDraftEventCache({
kind: ExtendedKind.VOICE_COMMENT,
content: transformedEmojisContent || mediaUrl, // Content is optional text, fallback to URL
tags
})
}
export async function createPictureDraftEvent(
content: string,
imetaTags: string[][],
mentions: string[],
options: {
title?: string
addClientTag?: boolean
isNsfw?: boolean
addExpirationTag?: boolean
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const hashtags = extractHashtags(transformedEmojisContent)
const tags: string[][] = []
if (options.title) {
tags.push(buildTitleTag(options.title))
}
tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(...imetaTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
if (options.addExpirationTag && options.expirationMonths) {
tags.push(buildExpirationTag(options.expirationMonths))
}
if (options.addQuietTag && options.quietDays) {
tags.push(buildQuietTag(options.quietDays))
}
return setDraftEventCache({
kind: ExtendedKind.PICTURE,
content: transformedEmojisContent,
tags
})
}
export async function createVideoDraftEvent(
content: string,
imetaTags: string[][],
mentions: string[],
videoKind: number, // 21 or 22
options: {
title?: string
addClientTag?: boolean
isNsfw?: boolean
addExpirationTag?: boolean
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const hashtags = extractHashtags(transformedEmojisContent)
const tags: string[][] = []
if (options.title) {
tags.push(buildTitleTag(options.title))
}
tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(...imetaTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
if (options.addExpirationTag && options.expirationMonths) {
tags.push(buildExpirationTag(options.expirationMonths))
}
if (options.addQuietTag && options.quietDays) {
tags.push(buildQuietTag(options.quietDays))
}
return setDraftEventCache({
kind: videoKind, // ExtendedKind.VIDEO or ExtendedKind.SHORT_VIDEO
content: transformedEmojisContent,
tags
})
}
// Article draft event functions
export async function createLongFormArticleDraftEvent(
content: string,
mentions: string[],
options: {
title?: string
summary?: string
image?: string
publishedAt?: number
dTag?: string
addClientTag?: boolean
isNsfw?: boolean
addExpirationTag?: boolean
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const hashtags = extractHashtags(transformedEmojisContent)
const tags: string[][] = []
if (options.dTag) {
tags.push(buildDTag(options.dTag))
}
if (options.title) {
tags.push(buildTitleTag(options.title))
}
if (options.summary) {
tags.push(['summary', options.summary])
}
if (options.image) {
tags.push(['image', options.image])
}
if (options.publishedAt) {
tags.push(['published_at', options.publishedAt.toString()])
}
tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
// imeta tags for images in content
const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) {
tags.push(...generateImetaTags(images))
}
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
if (options.addExpirationTag && options.expirationMonths) {
tags.push(buildExpirationTag(options.expirationMonths))
}
if (options.addQuietTag && options.quietDays) {
tags.push(buildQuietTag(options.quietDays))
}
return setDraftEventCache({
kind: kinds.LongFormArticle,
content: transformedEmojisContent,
tags
})
}
function normalizeDTag(identifier: string): string {
// Convert to lowercase and replace non-letter characters with '-'
return identifier
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
export async function createWikiArticleDraftEvent(
content: string,
mentions: string[],
options: {
dTag: string
title?: string
summary?: string
image?: string
addClientTag?: boolean
isNsfw?: boolean
addExpirationTag?: boolean
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const hashtags = extractHashtags(transformedEmojisContent)
const tags: string[][] = []
tags.push(buildDTag(normalizeDTag(options.dTag)))
if (options.title) {
tags.push(buildTitleTag(options.title))
}
if (options.summary) {
tags.push(['summary', options.summary])
}
if (options.image) {
tags.push(['image', options.image])
}
tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
if (options.addExpirationTag && options.expirationMonths) {
tags.push(buildExpirationTag(options.expirationMonths))
}
if (options.addQuietTag && options.quietDays) {
tags.push(buildQuietTag(options.quietDays))
}
return setDraftEventCache({
kind: ExtendedKind.WIKI_ARTICLE,
content: transformedEmojisContent,
tags
})
}
export async function createWikiArticleMarkdownDraftEvent(
content: string,
mentions: string[],
options: {
dTag: string
title?: string
summary?: string
image?: string
addClientTag?: boolean
isNsfw?: boolean
addExpirationTag?: boolean
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const hashtags = extractHashtags(transformedEmojisContent)
const tags: string[][] = []
tags.push(buildDTag(normalizeDTag(options.dTag)))
if (options.title) {
tags.push(buildTitleTag(options.title))
}
if (options.summary) {
tags.push(['summary', options.summary])
}
if (options.image) {
tags.push(['image', options.image])
}
tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
if (options.addExpirationTag && options.expirationMonths) {
tags.push(buildExpirationTag(options.expirationMonths))
}
if (options.addQuietTag && options.quietDays) {
tags.push(buildQuietTag(options.quietDays))
}
return setDraftEventCache({
kind: ExtendedKind.WIKI_ARTICLE_MARKDOWN,
content: transformedEmojisContent,
tags
})
}
export async function createPublicationContentDraftEvent(
content: string,
mentions: string[],
options: {
dTag: string
title?: string
summary?: string
image?: string
addClientTag?: boolean
isNsfw?: boolean
addExpirationTag?: boolean
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const hashtags = extractHashtags(transformedEmojisContent)
const tags: string[][] = []
tags.push(buildDTag(options.dTag))
if (options.title) {
tags.push(buildTitleTag(options.title))
}
if (options.summary) {
tags.push(['summary', options.summary])
}
if (options.image) {
tags.push(['image', options.image])
}
tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
if (options.addExpirationTag && options.expirationMonths) {
tags.push(buildExpirationTag(options.expirationMonths))
}
if (options.addQuietTag && options.quietDays) {
tags.push(buildQuietTag(options.quietDays))
}
return setDraftEventCache({
kind: ExtendedKind.PUBLICATION_CONTENT,
content: transformedEmojisContent,
tags
})
}
// Citation draft event functions
export function createCitationInternalDraftEvent(
content: string,
options: {
cTag: string // kind:pubkey:hex format
publishedOn?: string // ISO 8601 format
title?: string
author?: string
accessedOn?: string // ISO 8601 format
location?: string
geohash?: string
summary?: string
relayHint?: string
}
): TDraftEvent {
const tags: string[][] = []
tags.push(['c', options.cTag, options.relayHint || ''])
if (options.publishedOn) {
tags.push(['published_on', options.publishedOn])
}
if (options.title) {
tags.push(buildTitleTag(options.title))
}
if (options.author) {
tags.push(['author', options.author])
}
if (options.accessedOn) {
tags.push(['accessed_on', options.accessedOn])
}
if (options.location) {
tags.push(['location', options.location])
}
if (options.geohash) {
tags.push(['g', options.geohash])
}
if (options.summary) {
tags.push(['summary', options.summary])
}
return {
kind: ExtendedKind.CITATION_INTERNAL,
content,
tags,
created_at: dayjs().unix()
}
}
export function createCitationExternalDraftEvent(
content: string,
options: {
url: string
accessedOn: string // ISO 8601 format
title?: string
author?: string
publishedOn?: string // ISO 8601 format
publishedBy?: string
version?: string
location?: string
geohash?: string
openTimestamp?: string // e tag of kind 1040 event
summary?: string
}
): TDraftEvent {
const tags: string[][] = []
tags.push(['u', options.url])
tags.push(['accessed_on', options.accessedOn])
if (options.title) {
tags.push(buildTitleTag(options.title))
}
if (options.author) {
tags.push(['author', options.author])
}
if (options.publishedOn) {
tags.push(['published_on', options.publishedOn])
}
if (options.publishedBy) {
tags.push(['published_by', options.publishedBy])
}
if (options.version) {
tags.push(['version', options.version])
}
if (options.location) {
tags.push(['location', options.location])
}
if (options.geohash) {
tags.push(['g', options.geohash])
}
if (options.openTimestamp) {
tags.push(['open_timestamp', options.openTimestamp])
}
if (options.summary) {
tags.push(['summary', options.summary])
}
return {
kind: ExtendedKind.CITATION_EXTERNAL,
content,
tags,
created_at: dayjs().unix()
}
}
export function createCitationHardcopyDraftEvent(
content: string,
options: {
accessedOn: string // ISO 8601 format
title?: string
author?: string
pageRange?: string
chapterTitle?: string
editor?: string
publishedOn?: string // ISO 8601 format
publishedBy?: string
publishedIn?: string // journal name
volume?: string
doi?: string
version?: string
location?: string
geohash?: string
summary?: string
}
): TDraftEvent {
const tags: string[][] = []
tags.push(['accessed_on', options.accessedOn])
if (options.title) {
tags.push(buildTitleTag(options.title))
}
if (options.author) {
tags.push(['author', options.author])
}
if (options.pageRange) {
tags.push(['page_range', options.pageRange])
}
if (options.chapterTitle) {
tags.push(['chapter_title', options.chapterTitle])
}
if (options.editor) {
tags.push(['editor', options.editor])
}
if (options.publishedOn) {
tags.push(['published_on', options.publishedOn])
}
if (options.publishedBy) {
tags.push(['published_by', options.publishedBy])
}
if (options.publishedIn) {
tags.push(['published_in', options.publishedIn, options.volume || ''])
}
if (options.doi) {
tags.push(['doi', options.doi])
}
if (options.version) {
tags.push(['version', options.version])
}
if (options.location) {
tags.push(['location', options.location])
}
if (options.geohash) {
tags.push(['g', options.geohash])
}
if (options.summary) {
tags.push(['summary', options.summary])
}
return {
kind: ExtendedKind.CITATION_HARDCOPY,
content,
tags,
created_at: dayjs().unix()
}
}
export function createCitationPromptDraftEvent(
content: string,
options: {
llm: string // language model name
accessedOn: string // ISO 8601 format
version?: string
summary?: string // prompt conversation script
url?: string // website llm was accessed from
}
): TDraftEvent {
const tags: string[][] = []
tags.push(['llm', options.llm])
tags.push(['accessed_on', options.accessedOn])
if (options.version) {
tags.push(['version', options.version])
}
if (options.summary) {
tags.push(['summary', options.summary])
}
if (options.url) {
tags.push(['u', options.url])
}
return {
kind: ExtendedKind.CITATION_PROMPT,
content,
tags,
created_at: dayjs().unix()
}
}

78
src/lib/media-kind-detection.ts

@ -0,0 +1,78 @@ @@ -0,0 +1,78 @@
import { ExtendedKind } from '@/constants'
/**
* Get the appropriate event kind for a media file based on its type and duration
* @param file - The file to analyze
* @param isReply - Whether this is a reply/comment
* @returns The event kind number
*/
export async function getMediaKindFromFile(file: File, isReply: boolean = false): Promise<number> {
const fileType = file.type
const fileName = file.name.toLowerCase()
// Check if it's an image
if (fileType.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|heic|avif|apng)$/i.test(fileName)) {
return ExtendedKind.PICTURE // kind 20
}
// Check if it's audio or video
const isAudio = fileType.startsWith('audio/') || /\.(mp3|m4a|ogg|wav|webm|opus|aac|flac)$/i.test(fileName)
const isVideo = fileType.startsWith('video/') || /\.(mp4|webm|ogg|mov|avi|mkv|m4v)$/i.test(fileName)
if (isAudio || isVideo) {
// Get duration for audio/video files
const duration = await getMediaDuration(file)
if (isAudio) {
// Audio mp4 files longer than 60 seconds should be treated as video
if ((fileType === 'audio/mp4' || fileName.endsWith('.m4a')) && duration > 60) {
// Determine if it should be long or short video based on duration
return duration > 600 ? ExtendedKind.VIDEO : ExtendedKind.SHORT_VIDEO
}
// Audio files <= 60 seconds
return isReply ? ExtendedKind.VOICE_COMMENT : ExtendedKind.VOICE
}
if (isVideo) {
// Video files longer than 10 minutes (600 seconds) are long videos
return duration > 600 ? ExtendedKind.VIDEO : ExtendedKind.SHORT_VIDEO
}
}
// Default: treat as picture if we can't determine
return ExtendedKind.PICTURE
}
/**
* Get the duration of a media file in seconds
* @param file - The file to analyze
* @returns Duration in seconds, or 0 if unable to determine
*/
function getMediaDuration(file: File): Promise<number> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file)
const media = document.createElement(file.type.startsWith('audio/') ? 'audio' : 'video')
media.onloadedmetadata = () => {
const duration = media.duration || 0
URL.revokeObjectURL(url)
resolve(duration)
}
media.onerror = () => {
URL.revokeObjectURL(url)
resolve(0)
}
media.src = url
media.load()
// Timeout after 5 seconds
setTimeout(() => {
URL.revokeObjectURL(url)
resolve(0)
}, 5000)
})
}

93
src/lib/private-relays.ts

@ -0,0 +1,93 @@ @@ -0,0 +1,93 @@
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { ExtendedKind } from '@/constants'
/**
* Check if user has private relays available (outbox relays or cache relays)
* @param pubkey - User's public key
* @returns Promise<boolean> - true if user has at least one private relay available
*/
export async function hasPrivateRelays(pubkey: string): Promise<boolean> {
// Check for outbox relays (kind 10002)
const relayList = await client.fetchRelayList(pubkey)
if (relayList.write && relayList.write.length > 0) {
return true
}
// Check for cache relays (kind 10432)
const cacheRelayEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)
if (cacheRelayEvent) {
// Check if cache relay event has any relays
const hasRelays = cacheRelayEvent.tags.some(tag => tag[0] === 'relay' && tag[1])
if (hasRelays) {
return true
}
}
return false
}
/**
* Get private relay URLs (outbox + cache relays)
* @param pubkey - User's public key
* @returns Promise<string[]> - Array of relay URLs
*/
export async function getPrivateRelayUrls(pubkey: string): Promise<string[]> {
const relayUrls: string[] = []
// Get outbox relays (kind 10002)
const relayList = await client.fetchRelayList(pubkey)
if (relayList.write) {
relayUrls.push(...relayList.write)
}
// Get cache relays (kind 10432)
const cacheRelayEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)
if (cacheRelayEvent) {
cacheRelayEvent.tags.forEach(tag => {
if (tag[0] === 'relay' && tag[1]) {
relayUrls.push(tag[1])
}
})
}
// Deduplicate
return Array.from(new Set(relayUrls))
}
/**
* Check if user has cache relays set
* @param pubkey - User's public key
* @returns Promise<boolean> - true if user has at least one cache relay
*/
export async function hasCacheRelays(pubkey: string): Promise<boolean> {
const cacheRelayEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)
if (cacheRelayEvent) {
// Check if cache relay event has any relays
const hasRelays = cacheRelayEvent.tags.some(tag => tag[0] === 'relay' && tag[1])
return hasRelays
}
return false
}
/**
* Get cache relay URLs only
* @param pubkey - User's public key
* @returns Promise<string[]> - Array of cache relay URLs
*/
export async function getCacheRelayUrls(pubkey: string): Promise<string[]> {
const relayUrls: string[] = []
// Get cache relays (kind 10432)
const cacheRelayEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)
if (cacheRelayEvent) {
cacheRelayEvent.tags.forEach(tag => {
if (tag[0] === 'relay' && tag[1]) {
relayUrls.push(tag[1])
}
})
}
return Array.from(new Set(relayUrls))
}
Loading…
Cancel
Save