feat: add QR scanner, improve UX, and simplify navigation
- Add live camera QR scanner for nsec/ncryptsec login - Replace browser prompt() with proper password dialog for ncryptsec - Add missing /notes/:id route for thread view navigation - Remove explore section entirely (button, page, routes) - Remove profile button from bottom nav, avatar now opens profile - Remove "Notes" tab from feed, default to showing all posts/replies - Add PasswordPromptProvider for secure password input - Add SidebarDrawer for mobile navigation - Add domain layer with value objects and adapters - Various UI and navigation improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
187
src/application/PublishingService.ts
Normal file
187
src/application/PublishingService.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Pubkey, Timestamp } from '@/domain/shared'
|
||||
import { FollowList } from '@/domain/social'
|
||||
import { MuteList } from '@/domain/social'
|
||||
import { RelayList } from '@/domain/relay'
|
||||
import { kinds } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Draft event structure (matches TDraftEvent)
|
||||
*/
|
||||
export type DraftEvent = {
|
||||
kind: number
|
||||
content: string
|
||||
created_at: number
|
||||
tags: string[][]
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for publishing a note
|
||||
*/
|
||||
export type PublishNoteOptions = {
|
||||
parentEventId?: string
|
||||
mentions?: Pubkey[]
|
||||
hashtags?: string[]
|
||||
isNsfw?: boolean
|
||||
addClientTag?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* PublishingService Domain Service
|
||||
*
|
||||
* Handles creation of draft events and publishing logic.
|
||||
* This service encapsulates the business rules for event creation.
|
||||
*/
|
||||
export class PublishingService {
|
||||
/**
|
||||
* Create a draft note event
|
||||
*/
|
||||
createNoteDraft(
|
||||
content: string,
|
||||
options: PublishNoteOptions = {}
|
||||
): DraftEvent {
|
||||
const tags: string[][] = []
|
||||
|
||||
// Add mention tags
|
||||
if (options.mentions) {
|
||||
for (const pubkey of options.mentions) {
|
||||
tags.push(['p', pubkey.hex])
|
||||
}
|
||||
}
|
||||
|
||||
// Add hashtags
|
||||
if (options.hashtags) {
|
||||
for (const hashtag of options.hashtags) {
|
||||
tags.push(['t', hashtag.toLowerCase()])
|
||||
}
|
||||
}
|
||||
|
||||
// Add NSFW warning
|
||||
if (options.isNsfw) {
|
||||
tags.push(['content-warning', 'NSFW'])
|
||||
}
|
||||
|
||||
// Add client tag
|
||||
if (options.addClientTag) {
|
||||
tags.push(['client', 'smesh'])
|
||||
}
|
||||
|
||||
return {
|
||||
kind: kinds.ShortTextNote,
|
||||
content,
|
||||
created_at: Timestamp.now().unix,
|
||||
tags
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a draft reaction event
|
||||
*/
|
||||
createReactionDraft(
|
||||
targetEventId: string,
|
||||
targetPubkey: string,
|
||||
targetKind: number,
|
||||
emoji: string = '+'
|
||||
): DraftEvent {
|
||||
const tags: string[][] = [
|
||||
['e', targetEventId],
|
||||
['p', targetPubkey]
|
||||
]
|
||||
|
||||
if (targetKind !== kinds.ShortTextNote) {
|
||||
tags.push(['k', targetKind.toString()])
|
||||
}
|
||||
|
||||
return {
|
||||
kind: kinds.Reaction,
|
||||
content: emoji,
|
||||
created_at: Timestamp.now().unix,
|
||||
tags
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a draft repost event
|
||||
*/
|
||||
createRepostDraft(
|
||||
targetEventId: string,
|
||||
targetPubkey: string,
|
||||
embeddedContent?: string
|
||||
): DraftEvent {
|
||||
return {
|
||||
kind: kinds.Repost,
|
||||
content: embeddedContent || '',
|
||||
created_at: Timestamp.now().unix,
|
||||
tags: [
|
||||
['e', targetEventId],
|
||||
['p', targetPubkey]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a draft follow list event from a FollowList aggregate
|
||||
*/
|
||||
createFollowListDraft(followList: FollowList): DraftEvent {
|
||||
return followList.toDraftEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a draft mute list event from a MuteList aggregate
|
||||
* Note: The caller must handle encryption of private mutes
|
||||
*/
|
||||
createMuteListDraft(muteList: MuteList, encryptedPrivateMutes: string = ''): DraftEvent {
|
||||
return muteList.toDraftEvent(encryptedPrivateMutes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a draft relay list event from a RelayList aggregate
|
||||
*/
|
||||
createRelayListDraft(relayList: RelayList): DraftEvent {
|
||||
return relayList.toDraftEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract mentions from note content
|
||||
* Finds npub1, nprofile1, and @mentions
|
||||
*/
|
||||
extractMentionsFromContent(content: string): Pubkey[] {
|
||||
const mentions: Pubkey[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
// Match npub1 and nprofile1
|
||||
const bech32Regex = /n(?:pub1|profile1)[0-9a-z]{58,}/gi
|
||||
const matches = content.match(bech32Regex) || []
|
||||
|
||||
for (const match of matches) {
|
||||
const pubkey = Pubkey.tryFromString(match)
|
||||
if (pubkey && !seen.has(pubkey.hex)) {
|
||||
seen.add(pubkey.hex)
|
||||
mentions.push(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
return mentions
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract hashtags from note content
|
||||
*/
|
||||
extractHashtagsFromContent(content: string): string[] {
|
||||
const hashtags: string[] = []
|
||||
const matches = content.match(/#[\p{L}\p{N}\p{M}]+/gu) || []
|
||||
|
||||
for (const match of matches) {
|
||||
const hashtag = match.slice(1).toLowerCase()
|
||||
if (hashtag && !hashtags.includes(hashtag)) {
|
||||
hashtags.push(hashtag)
|
||||
}
|
||||
}
|
||||
|
||||
return hashtags
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of the publishing service
|
||||
*/
|
||||
export const publishingService = new PublishingService()
|
||||
188
src/application/RelaySelector.ts
Normal file
188
src/application/RelaySelector.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { RelayUrl } from '@/domain/shared'
|
||||
import { RelayList } from '@/domain/relay'
|
||||
|
||||
/**
|
||||
* Options for relay selection
|
||||
*/
|
||||
export type RelaySelectorOptions = {
|
||||
maxRelays?: number
|
||||
preferSecure?: boolean
|
||||
excludeOnion?: boolean
|
||||
includeDefaultRelays?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* RelaySelector Domain Service
|
||||
*
|
||||
* Handles intelligent selection of relays for various operations.
|
||||
* Implements relay selection strategies based on context.
|
||||
*/
|
||||
export class RelaySelector {
|
||||
constructor(
|
||||
private readonly defaultRelays: RelayUrl[] = []
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Select relays for publishing an event
|
||||
* Prioritizes write relays from the user's relay list
|
||||
*/
|
||||
selectForPublishing(
|
||||
relayList: RelayList | null,
|
||||
options: RelaySelectorOptions = {}
|
||||
): RelayUrl[] {
|
||||
const { maxRelays = 5, preferSecure = true, excludeOnion = false } = options
|
||||
|
||||
const candidates: RelayUrl[] = []
|
||||
|
||||
// Add user's write relays
|
||||
if (relayList) {
|
||||
const writeRelays = relayList.getWriteRelays()
|
||||
candidates.push(...writeRelays)
|
||||
}
|
||||
|
||||
// Add default relays if needed
|
||||
if (options.includeDefaultRelays || candidates.length === 0) {
|
||||
for (const relay of this.defaultRelays) {
|
||||
if (!candidates.some((c) => c.equals(relay))) {
|
||||
candidates.push(relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter and sort
|
||||
let filtered = candidates
|
||||
if (excludeOnion) {
|
||||
filtered = filtered.filter((r) => !r.isOnion)
|
||||
}
|
||||
|
||||
if (preferSecure) {
|
||||
filtered.sort((a, b) => {
|
||||
if (a.isSecure && !b.isSecure) return -1
|
||||
if (!a.isSecure && b.isSecure) return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
return filtered.slice(0, maxRelays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select relays for fetching events
|
||||
* Prioritizes read relays from the user's relay list
|
||||
*/
|
||||
selectForFetching(
|
||||
relayList: RelayList | null,
|
||||
options: RelaySelectorOptions = {}
|
||||
): RelayUrl[] {
|
||||
const { maxRelays = 5, preferSecure = true, excludeOnion = false } = options
|
||||
|
||||
const candidates: RelayUrl[] = []
|
||||
|
||||
// Add user's read relays
|
||||
if (relayList) {
|
||||
const readRelays = relayList.getReadRelays()
|
||||
candidates.push(...readRelays)
|
||||
}
|
||||
|
||||
// Add default relays if needed
|
||||
if (options.includeDefaultRelays || candidates.length === 0) {
|
||||
for (const relay of this.defaultRelays) {
|
||||
if (!candidates.some((c) => c.equals(relay))) {
|
||||
candidates.push(relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter and sort
|
||||
let filtered = candidates
|
||||
if (excludeOnion) {
|
||||
filtered = filtered.filter((r) => !r.isOnion)
|
||||
}
|
||||
|
||||
if (preferSecure) {
|
||||
filtered.sort((a, b) => {
|
||||
if (a.isSecure && !b.isSecure) return -1
|
||||
if (!a.isSecure && b.isSecure) return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
return filtered.slice(0, maxRelays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select relays for publishing to specific users' inboxes
|
||||
* Includes mentioned users' read relays
|
||||
*/
|
||||
selectForMentions(
|
||||
authorRelayList: RelayList | null,
|
||||
mentionedRelayLists: RelayList[],
|
||||
options: RelaySelectorOptions = {}
|
||||
): RelayUrl[] {
|
||||
const { maxRelays = 8 } = options
|
||||
|
||||
const relaySet = new Map<string, RelayUrl>()
|
||||
|
||||
// Add author's write relays first
|
||||
if (authorRelayList) {
|
||||
for (const relay of authorRelayList.getWriteRelays()) {
|
||||
relaySet.set(relay.value, relay)
|
||||
}
|
||||
}
|
||||
|
||||
// Add mentioned users' read relays
|
||||
for (const relayList of mentionedRelayLists) {
|
||||
for (const relay of relayList.getReadRelays()) {
|
||||
relaySet.set(relay.value, relay)
|
||||
}
|
||||
}
|
||||
|
||||
// Add defaults if needed
|
||||
if (options.includeDefaultRelays || relaySet.size === 0) {
|
||||
for (const relay of this.defaultRelays) {
|
||||
relaySet.set(relay.value, relay)
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = Array.from(relaySet.values())
|
||||
|
||||
// Filter onion if needed
|
||||
let filtered = candidates
|
||||
if (options.excludeOnion) {
|
||||
filtered = filtered.filter((r) => !r.isOnion)
|
||||
}
|
||||
|
||||
return filtered.slice(0, maxRelays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relay URLs as strings (for compatibility with existing code)
|
||||
*/
|
||||
selectForPublishingUrls(
|
||||
relayList: RelayList | null,
|
||||
options: RelaySelectorOptions = {}
|
||||
): string[] {
|
||||
return this.selectForPublishing(relayList, options).map((r) => r.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relay URLs as strings for fetching
|
||||
*/
|
||||
selectForFetchingUrls(
|
||||
relayList: RelayList | null,
|
||||
options: RelaySelectorOptions = {}
|
||||
): string[] {
|
||||
return this.selectForFetching(relayList, options).map((r) => r.value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a RelaySelector with default relays
|
||||
*/
|
||||
export function createRelaySelector(defaultRelayUrls: string[]): RelaySelector {
|
||||
const defaultRelays = defaultRelayUrls
|
||||
.map((url) => RelayUrl.tryCreate(url))
|
||||
.filter((r): r is RelayUrl => r !== null)
|
||||
|
||||
return new RelaySelector(defaultRelays)
|
||||
}
|
||||
12
src/application/index.ts
Normal file
12
src/application/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Application Layer
|
||||
*
|
||||
* Use cases and orchestration services.
|
||||
* Coordinates between domain objects and infrastructure.
|
||||
*/
|
||||
|
||||
export { RelaySelector, createRelaySelector } from './RelaySelector'
|
||||
export type { RelaySelectorOptions } from './RelaySelector'
|
||||
|
||||
export { PublishingService, publishingService } from './PublishingService'
|
||||
export type { DraftEvent, PublishNoteOptions } from './PublishingService'
|
||||
Reference in New Issue
Block a user