-
Composer-level publish decisions remain ephemeral. The per-post toggle in the note editor controls whether the current publish operation should first create the X post, then inject a NIP-48
proxytag pointing at that X URL into the Nostr event before signing and publishing to relays. -
After the Nostr event has been accepted by a relay, the app finalizes the backend mapping between the Nostr event id and the previously created X post id. The NIP-48 proxy tag is therefore the user-visible source of truth embedded in the event itself, while the backend mapping supports account-scoped usage tracking and future X metrics sync.
Nostria - Your Social Network
A beautiful, feature-rich Nostr client that puts control back where it belongs: with you.
- Overview
- Technology Stack
- Application Architecture
- Nostr Protocol Implementation
- Backend Services
- UI/UX Architecture
- State Management
- Navigation & Layout System
- Media Player System
- Authentication & Account Management
- Relay Architecture
- Encryption & Privacy
- Performance Optimization
- Server-Side Rendering (SSR)
- Platform Support
- Content Creation
- Command Palette
- AI Features
- Key Design Decisions
- Development Guidelines
Nostria is a decentralized social media client built on the Nostr protocol. It provides a beautiful, responsive user experience across all platforms while maintaining the core principles of decentralization, user sovereignty, and censorship resistance.
IMPORTANT: Make sure that in the UI for users, you don't call things for "events", but for "posts". For code it's good to call it "event" and "events", but user-facing texts should say "post" and "posts".
- Decentralization: No central server dependency; users control their data and relay connections
- User Sovereignty: Full control over identity, data, and social graph
- Performance First: Optimized rendering, lazy loading, and efficient resource management, including local database caching and retrieval.
- Universal Access: Works on web, desktop (Tauri), and mobile (PWA/Bubblewrap)
- Beautiful Design: Glass-effect UI with modern Material 3 theming
- Feeds: Customizable content feeds with multiple sources (following, public, trending, search)
- Summary: Content summarization for quick overview
- Messages: Encrypted direct messaging (NIP-04, NIP-17, NIP-44)
- Articles: Long-form content support (NIP-23)
- Discover: Content discovery and exploration
- People: Profile management and social connections
- Collections: Follow sets and content organization
- Music: Integrated music player with offline support
- Streams: Live streaming viewer
- Notifications: Real-time activity notifications
- Push Notifications: Some notifications via web push, such as Zaps, Follows and more. This is handled by separate service hosted by Nostria.
- Zaps: Bitcoin Lightning payments (NIP-57)
| Technology | Purpose |
|---|---|
| Angular 21+ | Core framework with standalone components |
| Angular Material 3 | UI component library |
| TypeScript | Type-safe development |
| Signals | Reactive state management |
| nostr-tools | Nostr protocol implementation |
| @getalby/sdk | Lightning/NWC wallet integration |
| Service | URL Pattern | Purpose |
|---|---|---|
| Image Proxy | proxy.{region}.nostria.app |
Optimized image delivery and caching |
| Metadata Service | metadata.nostria.app |
OpenGraph/social preview fetching |
| Discovery Relay | discovery.{region}.nostria.app |
Relay discovery and bootstrapping |
| CORS Proxy | proxy.{region}.nostria.app/api/cors-proxy |
Cross-origin resource fetching |
| Technology | Purpose |
|---|---|
| Tauri | Desktop application packaging |
| Bubblewrap | Android TWA packaging |
| PWA | Progressive Web App support |
src/
├── app/
│ ├── api/ # Generated API clients
│ ├── components/ # Reusable UI components
│ ├── directives/ # Angular directives
│ ├── interfaces/ # TypeScript interfaces
│ ├── models/ # Data models
│ ├── pages/ # Route-level page components
│ ├── pipes/ # Angular pipes
│ ├── services/ # Business logic and data services
│ ├── utils/ # Utility functions
│ └── workers/ # Web workers
├── environments/ # Environment configurations
├── locale/ # i18n translation files
└── types/ # TypeScript type definitions
All components follow these patterns:
- Standalone Components: No NgModules; each component is self-contained
- OnPush Change Detection:
changeDetection: ChangeDetectionStrategy.OnPush - Signal-Based State: Using
signal(),computed(), andeffect() - Input/Output Functions:
input()andoutput()instead of decorators - Native Control Flow:
@if,@for,@switchinstead of structural directives
@Component({
selector: 'app-example',
templateUrl: './example.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExampleComponent {
// Inputs using signal-based input()
data = input.required<DataType>();
optional = input<string>();
// Outputs using output()
selected = output<Item>();
// Services using inject()
private readonly service = inject(ExampleService);
// Local state with signals
items = signal<Item[]>([]);
loading = signal(false);
// Derived state with computed
filteredItems = computed(() => this.items().filter((item) => item.active));
}Services follow single-responsibility principle:
providedIn: 'root': Singleton services for global stateinject()Function: Constructor injection via inject function- Signal-Based State: Reactive state with signals
- Lazy Loading: On-demand service initialization where appropriate
Nostria implements the following Nostr Implementation Possibilities (NIPs):
| NIP | Name | Status |
|---|---|---|
| NIP-01 | Basic protocol flow | ✅ Implemented |
| NIP-02 | Contact List and Petnames | ✅ Implemented |
| NIP-04 | Encrypted Direct Message (Legacy) | ✅ Implemented |
| NIP-05 | DNS-based identifiers | ✅ Implemented |
| NIP-07 | Browser extension signing | ✅ Implemented |
| NIP-09 | Event Deletion | ✅ Implemented |
| NIP-10 | Text Notes and Threads | ✅ Implemented |
| NIP-11 | Relay Information | ✅ Implemented |
| NIP-17 | Private Direct Messages | ✅ Implemented |
| NIP-18 | Reposts | ✅ Implemented |
| NIP-19 | bech32-encoded entities | ✅ Implemented |
| NIP-21 | nostr: URL scheme |
✅ Implemented |
| NIP-22 | Comments | ✅ Implemented |
| NIP-23 | Long-form Content (Articles) | ✅ Implemented |
| NIP-25 | Reactions | ✅ Implemented |
| NIP-27 | Text Note References | ✅ Implemented |
| NIP-30 | Custom Emoji | ✅ Implemented |
| NIP-36 | Sensitive Content | ✅ Implemented |
| NIP-42 | Relay Authentication | ✅ Implemented |
| NIP-44 | Encrypted Payloads (Gift Wrap) | ✅ Implemented |
| NIP-46 | Nostr Remote Signing | ✅ Implemented |
| NIP-47 | Nostr Wallet Connect | ✅ Implemented |
| NIP-50 | Keywords filter (Search) | ✅ Implemented |
| NIP-51 | Lists (Bookmarks, Mutes) | ✅ Implemented |
| NIP-52 | Calendar Events | ✅ Implemented |
| NIP-55 | Android Signer | ✅ Implemented |
| NIP-57 | Lightning Zaps | ✅ Implemented |
| NIP-58 | Badges | ✅ Implemented |
| NIP-65 | Relay List Metadata | ✅ Implemented |
| NIP-68 | Picture-first feeds | ✅ Implemented |
| NIP-71 | Video Events | ✅ Implemented |
| NIP-75 | Zap Goals | ✅ Implemented |
| NIP-85 | Trusted Assertions (Web of Trust) | ✅ Implemented |
| NIP-98 | HTTP Auth | ✅ Implemented |
| NIP-A0 | Voice Messages | ✅ Implemented |
| BUD-01/02/03 | Blossom file storage | ✅ Implemented |
Nostria makes specific protocol decisions:
- NIP-65 Relay Flags: READ/WRITE flags are ignored; all relays are both read and write.
- NIP-96: Not implemented; Blossom is preferred for file storage
- NIP-58 Badges: Both badge definition and badge claim are published to user's relays for self-contained data
IMPORTANT: Nostr uses Unix timestamps in seconds, not milliseconds:
// Correct
const timestamp = Math.floor(Date.now() / 1000);
// Incorrect
const timestamp = Date.now(); // This is milliseconds!Used for all profile images except the profile details page (where full resolution is needed).
// ImageCacheService usage
const optimizedUrl = imageCacheService.getOptimizedImageUrl(originalUrl);
// Returns: https://proxy.{region}.nostria.app/api/ImageOptimizeProxy?w=96&h=96&url=...Features:
- Region-based routing (EU, US, etc.)
- Size optimization (default 96x96 for profiles)
- Caching headers for performance
- Configurable per user settings
Fetches OpenGraph metadata for URL previews:
// OpenGraphService usage
const metadata = await opengraphService.getOpenGraphData(url);
// Fetches from: https://metadata.nostria.app/og?url=...For fetching cross-origin resources:
// CorsProxyService usage
const proxyUrl = corsProxyService.getProxyUrl(targetUrl);
const response = await corsProxyService.fetch(url);- Material 3: Full Angular Material 3 theming
- Glass Effect: Translucent toolbars and menus with blur effects
- Dark/Light Mode: System-aware with manual override
- RTL Support: Arabic and Persian locale support
// Primary colors
--mat-sys-primary: #c5c0ff;
--mat-sys-on-primary: #2a2278;
--mat-sys-primary-container: #413b8f;
// Surface colors
--mat-sys-surface: #18111b;
--mat-sys-surface-container: #241d27;
--mat-sys-on-surface: #ecdeed;
// Semantic colors
--mat-sys-error: #ffb4ab;
--mat-success-color: #66bb6a;
--nostria-bitcoin: #ff6b1a;
// Elevation
--mat-sys-level0 through --mat-sys-level5
// Corner radius
--mat-sys-corner-small: 8px;
--mat-sys-corner-medium: 12px;
--mat-sys-corner-large: 16px;// Use :host-context for dark mode styles
:host-context(.dark) .your-class {
background-color: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
}Nostria uses CustomDialogComponent instead of Angular Material dialogs for better mobile support:
// CustomDialogService usage
const ref = customDialog.open(MyDialogComponent, {
title: 'Dialog Title',
data: {
/* ... */
},
});
const result = await ref.closed.toPromise();Features:
- Responsive: floating on desktop, full-screen on mobile
- Keyboard-aware: adjusts for mobile keyboard
- Enter key support for primary action
- Backdrop click to close
@Injectable({ providedIn: 'root' })
export class StateService {
// Writable signals for state
private readonly _items = signal<Item[]>([]);
// Read-only public access
readonly items = this._items.asReadonly();
// Computed derived state
readonly activeItems = computed(() => this._items().filter((i) => i.active));
// Effects for side effects
constructor() {
effect(() => {
const items = this.items();
this.persistToStorage(items);
});
}
}| Service | Purpose |
|---|---|
AccountStateService |
Current user account state |
AccountLocalStateService |
Per-account local preferences |
ApplicationStateService |
Global app state |
ProfileStateService |
Profile data management |
FeedService |
Feed configuration and data |
SettingsService |
User settings |
LocalSettingsService |
Device-local settings |
Dual-posting integrations follow a split-responsibility model:
SettingsServicestores the user's product preference for whether dual-posting should be enabled by default on new posts. This preference is synchronized through the existing Nostr settings event so it follows the user across devices.- Backend-managed credentials and connection state are kept out of the Nostr settings event. For X dual-posting, OAuth tokens are stored only in the
nostria-servicedatabase and used server-side when the app explicitly requests a dual-post. - Composer-level publish decisions remain ephemeral. The per-post toggle in the note editor controls whether the current publish operation should also trigger the backend X post call after the Nostr event has been published successfully.
This separation keeps secrets off relays while still allowing user preferences to sync naturally with the rest of the app settings.
These two services serve different purposes for local storage:
Purpose: Per-account UI preferences and caching metadata stored locally.
When to use: Settings that should be different for each Nostr account on the same device.
Storage key: nostria-account-states (stores a map keyed by pubkey)
Examples:
notificationLastCheck: Timestamp of last notification check (per-account)messagesLastCheck: Timestamp of last messages check (per-account)zapHistoryLastTimestamp: Most recent zap timestamp for incremental fetchingactiveFeed: The currently selected feed for that accountfavorites: User's favorite itemsmusicYoursSectionCollapsed: UI collapse state for music sectiontrustedMediaAuthors: Authors whose media is auto-loaded
// Usage
private accountLocalState = inject(AccountLocalStateService);
// Get per-account timestamp
const lastZapTimestamp = this.accountLocalState.getZapHistoryLastTimestamp(pubkey);
// Set per-account timestamp
this.accountLocalState.setZapHistoryLastTimestamp(pubkey, timestamp);Purpose: Technical/global settings that apply across all accounts on the device.
When to use: Device-level preferences that don't change based on which account is logged in.
Storage key: nostria-local-settings
Examples:
useProxy: Whether to use the image proxy (network optimization)proxyRegion: Which regional proxy server to usemediaServer: Preferred media upload serveraiEnabled: Whether AI features are enabled on this devicevirtualKeyboardHeight: Cached keyboard height for mobile
// Usage
private localSettings = inject(LocalSettingsService);
// Get global setting
const useProxy = this.localSettings.settings().useProxy;
// Update global setting
this.localSettings.update({ useProxy: true });| Scenario | Service to Use |
|---|---|
| "Remember my last feed selection" | AccountLocalStateService |
| "Remember the proxy region for this device" | LocalSettingsService |
| "Track when I last checked notifications" | AccountLocalStateService |
| "Enable AI features on this device" | LocalSettingsService |
| "Cache timestamp for incremental fetching" | AccountLocalStateService |
| "Store mobile keyboard height" | LocalSettingsService |
User Action
↓
Component (handles UI event)
↓
Service (business logic, signal updates)
↓
Effect (side effects: persistence, API calls)
↓
Signal Update
↓
Computed Signals (derived state)
↓
Component Template (re-renders via OnPush)
The app uses a single unified menu that slides out from the left side. The menu button in the toolbar displays the user's profile image (or a generic icon if not authenticated).
Key elements:
- Profile Button (top-left toolbar): Shows user's profile picture, opens the unified left sidenav
- Left Sidenav: Contains user profile section with account switcher, navigation items, and settings
- Right Toolbar Section: Contains search, notifications, and right panel actions
The profile section at the top of the sidenav includes:
- User avatar and name
- Premium badge (if subscribed)
- Expandable account list for switching between accounts
- Quick action buttons (profile, credentials, settings, theme toggle)
The app uses a sophisticated two-column layout system:
┌──────────────────────────────────────────────────┐
│ Toolbar │
├──────────────────────┬───────────────────────────┤
│ │ │
│ Left Panel │ Right Panel │
│ (700px) │ (700px) │
│ │ │
│ - Feeds │ - Event details │
│ - Music list │ - Article content │
│ - Activity │ - Profile details │
│ │ │
└──────────────────────┴───────────────────────────┘
Responsive Behavior:
- Desktop (>1440px): Full two-column (700px + 700px)
- Tablet (1024-1440px): Narrower columns (600px + 600px)
- Mobile (<1024px): Right panel overlays left panel
| Service | Purpose |
|---|---|
RightPanelService |
Manages right panel content stack (component-based) |
PanelNavigationService |
Primary navigation service - manages left/right panel stacks |
TwoColumnLayoutService |
Column width, visibility, and route categorization |
LayoutService |
Screen size detection, dialog management |
The right panel uses a component-based approach (not routing):
// Open detail in right panel
rightPanelService.open(
{
component: EventPageComponent,
inputs: { dialogEventId: eventId },
title: 'Thread',
},
`/e/${eventId}`,
);
// Navigate back
rightPanelService.goBack();
// Close panel
rightPanelService.close();Benefits:
- Left panel state is preserved
- Clean URLs for sharing
- Independent navigation history
- No router-outlet conflicts
The hamburger menu supports:
- Adding/removing items
- Drag-and-drop reordering
- Works on both desktop and mobile
- Persisted per-account
Integrated media player supporting multiple formats:
interface MediaItem {
artwork: string;
title: string;
artist: string;
source: string;
type: 'Music' | 'Podcast' | 'YouTube' | 'Video' | 'HLS' | 'LiveKit' | 'External';
isLiveStream?: boolean;
lyrics?: string;
}Features:
- Shuffle and repeat modes
- Queue management with drag-and-drop
- Podcast progress tracking
- Offline music support
- YouTube integration
- Live stream viewer with full-screen mode
- Wake lock to prevent screen sleep
- Expandable player UI
- Minimized: Small bar at bottom of screen
- Expanded: Larger view with artwork and controls
- Full-screen: For video/live streams
Users can manage multiple Nostr identities:
interface NostrUser {
pubkey: string;
privkey?: string; // Plain hex or encrypted
mnemonic?: string; // BIP39 phrase (encrypted)
source: 'extension' | 'nsec' | 'preview' | 'remote' | 'external';
bunker?: BunkerPointer; // For NIP-46 remote signing
isEncrypted?: boolean; // PIN protection flag
preferredSigningMethod?: 'local' | 'remote';
}| Method | NIP | Description |
|---|---|---|
| nsec | - | Direct private key (can be PIN-encrypted) |
| Browser Extension | NIP-07 | window.nostr interface |
| Remote Signer | NIP-46 | Bunker/NIP-46 protocol |
| Android Signer | NIP-55 | Android intent-based signing |
Private keys can be encrypted with a user PIN:
// CryptoEncryptionService
const encrypted = await cryptoEncryption.encrypt(privateKey, pin);
const decrypted = await cryptoEncryption.decrypt(encrypted, pin);RelayServiceBase (relay.ts)
├── AccountRelayService - User's personal relays
├── SharedRelayService - Other users' relay discovery
└── DiscoveryRelayService - Bootstrap/discovery relays
RelayPoolService - Shared connection pool
SubscriptionManagerService - Global subscription coordination
RelaysService - Relay statistics and configuration
Manages the authenticated user's relay connections:
// Initialization
await accountRelay.setAccount(pubkey);
// Usage
const event = await accountRelay.get(filter);
const subscription = accountRelay.subscribe(filter, onEvent, onEose);
await accountRelay.publish(event);For querying other users' content:
// Automatically discovers user's relays
const profile = await sharedRelay.getUserProfile(pubkey);- Maximum 50 total subscriptions
- Maximum 10 subscriptions per relay
- Automatic deduplication
- Lifecycle tracking
The Following feed uses a hybrid author-priority strategy to minimize time-to-first-render:
- Known active authors first (sorted by
lastPostedAtSec) - Unknown authors fallback to reverse follow order (latest follows at the bottom of kind-3 list are queried first)
- Incremental render path pushes events to UI as soon as each relay batch returns
Author activity is persisted in shared IndexedDB (followingActivity store) and updated when events arrive from tracked pubkeys.
Tracked pubkeys are built from:
- Default kind-3 following list
- Pubkeys present in the account's People Lists (kind 30000)
Periodic refresh for Following uses a windowed active-first rotation:
- A fixed top-active slice is always queried each cycle
- Remaining authors are queried via rotating windows so all follows are eventually checked
Supports both legacy and modern encryption:
| Protocol | NIP | Use Case |
|---|---|---|
| NIP-04 | Legacy | Old DM compatibility |
| NIP-44 | Modern | New encrypted messages |
| NIP-17 | Gift Wrap | Private DMs |
// EncryptionService usage
const encrypted = await encryption.encrypt(content, recipientPubkey);
const decrypted = await encryption.decrypt(content, senderPubkey);For remote signers, operations are queued to prevent overwhelming:
// Operations queued with 100ms delay between
pendingBunkerOperations = signal(0);
isBunkerConnecting = signal(false);Critical Rule: Don't render all items even if available in memory:
// Feed rendering with virtual scrolling
displayedItems = computed(() =>
this.allItems().slice(0, this.visibleCount())
);
// Load more on scroll
onScroll(event: Event) {
if (this.isNearBottom(event)) {
this.visibleCount.update(n => n + 20);
}
}If an component already uses Virtual Scrolling, don't automatically change to a different architecture unless explicitly told to do so.
For lists with fixed-height items, use Angular CDK's ScrollingModule:
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport
[itemSize]="72"
[minBufferPx]="400"
[maxBufferPx]="800"
class="virtual-viewport">
@for (item of items(); track item.id) {
<div class="fixed-height-item">{{ item.name }}</div>
}
</cdk-virtual-scroll-viewport>
`,
styles: [`
.virtual-viewport {
height: 100%;
/* or a fixed height like 500px */
}
.fixed-height-item {
height: 72px; /* Must match itemSize */
}
`]
})When to use CDK Virtual Scrolling:
- Lists with fixed-height items (notifications, zap history, following lists)
- Data sets with 100+ items
- Items that don't need dynamic resizing
When NOT to use CDK Virtual Scrolling:
- Feeds with variable content (notes with images, articles, reposts)
- Lists where items need to expand/collapse
- Content with unknown height until rendered
Configuration Parameters:
| Parameter | Purpose | Typical Value |
|---|---|---|
itemSize |
Height of each item in pixels | 48-96 |
minBufferPx |
Minimum buffer of content to render | 400 |
maxBufferPx |
Maximum buffer of content to render | 800 |
Fixed-Height Layout Strategies:
For items with optional content (comments, metadata), use these techniques to maintain fixed height:
- Truncation with ellipsis: Show first line with
... - Inline layout: Move optional content to same row
- Tooltip for overflow: Full content on hover
- Expandable rows: Click to expand (use manual virtualization instead)
- Profile images: Always through image proxy (96x96)
- Profile details page: Original source (full resolution)
- Lazy loading: Images load as they enter viewport
- Preloading: Critical images preloaded in background
Custom RouteReuseStrategy preserves component state:
@Injectable()
export class CustomReuseStrategy implements RouteReuseStrategy {
// Preserves feed scroll position
// Caches heavy components
// Clears on major navigation changes
}- OnPush everywhere: Components only update on input changes
- Signal-based: Fine-grained reactivity
- Computed caching: Derived values cached until dependencies change
// app.routes.server.ts
export const serverRoutes: ServerRoute[] = [
{ path: 'e/**', renderMode: RenderMode.Server }, // Events
{ path: 'p/**', renderMode: RenderMode.Server }, // Profiles
{ path: 'u/**', renderMode: RenderMode.Server }, // Usernames
{ path: 'a/**', renderMode: RenderMode.Server }, // Articles
{ path: 'stream/**', renderMode: RenderMode.Server },
{ path: 'music/**', renderMode: RenderMode.Server },
{ path: '**', renderMode: RenderMode.Client }, // Everything else
];CRITICAL: Never access browser APIs directly:
// ❌ WRONG - Will crash SSR
const width = window.innerWidth;
// ✅ CORRECT - Check platform first
import { isPlatformBrowser, PLATFORM_ID } from '@angular/common';
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
if (this.isBrowser) {
const width = window.innerWidth;
}Forbidden in SSR context:
window,document,localStoragenavigator,locationcachesAPI (Cache Storage)setTimeout/setInterval(without platform check)- Any DOM manipulation
The SSR server (src/server.ts) detects social media bots and search engine crawlers to provide optimized responses with proper meta tags:
Detected Bots:
- Social: Facebook, Twitter, LinkedIn, Discord, Slack, Telegram, WhatsApp, Pinterest, Tumblr
- Search: Google, Bing, Yahoo, DuckDuckGo, Baidu, Yandex
- Other: Embed services, preview generators
// Bot detection in server.ts
const BOT_USER_AGENTS = [
'facebookexternalhit',
'Facebot',
'Twitterbot',
'LinkedInBot',
'Discordbot',
'Slackbot',
'TelegramBot',
'WhatsApp',
'Googlebot',
'bingbot',
'DuckDuckBot', // ... and more
];For bot requests, SSR responses are cached in memory to improve performance:
| Configuration | Value | Purpose |
|---|---|---|
SSR_CACHE_MAX_AGE_MS |
5 minutes | Cache TTL |
SSR_CACHE_MAX_ENTRIES |
1000 | Maximum cached responses |
| Cache-Control (bot) | max-age=300, s-maxage=600 |
CDN caching headers |
Cache Headers:
X-SSR-Cache: HIT- Response served from cacheX-SSR-Cache: MISS- Fresh SSR render
Timeouts ensure fast responses for social media bots:
| Component | Timeout | Purpose |
|---|---|---|
METADATA_REQUEST_TIMEOUT_MS |
1.8 sec | Optional external metadata fetch |
TOTAL_RESOLVER_TIMEOUT_MS |
6 seconds | Total resolver time |
RELAY_FETCH_TIMEOUT_MS |
3 seconds | Relay queries (stream resolver) |
On timeout, default meta tags are returned so bots still get a valid response.
SSR social metadata resolution is local-first:
DataResolverandStreamResolverfetch event/profile data directly from relays during SSR.- External metadata API calls are not used by SSR resolvers.
This reduces dependency on metadata.nostria.app for first bot fetches (Discord/X/etc.) and improves preview reliability when upstream metadata API latency spikes.
The MetaService handles social sharing metadata with proper canonical URL generation:
// MetaService usage for SSR
metaService.updateSocialMetadata({
title: 'Event Title',
description: 'Event content...',
image: 'https://...',
url: 'https://nostria.app/p/npub1...', // Canonical URL
twitterCard: 'summary_large_image',
});Canonical URL Generation (og:url):
The og:url meta tag always uses canonical Nostr-encoded URLs, regardless of how the content was accessed:
| Access URL | og:url (Canonical) |
|---|---|
/u/sondreb (username) |
https://nostria.app/p/npub1zl3g38... |
/p/nprofile1... (with relays) |
https://nostria.app/p/npub1... (npub form) |
/a/naddr1... (with relay hints) |
https://nostria.app/a/naddr1... (without hints) |
/a/npub.../slug (friendly URL) |
https://nostria.app/a/naddr1... (naddr form) |
/stream/naddr1... (with relays) |
https://nostria.app/stream/naddr1... (without relays) |
Key Files:
src/server.ts- Bot detection, caching, fallback HTMLsrc/app/services/meta.service.ts- Meta tag updates, canonical URL generationsrc/app/data-resolver.ts- Profile/event/article metadata resolutionsrc/app/stream-resolver.ts- Stream metadata resolution
SSR uses resolvers to fetch metadata before rendering:
| Resolver | Route | Purpose |
|---|---|---|
DataResolver |
/p/**, /u/**, /e/**, /a/** |
Profile, event, article metadata |
StreamResolver |
/stream/** |
Live stream metadata |
ArticleResolver |
/a/:id/:slug |
Article content |
UsernameResolver |
/u/:username |
Username to pubkey resolution |
If SSR fails for a bot request, a fallback HTML page is served with default meta tags:
<meta property="og:title" content="Nostria - Your Social Network" />
<meta property="og:description" content="A decentralized social network..." />
<meta property="og:image" content="https://nostria.app/assets/nostria-social.jpg" />- URL: https://nostria.app
- PWA: Installable with offline support
- Service Worker: Caching and background sync
npm run tauri dev # Development
npm run tauri build # Production buildFeatures:
- Native window management
- System tray integration
- Auto-updates
Android TWA (Bubblewrap):
bubblewrap init --manifest https://nostria.app/manifest.webmanifest
bubblewrap buildAndroid Tauri:
npm run tauri android init
npm run tauri android dev| Type | Component | Event Kind |
|---|---|---|
| Note | NoteEditorDialogComponent |
1 |
| Article | ArticleEditorDialogComponent |
30023 |
| Media | MediaCreatorDialogComponent |
Varies |
| Video Clip | VideoRecordDialogComponent |
1063 |
| Audio Clip | AudioRecordDialogComponent |
1222 (root), 1244 (replies) |
Content Creation
↓
NoteEditorDialog / ArticleEditor
↓
Event Construction (nostr-tools)
↓
Signing (local, extension, or remote)
↓
PublishService
↓
AccountRelayService.publish()
↓
Multiple relays (user's relay list)
Uses Blossom protocol (BUD-01/02/03):
// Upload to user's media servers
const uploadResult = await mediaService.uploadFile(file);
// Returns Blossom URL for inclusion in eventThe Command Palette provides quick keyboard-driven access to all app features. It's opened with Ctrl+K (or Cmd+K on Mac) and supports both keyboard navigation and voice commands.
Location: src/app/components/command-palette-dialog/
- Fuzzy Search: Commands filtered by label and keywords
- Keyboard Navigation: Arrow keys, Enter to execute, Escape to close
- Voice Commands: Transcription via local AI (requires AI enabled)
- Categorized Commands: Navigation, actions, settings
interface Command {
id: string; // Unique identifier (e.g., 'nav-music')
label: string; // Display label (e.g., 'Open Music')
icon: string; // Material icon name
action: () => void; // Action to execute
keywords?: string[]; // Search keywords for fuzzy matching
description?: string; // Optional description
}IMPORTANT: When adding new features or routes to the app, always add corresponding commands to the Command Palette.
// In command-palette-dialog.component.ts
commands: Command[] = [
// ... existing commands
{
id: 'nav-newfeature',
label: 'Open New Feature',
icon: 'feature_icon',
action: () => this.router.navigate(['/newfeature']),
keywords: ['new feature', 'related', 'search', 'terms']
},
];| Category | ID Prefix | Purpose |
|---|---|---|
| Navigation - Core | nav- |
Home, Feeds, Messages, Notifications |
| Navigation - Content | nav- |
Articles, Music, Streams, Media |
| Navigation - Collections | nav- |
Collections, Bookmarks, People, Lists |
| Navigation - Tools | nav- |
Memos, Calendar, Polls, Analytics |
| Navigation - Account | nav- |
Profile, Settings, Accounts, Backup |
| Actions | act- |
Create Note, Create Article, etc. |
When AI transcription is enabled, users can speak commands:
- Direct navigation: "Open Music", "Go to Settings"
- Search: "Search ", "Find "
- Actions: "Create Note", "Create Article"
Nostria includes privacy-focused AI features powered by Transformers.js, which runs machine learning models entirely in the browser. No data is sent to external servers, ensuring complete privacy for users.
Location: src/app/services/ai.service.ts and src/app/workers/ai.worker.ts
User Request
↓
AiService (main thread)
↓
Web Worker (ai.worker.ts)
↓
Transformers.js (ONNX Runtime)
↓
Local ML Models (cached in browser)
| Task | Model | Use Case |
|---|---|---|
| Summarization | distilbart-cnn-6-6 | Summarize long articles/threads |
| Translation | opus-mt-* | Translate content between languages |
| Transcription | whisper-tiny | Voice-to-text for command palette |
| Text-to-Speech | speecht5_tts | Read content aloud |
| Sentiment Analysis | distilbert-sst-2 | Analyze content sentiment |
| Text Generation | LaMini-Flan-T5-783M | Generate text responses |
Key Principle: All AI processing happens locally in the user's browser.
- Models are downloaded once and cached in IndexedDB
- No API calls to external AI services
- User content never leaves the device
- Full functionality works offline (after initial model download)
// AiService injection
private ai = inject(AiService);
// Check if AI is enabled in settings
if (this.settings.settings().aiEnabled) {
// Summarize text
const summary = await this.ai.summarizeText(longText);
// Translate content
const translated = await this.ai.translateText(
text,
'Xenova/opus-mt-es-en' // Spanish to English
);
// Transcribe audio
const transcript = await this.ai.transcribeAudio(audioData);
}Models are managed through the AI Settings page (/ai/settings):
// Load a model (downloads if not cached)
await ai.loadModel('summarization', ai.summarizationModelId, (progress) => {
console.log('Download progress:', progress);
});
// Check if model is loaded/cached
const status = await ai.checkModel('summarization', modelId);
// { loaded: boolean, cached: boolean }
// Delete model from cache
await ai.deleteModelFromCache(modelId);
// Clear all cached models
await ai.clearAllCache();AI features are controlled via user settings:
| Setting | Purpose |
|---|---|
aiEnabled |
Master toggle for all AI features |
aiSummarizationEnabled |
Enable content summarization |
aiTranslationEnabled |
Enable translation |
aiTranscriptionEnabled |
Enable voice transcription |
aiSpeechEnabled |
Enable text-to-speech |
aiSentimentEnabled |
Enable sentiment analysis |
AI processing runs in a Web Worker to prevent UI blocking:
// ai.worker.ts handles:
// - Model loading and caching
// - Inference execution
// - Progress reporting back to main thread
// Main thread receives:
// - 'progress' events during model download
// - 'error' events on failure
// - Result payload on successThe app includes 50+ translation model pairs supporting languages including:
- European: English, Spanish, French, German, Italian, Portuguese, etc.
- Asian: Chinese, Japanese, Korean, Vietnamese, Thai, Hindi, etc.
- Other: Arabic, Russian, Ukrainian, Turkish, etc.
Every component is standalone to enable:
- Tree-shaking
- Faster compilation
- Clearer dependencies
Signals provide:
- Simpler mental model
- Automatic dependency tracking
- Better Angular integration
- Fine-grained reactivity
CustomDialogComponent provides:
- Better mobile keyboard handling
- Full-screen mobile mode
- Consistent styling
- Easier customization
Instead of named router outlets:
- Preserves left panel state
- Simpler URL management
- Independent navigation stack
- Better performance
All backend services use regional routing:
- Lower latency
- Better reliability
- User proximity optimization
- Components:
name.component.ts - Services:
name.service.ts - Interfaces:
name.interface.tsor ininterfaces.ts - Tests:
name.spec.ts
- Prettier: single quotes, CRLF
- ESLint: Angular ESLint rules
- TypeScript: Strict mode, no
any
Minimum 16px to prevent iOS Safari auto-zoom on focus
Don't be afraid of adding global styles to "styles.scss" instead of doing component specific styles. This is to avoid duplicate and have more reuse.
@Component({
selector: 'app-example',
changeDetection: ChangeDetectionStrategy.OnPush,
// No standalone: true needed (it's the default)
})
export class ExampleComponent {
// ✅ Use input() and output()
data = input.required<Data>();
selected = output<Item>();
// ✅ Use inject() for DI
private service = inject(MyService);
// ✅ Use signals for state
items = signal<Item[]>([]);
// ✅ Use computed for derived state
activeCount = computed(() => this.items().filter((i) => i.active).length);
// ❌ Don't use @HostBinding/@HostListener
// ✅ Use host: {} in decorator instead
}<!-- ✅ Use native control flow -->
@if (condition()) {
<div>Content</div>
} @for (item of items(); track item.id) {
<app-item [data]="item" />
}
<!-- ❌ Don't use structural directives -->
<div *ngIf="condition">...</div>
<div *ngFor="let item of items">...</div>
<!-- ❌ Don't use ngClass/ngStyle -->
<div [ngClass]="{'active': isActive}">...</div>
<!-- ✅ Use class/style bindings -->
<div [class.active]="isActive()">...</div>Always use fetch instead of HttpClient:
// ✅ Correct
const response = await fetch(url);
const data = await response.json();
// ❌ Avoid
this.http.get(url).subscribe(data => ...);Nostria uses a comprehensive testing strategy with both unit tests (Karma/Jasmine) and end-to-end tests (Playwright).
Unit Tests (Karma/Jasmine):
npm run test # Run unit testsE2E Tests (Playwright):
npm run test:e2e # Run all e2e tests
npm run test:e2e:ui # Run with Playwright UI
npm run test:e2e:headed # Run in headed browser mode
npm run test:e2e:debug # Run in debug mode
npm run test:e2e:ai # Run with AI-optimized settings (full artifacts)
npm run test:e2e:report # View HTML test report
npm run test:e2e:codegen # Generate tests via recordingOther Tools:
npm run lint # Run linter
npm run format:check # Check formattingFor complete E2E testing documentation, see TESTING.md.
The E2E testing setup is designed for AI/LLM-driven test automation, with emphasis on:
- Structured output formats (JSON) for programmatic analysis
- Automatic screenshot and video capture
- Console log collection for debugging
- Semantic page analysis utilities
e2e/
├── fixtures.ts # Extended test fixtures with AI utilities
├── global-setup.ts # Pre-test setup (creates directories, metadata)
├── global-teardown.ts # Post-test cleanup and summary generation
├── helpers/
│ └── ai-automation.ts # AI-specific automation helpers
├── pages/
│ └── index.ts # Page Object Models for all pages
└── tests/
├── home.spec.ts # Home page tests
├── navigation.spec.ts # Navigation tests
└── accessibility.spec.ts # Accessibility tests
All test artifacts are stored in test-results/:
| Directory | Contents |
|---|---|
screenshots/ |
Named screenshots from tests |
videos/ |
Video recordings (on failure by default) |
traces/ |
Playwright traces for debugging |
logs/ |
Console log captures |
ai-states/ |
Page state snapshots for AI analysis |
artifacts/ |
General test artifacts |
The testing setup includes special utilities for AI-driven testing:
- Page State Capture: Structured JSON containing all interactive elements, visible text, errors, and network requests
- Semantic Actions: Natural language-like commands (
clickButton('Submit'),fillInput('Email', 'test@example.com')) - Console Log Collection: Automatic capture of all console output for debugging
- Screenshot Helpers: Named screenshots with timestamps
| Term | Definition |
|---|---|
| Account | User's identity within Nostria (can have multiple) |
| User | Any Nostr user (not necessarily the current user) |
| Feed | Configured content stream with filters |
| Event | A Nostr protocol event (note, article, etc.) |
| Zap | Bitcoin Lightning payment via Nostr |
| Relay | WebSocket server for Nostr events |
| Kind | Nostr event type number |
- Nostr Protocol: https://github.com/nostr-protocol/nips
- Angular: https://angular.dev
- Angular Material: https://material.angular.io
- Tauri: https://tauri.app
- Blossom: https://github.com/hzrd149/blossom
This document is the authoritative source for Nostria's architecture. AI assistants and developers should reference this when making changes to the codebase.