diff --git a/app/app.tsx b/app/app.tsx
index cf35c1d7e..8f1361d36 100644
--- a/app/app.tsx
+++ b/app/app.tsx
@@ -45,6 +45,9 @@ import { NotificationsProvider } from "./components/notification"
import { SafeAreaProvider } from "react-native-safe-area-context"
import { FlashcardProvider } from "./contexts/Flashcard"
import { NostrGroupChatProvider } from "./screens/chat/GroupChat/GroupChatProvider"
+import { useEffect } from "react"
+import { nostrRuntime } from "./nostr/runtime/NostrRuntime"
+import { AppState } from "react-native"
// FIXME should we only load the currently used local?
// this would help to make the app load faster
@@ -57,55 +60,72 @@ loadAllLocales()
/**
* This is the root component of our app.
*/
-export const App = () => (
+export const App = () => {
/* eslint-disable-next-line react-native/no-inline-styles */
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-)
+
+ useEffect(() => {
+ nostrRuntime.start()
+
+ const sub = AppState.addEventListener("change", (state) => {
+ if (state === "active") nostrRuntime.onForeground()
+ else nostrRuntime.onBackground()
+ })
+
+ return () => {
+ sub.remove()
+ nostrRuntime.stop()
+ }
+ }, [])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/nostr/hooks/useNostrEvents.ts b/app/nostr/hooks/useNostrEvents.ts
new file mode 100644
index 000000000..de435251f
--- /dev/null
+++ b/app/nostr/hooks/useNostrEvents.ts
@@ -0,0 +1,16 @@
+import { useSyncExternalStore } from "react"
+import { nostrRuntime } from "../runtime/NostrRuntime"
+
+/**
+ * Hook to get nostr events from the runtime.
+ * If canonicalKey is provided, returns only events for that key.
+ * Otherwise returns all latest canonical events (backward-compatible).
+ */
+export function useNostrEvents(canonicalKey?: string) {
+ const store = nostrRuntime.getEventStore()
+
+ return useSyncExternalStore(
+ (cb) => store.subscribe(cb),
+ () => (canonicalKey ? store.getAllVersions(canonicalKey) : store.getAllCanonical()),
+ )
+}
diff --git a/app/nostr/runtime/NostrRuntime.ts b/app/nostr/runtime/NostrRuntime.ts
new file mode 100644
index 000000000..dd5e0fcea
--- /dev/null
+++ b/app/nostr/runtime/NostrRuntime.ts
@@ -0,0 +1,75 @@
+import { Filter, Event } from "nostr-tools"
+import { RelayManager } from "./RelayManager"
+import { SubscriptionRegistry } from "./SubscriptionRegistry"
+import { EventStore } from "../store/EventStore"
+
+export class NostrRuntime {
+ private relayManager = new RelayManager()
+ private subscriptions = new SubscriptionRegistry(this.relayManager)
+ private events = new EventStore()
+
+ start() {
+ this.relayManager.start()
+ }
+
+ stop() {
+ this.subscriptions.clear()
+ this.relayManager.stop()
+ }
+
+ onForeground() {
+ this.relayManager.reconnectAll()
+ this.subscriptions.restore()
+ }
+
+ onBackground() {
+ this.relayManager.disconnectAll()
+ }
+
+ /** 🔑 Public API */
+
+ getEventStore() {
+ return this.events
+ }
+
+ ensureSubscription(
+ key: string,
+ filters: Filter[],
+ onEvent?: (event: Event) => void,
+ onEose?: () => void,
+ relays?: string[],
+ ) {
+ return this.subscriptions.ensure(
+ key,
+ filters,
+ (event: Event) => {
+ if (this.events.add(event)) {
+ onEvent?.(event)
+ }
+ },
+ onEose,
+ relays,
+ )
+ }
+
+ releaseSubscription(key: string) {
+ this.subscriptions.release(key)
+ }
+
+ // get latest canonical event by key
+ getEvent(canonicalKey: string) {
+ return this.events.getLatest(canonicalKey)
+ }
+
+ // get all latest canonical events
+ getAllEvents() {
+ return this.events.getAllCanonical()
+ }
+
+ // optional: physical id lookup
+ getEventById(id: string) {
+ return this.events.getById(id)
+ }
+}
+
+export const nostrRuntime = new NostrRuntime()
diff --git a/app/nostr/runtime/RelayManager.ts b/app/nostr/runtime/RelayManager.ts
new file mode 100644
index 000000000..647707540
--- /dev/null
+++ b/app/nostr/runtime/RelayManager.ts
@@ -0,0 +1,53 @@
+import { SimplePool, AbstractRelay } from "nostr-tools"
+
+const DEFAULT_RELAYS = [
+ "wss://relay.damus.io",
+ "wss://relay.primal.net",
+ "wss://relay.snort.social",
+]
+
+export class RelayManager {
+ private pool = new SimplePool()
+ private relays = new Map()
+
+ start() {
+ // intentionally empty – connect lazily
+ }
+
+ stop() {
+ this.pool.close(Array.from(this.relays.keys()))
+ this.relays.clear()
+ }
+
+ async getRelay(url: string): Promise {
+ if (this.relays.has(url)) {
+ return this.relays.get(url)!
+ }
+
+ const relay = await this.pool.ensureRelay(url)
+ this.relays.set(url, relay)
+ return relay
+ }
+
+ getReadRelays() {
+ return DEFAULT_RELAYS
+ }
+
+ reconnectAll() {
+ // nostr-tools handles reconnect internally
+ }
+
+ disconnectAll() {
+ this.pool.close(DEFAULT_RELAYS)
+ }
+
+ subscribe(filters: any[], handlers: any, relays?: string[]) {
+ let relaysToUse: string[] = this.getReadRelays()
+ if ((relays || []).length !== 0) relaysToUse = relays!
+ return this.pool.subscribeMany(relaysToUse, filters, handlers)
+ }
+
+ publish(event: any) {
+ return this.pool.publish(this.getReadRelays(), event)
+ }
+}
diff --git a/app/nostr/runtime/SubscriptionRegistry.ts b/app/nostr/runtime/SubscriptionRegistry.ts
new file mode 100644
index 000000000..5abb74f38
--- /dev/null
+++ b/app/nostr/runtime/SubscriptionRegistry.ts
@@ -0,0 +1,75 @@
+import { Filter, Event, SubCloser } from "nostr-tools"
+import { RelayManager } from "./RelayManager"
+
+type SubscriptionEntry = {
+ filters: Filter[]
+ closer: SubCloser
+ refCount: number
+}
+
+export class SubscriptionRegistry {
+ private subs = new Map()
+
+ constructor(private relayManager: RelayManager) {}
+
+ ensure(
+ key: string,
+ filters: Filter[],
+ onEvent: (event: Event) => void,
+ onEose?: () => void,
+ relays?: string[],
+ ) {
+ const existing = this.subs.get(key)
+
+ if (existing) {
+ existing.refCount++
+ return
+ }
+
+ const closer = this.relayManager.subscribe(
+ filters,
+ {
+ onevent: onEvent,
+ onclose: () => {
+ this.subs.delete(key)
+ },
+ oneose: () => {
+ onEose?.()
+ },
+ },
+ relays,
+ )
+
+ this.subs.set(key, {
+ filters,
+ closer,
+ refCount: 1,
+ })
+ }
+
+ release(key: string) {
+ const sub = this.subs.get(key)
+ if (!sub) return
+
+ sub.refCount--
+ if (sub.refCount <= 0) {
+ sub.closer.close()
+ this.subs.delete(key)
+ }
+ }
+
+ restore() {
+ for (const [key, sub] of this.subs.entries()) {
+ const closer = this.relayManager.subscribe(sub.filters, {
+ onevent: () => {},
+ })
+
+ sub.closer = closer
+ }
+ }
+
+ clear() {
+ this.subs.forEach((s) => s.closer.close())
+ this.subs.clear()
+ }
+}
diff --git a/app/nostr/store/EventStore.ts b/app/nostr/store/EventStore.ts
new file mode 100644
index 000000000..e5fd14895
--- /dev/null
+++ b/app/nostr/store/EventStore.ts
@@ -0,0 +1,94 @@
+import { Event } from "nostr-tools"
+
+type Listener = () => void
+
+export class EventStore {
+ // key = canonical key, value = all physical events for that canonical object
+ private events = new Map()
+ private listeners = new Set()
+ constructor() {
+ this.emit() // emit initial snapshot
+ }
+
+ // Compute canonical key based on event type
+ private getCanonicalKey(event: Event): string {
+ // Parameterized replaceables (30000s)
+ if (event.kind >= 30000 && event.kind < 40000) {
+ const dTag = event.tags.find((t) => t[0] === "d")?.[1]
+ if (!dTag) return `${event.kind}:${event.pubkey}:` // fallback
+ return `${event.kind}:${event.pubkey}:${dTag}`
+ }
+
+ // Replaceable events (10000s)
+ if (
+ (event.kind >= 10000 && event.kind < 20000) ||
+ event.kind === 0 ||
+ event.kind === 3
+ ) {
+ return `${event.kind}:${event.pubkey}`
+ }
+
+ // Immutable events
+ return event.id
+ }
+
+ // Add a new event to the store
+ add(event: Event): boolean {
+ const key = this.getCanonicalKey(event)
+ const arr = this.events.get(key) || []
+
+ // Skip if we already have this exact physical event
+ if (arr.find((e) => e.id === event.id)) return false
+
+ arr.push(event)
+
+ // Sort by created_at descending — newest first
+ arr.sort((a, b) => b.created_at - a.created_at)
+
+ // Save back
+ this.events.set(key, arr)
+
+ // Notify listeners
+ this.emit()
+ return true
+ }
+
+ getById(id: string): Event | undefined {
+ for (const arr of this.events.values()) {
+ const e = arr.find((ev) => ev.id === id)
+ if (e) return e
+ }
+ return undefined
+ }
+
+ // Get all events (latest canonical event per key)
+ getAllCanonical(): Event[] {
+ return Array.from(this.events.values()).map((arr) => arr[0])
+ }
+
+ // Get latest event for a canonical key
+ getLatest(key: string): Event | undefined {
+ return this.events.get(key)?.[0]
+ }
+
+ // Optional: get all versions of a canonical event
+ getAllVersions(key: string): Event[] {
+ return this.events.get(key) || []
+ }
+
+ // Subscribe for updates
+ subscribe(listener: Listener) {
+ this.listeners.add(listener)
+ return () => this.listeners.delete(listener)
+ }
+
+ // Clear all events
+ clear() {
+ this.events.clear()
+ this.emit()
+ }
+
+ private emit() {
+ this.listeners.forEach((l) => l())
+ }
+}
diff --git a/app/screens/chat/GroupChat/GroupChatProvider.tsx b/app/screens/chat/GroupChat/GroupChatProvider.tsx
index 9f1ba1242..66c095352 100644
--- a/app/screens/chat/GroupChat/GroupChatProvider.tsx
+++ b/app/screens/chat/GroupChat/GroupChatProvider.tsx
@@ -11,6 +11,8 @@ import { Event, finalizeEvent } from "nostr-tools"
import { MessageType } from "@flyerhq/react-native-chat-ui"
import { getSecretKey } from "@app/utils/nostr"
import { useChatContext } from "../../../screens/chat/chatContext"
+import { nostrRuntime } from "@app/nostr/runtime/NostrRuntime"
+import { pool } from "@app/utils/nostr/pool"
// ===== Types =====
export type NostrGroupChatProviderProps = {
@@ -56,7 +58,7 @@ export const NostrGroupChatProvider: React.FC = ({
adminPubkeys,
children,
}) => {
- const { poolRef, userPublicKey } = useChatContext()
+ const { userPublicKey } = useChatContext()
// Internal message map ensures dedupe by id
const [messagesMap, setMessagesMap] = useState