feat(nostr): encrypted DMs with inbox and thread view (M11.2)
NIP-04 encrypted DMs with IDB persistence. DM inbox tab in Nostr section with contact list, message threads, and new conversation initiation. Added decodeNpub utility. Sub-tab switcher (Feed/Messages). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
9b4980b669
commit
3a0d63f2b1
@@ -0,0 +1,214 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useNostrIdentity } from './useNostrIdentity'
|
||||
|
||||
export interface DirectMessage {
|
||||
id: string
|
||||
fromPubkey: string
|
||||
toPubkey: string
|
||||
content: string
|
||||
created_at: number
|
||||
decrypted: boolean
|
||||
}
|
||||
|
||||
export interface DMThread {
|
||||
contactPubkey: string
|
||||
contactName: string
|
||||
messages: DirectMessage[]
|
||||
lastMessage: DirectMessage | null
|
||||
unread: number
|
||||
}
|
||||
|
||||
const DB_NAME = 'aiui-nostr-dms'
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = 'messages'
|
||||
|
||||
const threads = ref<DMThread[]>([])
|
||||
const activeContact = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null
|
||||
|
||||
function openDMDB(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise
|
||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||
store.createIndex('contact', 'contactPubkey', { unique: false })
|
||||
store.createIndex('created_at', 'created_at', { unique: false })
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => {
|
||||
dbPromise = null
|
||||
reject(req.error)
|
||||
}
|
||||
})
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
interface StoredDM extends DirectMessage {
|
||||
contactPubkey: string
|
||||
}
|
||||
|
||||
function truncatePubkey(pk: string): string {
|
||||
if (pk.length <= 12) return pk
|
||||
return pk.slice(0, 8) + '...' + pk.slice(-4)
|
||||
}
|
||||
|
||||
async function saveDM(msg: DirectMessage, myPubkey: string): Promise<void> {
|
||||
const db = await openDMDB()
|
||||
const contactPubkey = msg.fromPubkey === myPubkey ? msg.toPubkey : msg.fromPubkey
|
||||
const record: StoredDM = { ...msg, contactPubkey }
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).put(record)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadAllDMs(): Promise<StoredDM[]> {
|
||||
const db = await openDMDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const req = tx.objectStore(STORE_NAME).getAll()
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
function buildThreads(messages: StoredDM[]): DMThread[] {
|
||||
const threadMap = new Map<string, DirectMessage[]>()
|
||||
|
||||
for (const msg of messages) {
|
||||
const existing = threadMap.get(msg.contactPubkey) ?? []
|
||||
existing.push(msg)
|
||||
threadMap.set(msg.contactPubkey, existing)
|
||||
}
|
||||
|
||||
const result: DMThread[] = []
|
||||
for (const [contactPubkey, msgs] of threadMap) {
|
||||
msgs.sort((a, b) => a.created_at - b.created_at)
|
||||
result.push({
|
||||
contactPubkey,
|
||||
contactName: truncatePubkey(contactPubkey),
|
||||
messages: msgs,
|
||||
lastMessage: msgs[msgs.length - 1] ?? null,
|
||||
unread: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by latest message
|
||||
result.sort((a, b) => (b.lastMessage?.created_at ?? 0) - (a.lastMessage?.created_at ?? 0))
|
||||
return result
|
||||
}
|
||||
|
||||
export function useNostrDMs() {
|
||||
const { pubkey, isLoggedIn } = useNostrIdentity()
|
||||
|
||||
const activeThread = computed(() => {
|
||||
if (!activeContact.value) return null
|
||||
return threads.value.find(t => t.contactPubkey === activeContact.value) ?? null
|
||||
})
|
||||
|
||||
async function loadDMs() {
|
||||
if (!isLoggedIn.value) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
const all = await loadAllDMs()
|
||||
threads.value = buildThreads(all)
|
||||
} catch {
|
||||
// IDB error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendDM(toPubkey: string, plaintext: string): Promise<boolean> {
|
||||
if (!window.nostr?.nip04 || !pubkey.value) return false
|
||||
|
||||
try {
|
||||
const encrypted = await window.nostr.nip04.encrypt(toPubkey, plaintext)
|
||||
|
||||
const unsigned = {
|
||||
kind: 4,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', toPubkey]],
|
||||
content: encrypted,
|
||||
}
|
||||
|
||||
const signed = await window.nostr.signEvent(unsigned)
|
||||
if (!signed) return false
|
||||
|
||||
// Store locally
|
||||
const dm: DirectMessage = {
|
||||
id: signed.id,
|
||||
fromPubkey: pubkey.value,
|
||||
toPubkey,
|
||||
content: plaintext,
|
||||
created_at: signed.created_at,
|
||||
decrypted: true,
|
||||
}
|
||||
await saveDM(dm, pubkey.value)
|
||||
|
||||
// Broadcast to relays (reuse useNostr relay infra)
|
||||
const { publishEvent } = await import('./useNostr').then(m => m.useNostr())
|
||||
await publishEvent(signed)
|
||||
|
||||
// Refresh threads
|
||||
const all = await loadAllDMs()
|
||||
threads.value = buildThreads(all)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function receiveDM(eventId: string, fromPubkey: string, encryptedContent: string, createdAt: number): Promise<void> {
|
||||
if (!window.nostr?.nip04 || !pubkey.value) return
|
||||
|
||||
try {
|
||||
const plaintext = await window.nostr.nip04.decrypt(fromPubkey, encryptedContent)
|
||||
|
||||
const dm: DirectMessage = {
|
||||
id: eventId,
|
||||
fromPubkey,
|
||||
toPubkey: pubkey.value,
|
||||
content: plaintext,
|
||||
created_at: createdAt,
|
||||
decrypted: true,
|
||||
}
|
||||
await saveDM(dm, pubkey.value)
|
||||
|
||||
const all = await loadAllDMs()
|
||||
threads.value = buildThreads(all)
|
||||
} catch {
|
||||
// Decryption failed
|
||||
}
|
||||
}
|
||||
|
||||
function selectContact(contactPubkey: string) {
|
||||
activeContact.value = contactPubkey
|
||||
}
|
||||
|
||||
function clearActiveContact() {
|
||||
activeContact.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
threads,
|
||||
activeThread,
|
||||
activeContact,
|
||||
isLoading,
|
||||
loadDMs,
|
||||
sendDM,
|
||||
receiveDM,
|
||||
selectContact,
|
||||
clearActiveContact,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user