+ {/* Header */}
+
+ {isSelectionMode ? (
+ // Selection mode header
+ <>
+
+
+
+ {t('Delete')}
+
+
+
+
+ >
+ ) : (
+ // Normal header
+ <>
+ {onBack && (
+
+ )}
+
+
+
+ {displayName}
+ {isFollowing && (
+
+
+
+ )}
+
+ {profile?.nip05 && (
+
{profile.nip05}
+ )}
+
+
+
+
+
+
+
+
+
+
+ {t('Delete All')}
+
+
+
+ {t('Undelete All')}
+
+
+
+ >
+ )}
+
+
+ {/* Messages */}
+
+
+ {isLoadingConversation && messages.length === 0 ? (
+
+
+
+ ) : messages.length === 0 ? (
+
+
{t('No messages yet. Send one to start the conversation!')}
+
+ ) : (
+
+ {isLoadingConversation && (
+
+
+
+ )}
+ {messages.map((message) => {
+ const isOwn = message.senderPubkey === pubkey
+ const isSelected = selectedMessages.has(message.id)
+ return (
+
+ {/* Checkbox - shows on hover or when in selection mode */}
+
+ toggleMessageSelection(message.id)}
+ className="mt-2"
+ />
+
+
+
{message.content}
+
+ {formatTimestamp(message.createdAt)}
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+ {/* Jump to newest button */}
+ {showJumpButton && (
+
+ )}
+
+
+ {/* Composer */}
+
+
+
+
+ {/* Message Info Modal */}
+
+
+ {/* Conversation Settings Modal */}
+
+
+ )
+}
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx
index 08a2e42f..3da4232d 100644
--- a/src/components/Note/index.tsx
+++ b/src/components/Note/index.tsx
@@ -1,9 +1,11 @@
-import { useSecondaryPage } from '@/PageManager'
+import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
import { ExtendedKind, NSFW_DISPLAY_POLICY, SUPPORTED_KINDS } from '@/constants'
import { getParentStuff, isNsfwEvent } from '@/lib/event'
import { toExternalContent, toNote } from '@/lib/link'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
+import { useDM } from '@/providers/DMProvider'
import { useMuteList } from '@/providers/MuteListProvider'
+import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react'
@@ -18,6 +20,7 @@ import ParentNotePreview from '../ParentNotePreview'
import TrustScoreBadge from '../TrustScoreBadge'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
+import { Mail } from 'lucide-react'
import CommunityDefinition from './CommunityDefinition'
import EmojiPack from './EmojiPack'
import FollowPack from './FollowPack'
@@ -50,7 +53,10 @@ export default function Note({
showFull?: boolean
}) {
const { push } = useSecondaryPage()
+ const { navigate } = usePrimaryPage()
const { isSmallScreen } = useScreenSize()
+ const { pubkey } = useNostr()
+ const { startConversation } = useDM()
const { parentEventId, parentExternalContent } = useMemo(() => {
return getParentStuff(event)
}, [event])
@@ -58,6 +64,12 @@ export default function Note({
const [showNsfw, setShowNsfw] = useState(false)
const { mutePubkeySet } = useMuteList()
const [showMuted, setShowMuted] = useState(false)
+
+ const handleStartConversation = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ startConversation(event.pubkey)
+ navigate('inbox')
+ }
const isNsfw = useMemo(
() => (nsfwDisplayPolicy === NSFW_DISPLAY_POLICY.SHOW ? false : isNsfwEvent(event)),
[event, nsfwDisplayPolicy]
@@ -134,6 +146,15 @@ export default function Note({
diff --git a/src/components/Settings/index.tsx b/src/components/Settings/index.tsx
index 558b7841..f37b62a3 100644
--- a/src/components/Settings/index.tsx
+++ b/src/components/Settings/index.tsx
@@ -64,6 +64,7 @@ import {
KeyRound,
LayoutList,
List,
+ MessageSquare,
Monitor,
Moon,
Palette,
@@ -155,6 +156,9 @@ export default function Settings() {
// System settings
const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays())
+ // Messaging settings
+ const [preferNip44, setPreferNip44] = useState(storage.getPreferNip44())
+
const handleLanguageChange = (value: TLanguage) => {
i18n.changeLanguage(value)
setLanguage(value)
@@ -528,6 +532,37 @@ export default function Settings() {
)}
+ {/* Messaging */}
+ {!!pubkey && (
+
+
+
+
+ {t('Messaging')}
+
+
+
+
+
+ {
+ storage.setPreferNip44(checked)
+ setPreferNip44(checked)
+ dispatchSettingsChanged()
+ }}
+ />
+
+
+
+ )}
+
{/* System */}
diff --git a/src/components/Sidebar/AccountButton.tsx b/src/components/Sidebar/AccountButton.tsx
index d1e2de64..f8adc9c2 100644
--- a/src/components/Sidebar/AccountButton.tsx
+++ b/src/components/Sidebar/AccountButton.tsx
@@ -12,7 +12,7 @@ import { cn } from '@/lib/utils'
import { useSecondaryPage, useSidebarDrawer } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
-import { LogIn, LogOut, Plus, Wallet } from 'lucide-react'
+import { LogIn, LogOut, Plus, RefreshCw, Wallet } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import LoginDialog from '../LoginDialog'
@@ -139,6 +139,13 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
className="text-muted-foreground border border-muted-foreground px-1 rounded-md text-xs truncate"
/>
+
+ window.location.reload()}
+ >
+
+ {t('Force Reload')}
+
diff --git a/src/components/Sidebar/InboxButton.tsx b/src/components/Sidebar/InboxButton.tsx
new file mode 100644
index 00000000..a6127422
--- /dev/null
+++ b/src/components/Sidebar/InboxButton.tsx
@@ -0,0 +1,25 @@
+import { usePrimaryPage } from '@/PageManager'
+import { useDM } from '@/providers/DMProvider'
+import { MessageSquare } from 'lucide-react'
+import SidebarItem from './SidebarItem'
+
+export default function InboxButton({ collapse }: { collapse: boolean }) {
+ const { navigate, current, display } = usePrimaryPage()
+ const { hasNewMessages } = useDM()
+
+ return (
+ navigate('inbox')}
+ active={display && current === 'inbox'}
+ collapse={collapse}
+ >
+
+
+ {hasNewMessages && (
+
+ )}
+
+
+ )
+}
diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx
index 24664bc7..4e3285b4 100644
--- a/src/components/Sidebar/index.tsx
+++ b/src/components/Sidebar/index.tsx
@@ -10,6 +10,7 @@ import { ChevronsLeft, ChevronsRight } from 'lucide-react'
import AccountButton from './AccountButton'
import BookmarkButton from './BookmarkButton'
import HomeButton from './HomeButton'
+import InboxButton from './InboxButton'
import LayoutSwitcher from './LayoutSwitcher'
import NotificationsButton from './NotificationButton'
import PostButton from './PostButton'
@@ -57,6 +58,7 @@ export default function PrimaryPageSidebar() {
+ {pubkey && }
{pubkey && }
diff --git a/src/components/SidebarDrawer/index.tsx b/src/components/SidebarDrawer/index.tsx
index 3c82d0ff..4e89ee0e 100644
--- a/src/components/SidebarDrawer/index.tsx
+++ b/src/components/SidebarDrawer/index.tsx
@@ -7,6 +7,7 @@ import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
import AccountButton from '../Sidebar/AccountButton'
import BookmarkButton from '../Sidebar/BookmarkButton'
import HomeButton from '../Sidebar/HomeButton'
+import InboxButton from '../Sidebar/InboxButton'
import LogoutButton from '../Sidebar/LogoutButton'
import NotificationsButton from '../Sidebar/NotificationButton'
import ProfileButton from '../Sidebar/ProfileButton'
@@ -52,6 +53,11 @@ export default function SidebarDrawer({ open, onOpenChange }: SidebarDrawerProps
+ {pubkey && (
+
+
+
+ )}
diff --git a/src/constants.ts b/src/constants.ts
index 425fea93..9eade168 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -43,6 +43,10 @@ export const StorageKey = {
QUICK_REACTION: 'quickReaction',
QUICK_REACTION_EMOJI: 'quickReactionEmoji',
NSFW_DISPLAY_POLICY: 'nsfwDisplayPolicy',
+ PREFER_NIP44: 'preferNip44',
+ DM_CONVERSATION_FILTER: 'dmConversationFilter',
+ DM_ENCRYPTION_PREFERENCES: 'dmEncryptionPreferences',
+ DM_LAST_SEEN_TIMESTAMP: 'dmLastSeenTimestamp',
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
@@ -58,7 +62,8 @@ export const StorageKey = {
export const ApplicationDataKey = {
NOTIFICATIONS_SEEN_AT: 'seen_notifications_at',
- SETTINGS: 'smesh_settings'
+ SETTINGS: 'smesh_settings',
+ DM_DELETED_MESSAGES: 'dm_deleted_messages'
}
export const BIG_RELAY_URLS = [
@@ -69,6 +74,8 @@ export const BIG_RELAY_URLS = [
'wss://relay.orly.dev/'
]
+export const ARCHIVE_RELAY_URL = 'wss://archive.orly.dev/'
+
export const SEARCHABLE_RELAY_URLS = [
'wss://search.nos.today/',
'wss://relay.ditto.pub/',
@@ -80,6 +87,8 @@ export const SEARCHABLE_RELAY_URLS = [
export const GROUP_METADATA_EVENT_KIND = 39000
export const ExtendedKind = {
+ SEAL: 13,
+ PRIVATE_DM: 14,
EXTERNAL_CONTENT_REACTION: 17,
PICTURE: 20,
VIDEO: 21,
@@ -88,6 +97,7 @@ export const ExtendedKind = {
POLL_RESPONSE: 1018,
COMMENT: 1111,
VOICE: 1222,
+ GIFT_WRAP: 1059,
VOICE_COMMENT: 1244,
PINNED_USERS: 10010,
FAVORITE_RELAYS: 10012,
diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts
index 3e046078..ed94f494 100644
--- a/src/lib/draft-event.ts
+++ b/src/lib/draft-event.ts
@@ -3,6 +3,7 @@ import client from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service'
import mediaUpload from '@/services/media-upload.service'
import {
+ TDMDeletedState,
TDraftEvent,
TEmoji,
TMailboxRelay,
@@ -453,6 +454,15 @@ export function createSettingsDraftEvent(settings: TSyncSettings): TDraftEvent {
}
}
+export function createDeletedMessagesDraftEvent(deletedState: TDMDeletedState): TDraftEvent {
+ return {
+ kind: kinds.Application,
+ content: JSON.stringify(deletedState),
+ tags: [buildDTag(ApplicationDataKey.DM_DELETED_MESSAGES)],
+ created_at: dayjs().unix()
+ }
+}
+
export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraftEvent {
return {
kind: kinds.BookmarkList,
diff --git a/src/lib/timestamp.ts b/src/lib/timestamp.ts
new file mode 100644
index 00000000..cf9ca2ef
--- /dev/null
+++ b/src/lib/timestamp.ts
@@ -0,0 +1,29 @@
+import dayjs from 'dayjs'
+import relativeTime from 'dayjs/plugin/relativeTime'
+
+dayjs.extend(relativeTime)
+
+/**
+ * Format a unix timestamp (seconds) to a relative time string.
+ * e.g., "5 minutes ago", "2 hours ago", "3 days ago"
+ */
+export function formatTimestamp(timestamp: number): string {
+ return dayjs.unix(timestamp).fromNow()
+}
+
+/**
+ * Format a unix timestamp to a short time string.
+ * Shows time for today, date for older messages.
+ */
+export function formatMessageTime(timestamp: number): string {
+ const date = dayjs.unix(timestamp)
+ const now = dayjs()
+
+ if (date.isSame(now, 'day')) {
+ return date.format('HH:mm')
+ } else if (date.isSame(now, 'year')) {
+ return date.format('MMM D')
+ } else {
+ return date.format('MMM D, YYYY')
+ }
+}
diff --git a/src/pages/primary/InboxPage/index.tsx b/src/pages/primary/InboxPage/index.tsx
new file mode 100644
index 00000000..fcb9cb17
--- /dev/null
+++ b/src/pages/primary/InboxPage/index.tsx
@@ -0,0 +1,64 @@
+import InboxContent from '@/components/Inbox/InboxContent'
+import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
+import { useDM } from '@/providers/DMProvider'
+import { useNostr } from '@/providers/NostrProvider'
+import { TPageRef } from '@/types'
+import { MessageSquare, LogIn } from 'lucide-react'
+import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { usePrimaryPage } from '@/PageManager'
+import { Button } from '@/components/ui/button'
+
+const InboxPage = forwardRef((_, ref) => {
+ const { t } = useTranslation()
+ const layoutRef = useRef(null)
+ const { pubkey } = useNostr()
+ const { navigate } = usePrimaryPage()
+ const { markInboxAsSeen } = useDM()
+
+ useImperativeHandle(ref, () => layoutRef.current as TPageRef)
+
+ // Mark inbox as seen when page is viewed
+ useEffect(() => {
+ if (pubkey) {
+ markInboxAsSeen()
+ }
+ }, [pubkey, markInboxAsSeen])
+
+ return (
+ }
+ displayScrollToTopButton
+ >
+ {pubkey ? (
+
+ ) : (
+
+
+
+
{t('Sign in to view your messages')}
+
{t('Your encrypted conversations will appear here')}
+
+
+
+ )}
+
+ )
+})
+InboxPage.displayName = 'InboxPage'
+export default InboxPage
+
+function InboxTitlebar() {
+ const { t } = useTranslation()
+ return (
+
+ )
+}
diff --git a/src/providers/DMProvider.tsx b/src/providers/DMProvider.tsx
new file mode 100644
index 00000000..947cef0b
--- /dev/null
+++ b/src/providers/DMProvider.tsx
@@ -0,0 +1,779 @@
+import { ApplicationDataKey, BIG_RELAY_URLS } from '@/constants'
+import { createDeletedMessagesDraftEvent } from '@/lib/draft-event'
+import dmService, { IDMEncryption, isConversationDeleted, isMessageDeleted } from '@/services/dm.service'
+import indexedDb from '@/services/indexed-db.service'
+import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
+import client from '@/services/client.service'
+import { TConversation, TDirectMessage, TDMDeletedState, TDMEncryptionType } from '@/types'
+import { Event, kinds } from 'nostr-tools'
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
+import { useNostr } from './NostrProvider'
+
+type TDMContext = {
+ conversations: TConversation[]
+ currentConversation: string | null
+ messages: TDirectMessage[]
+ isLoading: boolean
+ isLoadingConversation: boolean
+ error: string | null
+ selectConversation: (partnerPubkey: string | null) => void
+ startConversation: (partnerPubkey: string) => void
+ sendMessage: (content: string) => Promise
+ refreshConversations: () => Promise
+ reloadConversation: () => void
+ loadMoreConversations: () => Promise
+ hasMoreConversations: boolean
+ preferNip44: boolean
+ setPreferNip44: (prefer: boolean) => void
+ isNewConversation: boolean
+ clearNewConversationFlag: () => void
+ // Unread tracking
+ totalUnreadCount: number
+ hasNewMessages: boolean
+ markInboxAsSeen: () => void
+ // Selection mode
+ selectedMessages: Set
+ isSelectionMode: boolean
+ toggleMessageSelection: (messageId: string) => void
+ selectAllMessages: () => void
+ clearSelection: () => void
+ // Deletion
+ deleteSelectedMessages: () => Promise
+ deleteAllInConversation: () => Promise
+ undeleteAllInConversation: () => Promise
+}
+
+const DMContext = createContext(undefined)
+
+export const useDM = () => {
+ const context = useContext(DMContext)
+ if (!context) {
+ throw new Error('useDM must be used within a DMProvider')
+ }
+ return context
+}
+
+export function DMProvider({ children }: { children: React.ReactNode }) {
+ const {
+ pubkey,
+ relayList,
+ nip04Encrypt,
+ nip04Decrypt,
+ nip44Encrypt,
+ nip44Decrypt,
+ hasNip44Support,
+ signEvent
+ } = useNostr()
+
+ const [conversations, setConversations] = useState([])
+ const [allConversations, setAllConversations] = useState([])
+ const [currentConversation, setCurrentConversation] = useState(null)
+ const [messages, setMessages] = useState([])
+ const [conversationMessages, setConversationMessages] = useState