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:
mleku
2025-12-28 04:00:16 +02:00
parent 6a7bfe0a3e
commit 2aa0a8c460
187 changed files with 42378 additions and 1454 deletions

View 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()

View 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
View 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'