Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 71 additions & 51 deletions app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 */
<SafeAreaProvider>
<StatusBar
backgroundColor={"#000"}
barStyle={Platform.OS === "android" ? "light-content" : "dark-content"}
/>
<GestureHandlerRootView style={{ flex: 1 }}>
<PolyfillCrypto />
<Provider store={store}>
<PersistentStateProvider>
<ChatContextProvider>
<NostrGroupChatProvider
groupId={"A9lScksyYAOWNxqR"}
relayUrls={["wss://groups.0xchat.com"]}
adminPubkeys={[]}
>
<ActivityIndicatorProvider>
<TypesafeI18n locale={detectDefaultLocale()}>
<ThemeProvider theme={theme}>
<GaloyClient>
<FeatureFlagContextProvider>
<ErrorBoundary FallbackComponent={ErrorScreen}>
<NavigationContainerWrapper>
<RootSiblingParent>
<NotificationsProvider>
<AppStateWrapper />
<PushNotificationComponent />
<BreezProvider>
<FlashcardProvider>
<RootStack />
</FlashcardProvider>
</BreezProvider>
<GaloyToast />
<NetworkErrorComponent />
</NotificationsProvider>
</RootSiblingParent>
</NavigationContainerWrapper>
</ErrorBoundary>
<ThemeSyncGraphql />
</FeatureFlagContextProvider>
</GaloyClient>
</ThemeProvider>
</TypesafeI18n>
</ActivityIndicatorProvider>
</NostrGroupChatProvider>
</ChatContextProvider>
</PersistentStateProvider>
</Provider>
</GestureHandlerRootView>
</SafeAreaProvider>
)

useEffect(() => {
nostrRuntime.start()

const sub = AppState.addEventListener("change", (state) => {
if (state === "active") nostrRuntime.onForeground()
else nostrRuntime.onBackground()
})

return () => {
sub.remove()
nostrRuntime.stop()
}
}, [])

return (
<SafeAreaProvider>
<StatusBar
backgroundColor={"#000"}
barStyle={Platform.OS === "android" ? "light-content" : "dark-content"}
/>
<GestureHandlerRootView style={{ flex: 1 }}>
<PolyfillCrypto />
<Provider store={store}>
<PersistentStateProvider>
<ChatContextProvider>
<NostrGroupChatProvider
groupId={"A9lScksyYAOWNxqR"}
relayUrls={["wss://groups.0xchat.com"]}
adminPubkeys={[]}
>
<ActivityIndicatorProvider>
<TypesafeI18n locale={detectDefaultLocale()}>
<ThemeProvider theme={theme}>
<GaloyClient>
<FeatureFlagContextProvider>
<ErrorBoundary FallbackComponent={ErrorScreen}>
<NavigationContainerWrapper>
<RootSiblingParent>
<NotificationsProvider>
<AppStateWrapper />
<PushNotificationComponent />
<BreezProvider>
<FlashcardProvider>
<RootStack />
</FlashcardProvider>
</BreezProvider>
<GaloyToast />
<NetworkErrorComponent />
</NotificationsProvider>
</RootSiblingParent>
</NavigationContainerWrapper>
</ErrorBoundary>
<ThemeSyncGraphql />
</FeatureFlagContextProvider>
</GaloyClient>
</ThemeProvider>
</TypesafeI18n>
</ActivityIndicatorProvider>
</NostrGroupChatProvider>
</ChatContextProvider>
</PersistentStateProvider>
</Provider>
</GestureHandlerRootView>
</SafeAreaProvider>
)
}
16 changes: 16 additions & 0 deletions app/nostr/hooks/useNostrEvents.ts
Original file line number Diff line number Diff line change
@@ -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()),
)
}
75 changes: 75 additions & 0 deletions app/nostr/runtime/NostrRuntime.ts
Original file line number Diff line number Diff line change
@@ -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()
53 changes: 53 additions & 0 deletions app/nostr/runtime/RelayManager.ts
Original file line number Diff line number Diff line change
@@ -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<string, AbstractRelay>()

start() {
// intentionally empty – connect lazily
}

stop() {
this.pool.close(Array.from(this.relays.keys()))
this.relays.clear()
}

async getRelay(url: string): Promise<AbstractRelay> {
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)
}
}
75 changes: 75 additions & 0 deletions app/nostr/runtime/SubscriptionRegistry.ts
Original file line number Diff line number Diff line change
@@ -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<string, SubscriptionEntry>()

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()
}
}
Loading