From f1c9992219b451823d25d7d427e0c0e754ea7b69 Mon Sep 17 00:00:00 2001 From: Abhay Date: Thu, 15 Jan 2026 18:35:48 +0530 Subject: [PATCH 1/4] Fetch Nostr Events via Central Singleton --- app/app.tsx | 122 +++++----- app/nostr/hooks/useNostrEvents.ts | 11 + app/nostr/runtime/NostrRuntime.ts | 63 ++++++ app/nostr/runtime/RelayManager.ts | 51 +++++ app/nostr/runtime/SubscriptionRegistry.ts | 62 +++++ app/nostr/store/EventStore.ts | 91 ++++++++ app/screens/chat/NIP17Chat.tsx | 262 ++++++++++------------ app/screens/chat/chatContext.tsx | 3 +- 8 files changed, 470 insertions(+), 195 deletions(-) create mode 100644 app/nostr/hooks/useNostrEvents.ts create mode 100644 app/nostr/runtime/NostrRuntime.ts create mode 100644 app/nostr/runtime/RelayManager.ts create mode 100644 app/nostr/runtime/SubscriptionRegistry.ts create mode 100644 app/nostr/store/EventStore.ts 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..a6f0477c3 --- /dev/null +++ b/app/nostr/hooks/useNostrEvents.ts @@ -0,0 +1,11 @@ +import { useSyncExternalStore } from "react" +import { nostrRuntime } from "../runtime/NostrRuntime" + +export function useNostrEvents() { + const store = nostrRuntime.getEventStore() + + return useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getAllCanonical(), + ) +} diff --git a/app/nostr/runtime/NostrRuntime.ts b/app/nostr/runtime/NostrRuntime.ts new file mode 100644 index 000000000..98d4046b7 --- /dev/null +++ b/app/nostr/runtime/NostrRuntime.ts @@ -0,0 +1,63 @@ +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) { + return this.subscriptions.ensure(key, filters, (event: Event) => { + if (this.events.add(event)) { + onEvent?.(event) + } + }) + } + + 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..fdec9f754 --- /dev/null +++ b/app/nostr/runtime/RelayManager.ts @@ -0,0 +1,51 @@ +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) { + return this.pool.subscribeMany(this.getReadRelays(), 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..dd8ef66ad --- /dev/null +++ b/app/nostr/runtime/SubscriptionRegistry.ts @@ -0,0 +1,62 @@ +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) { + const existing = this.subs.get(key) + + if (existing) { + existing.refCount++ + return + } + + const closer = this.relayManager.subscribe(filters, { + onevent: onEvent, + onclose: () => { + this.subs.delete(key) + }, + }) + + 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..97bbfc523 --- /dev/null +++ b/app/nostr/store/EventStore.ts @@ -0,0 +1,91 @@ +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() + + // 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/NIP17Chat.tsx b/app/screens/chat/NIP17Chat.tsx index b21f90e27..3d1968789 100644 --- a/app/screens/chat/NIP17Chat.tsx +++ b/app/screens/chat/NIP17Chat.tsx @@ -1,11 +1,10 @@ import { useTheme } from "@rneui/themed" import * as React from "react" -import { useCallback, useState } from "react" +import { useMemo, useState, useEffect } from "react" import { ActivityIndicator, Text, View, - Alert, TouchableOpacity, Image, Platform, @@ -15,16 +14,20 @@ import { FlatList } from "react-native-gesture-handler" import Icon from "react-native-vector-icons/Ionicons" import { Screen } from "../../components/screen" -import { bytesToHex, hexToBytes } from "@noble/hashes/utils" +import { bytesToHex } from "@noble/hashes/utils" import { testProps } from "../../utils/testProps" import { useI18nContext } from "@app/i18n/i18n-react" -import { getPublicKey, nip19 } from "nostr-tools" -import { convertRumorsToGroups, fetchSecretFromLocalStorage } from "@app/utils/nostr" +import { getPublicKey, nip19, Event } from "nostr-tools" +import { + convertRumorsToGroups, + fetchSecretFromLocalStorage, + getRumorFromWrap, + Rumor, +} from "@app/utils/nostr" import { useStyles } from "./style" import { HistoryListItem } from "./historyListItem" -import { useChatContext } from "./chatContext" -import { useFocusEffect, useNavigation } from "@react-navigation/native" +import { useNavigation, useFocusEffect } from "@react-navigation/native" import { useAppSelector } from "@app/store/redux" import { ImportNsecModal } from "../../components/import-nsec/import-nsec-modal" import { useIsAuthed } from "@app/graphql/is-authed-context" @@ -37,6 +40,7 @@ import { SearchListItem } from "./searchListItem" import Contacts from "./contacts" import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs" import ContactDetailsScreen from "./contactDetailsScreen" +import { nostrRuntime } from "@app/nostr/runtime/NostrRuntime" const Tab = createMaterialTopTabNavigator() @@ -46,122 +50,111 @@ export const NIP17Chat: React.FC = () => { theme: { colors }, } = useTheme() const isAuthed = useIsAuthed() - const { data: dataAuthed } = useHomeAuthedQuery({ skip: !isAuthed, fetchPolicy: "network-only", errorPolicy: "all", }) - const { rumors, poolRef, profileMap, resetChat, activeSubscription, initializeChat } = - useChatContext() - const [initialized, setInitialized] = useState(false) - const [searchedUsers, setSearchedUsers] = useState([]) + const [privateKey, setPrivateKey] = useState() - const [showImportModal, setShowImportModal] = useState(false) - const [skipMismatchCheck, setskipMismatchCheck] = useState(false) + const [showImportModal, setShowImportModal] = useState(false) + const [skipMismatchCheck, setSkipMismatchCheck] = useState(false) + const [searchedUsers, setSearchedUsers] = useState([]) const { LL } = useI18nContext() const { userData } = useAppSelector((state) => state.user) const navigation = useNavigation>() const RootNavigator = useNavigation>() - const { groupMetadata } = useNostrGroupChat() - React.useEffect(() => { - const unsubscribe = () => { - console.log("unsubscribing") - setInitialized(false) - } - async function initialize() { - console.log("Initializing nip17 screen use effect") - let secretKeyString = await fetchSecretFromLocalStorage() + // ------------------------------- + // Initialize private key and check mismatch + // ------------------------------- + useEffect(() => { + let mounted = true + + async function initKey() { + const secretKeyString = await fetchSecretFromLocalStorage() + if (!mounted) return if (!secretKeyString) { - console.log("Couldn't find secret key in local storage") setShowImportModal(true) return } - let secret = nip19.decode(secretKeyString).data as Uint8Array + + const secret = nip19.decode(secretKeyString).data as Uint8Array setPrivateKey(secret) + const accountNpub = dataAuthed?.me?.npub const storedNpub = nip19.npubEncode(getPublicKey(secret)) if (!skipMismatchCheck && accountNpub && storedNpub !== accountNpub) { - console.log("Account Info mismatch", accountNpub, storedNpub) setShowImportModal(true) } - if (!activeSubscription) initializeChat() - setInitialized(true) + + // Ensure giftwrap subscription + const userPubkey = getPublicKey(secret) + const giftwrapFilter = [{ "kinds": [1059], "#p": [userPubkey], "limit": 150 }] + nostrRuntime.ensureSubscription("giftwraps", giftwrapFilter) } - if (!initialized && poolRef) initialize() - return unsubscribe - }, [poolRef, isAuthed]) - useFocusEffect( - React.useCallback(() => { - let isMounted = true - async function checkSecretKey() { - if (!isMounted) return - let secretKeyString = await fetchSecretFromLocalStorage() - if (!secretKeyString) { - setShowImportModal(true) - return - } - let secret = nip19.decode(secretKeyString).data as Uint8Array - const accountNpub = dataAuthed?.me?.npub - const storedNpub = nip19.npubEncode(getPublicKey(secret)) - if (!skipMismatchCheck && accountNpub && storedNpub !== accountNpub) { - setShowImportModal(true) - } - } - if (initialized) { - setSearchedUsers([]) - checkSecretKey() - } - return () => { - isMounted = false - } - }, [setSearchedUsers, dataAuthed, isAuthed, skipMismatchCheck]), - ) - let SearchBarContent: React.ReactNode - let ListEmptyContent: React.ReactNode + initKey() + return () => { + mounted = false + nostrRuntime.releaseSubscription("giftwraps") + } + }, [dataAuthed, skipMismatchCheck]) - SearchBarContent = ( - <> - - - ) + // ------------------------------- + // Get decrypted rumors + // ------------------------------- + const decryptedRumors = useMemo(() => { + if (!privateKey) return [] - if (!initialized) { - ListEmptyContent = ( - - - - ) - } else { - ListEmptyContent = ( - - - {LL.ChatScreen.noChatsTitle()} - - {LL.ChatScreen.noChatsYet()} - - ) - } + const allEvents: Event[] = nostrRuntime.getAllEvents() + const giftwraps = allEvents.filter((e) => e.kind === 1059) - let groups = convertRumorsToGroups(rumors || []) - let groupIds = Array.from(groups.keys()).sort((a, b) => { - let groupARumors = groups.get(a) || [] - let groupBRumors = groups.get(b) || [] - let lastARumor = groupARumors[groupARumors.length ? groupARumors.length - 1 : 0] - let lastBRumor = groupBRumors[groupBRumors.length ? groupBRumors.length - 1 : 0] - return (lastBRumor?.created_at || 0) - (lastARumor?.created_at || 0) - }) + const rumors: Rumor[] = [] + for (const wrap of giftwraps) { + try { + const rumor = getRumorFromWrap(wrap, privateKey) + rumors.push(rumor) + } catch (e) { + console.warn("Failed to decrypt giftwrap", e) + } + } + + return rumors + }, [privateKey, nostrRuntime.getAllEvents()]) + + const groups = useMemo(() => convertRumorsToGroups(decryptedRumors), [decryptedRumors]) + const groupIds = useMemo(() => { + return Array.from(groups.keys()).sort((a, b) => { + const lastA = groups.get(a)?.at(-1) + const lastB = groups.get(b)?.at(-1) + return (lastB?.created_at || 0) - (lastA?.created_at || 0) + }) + }, [groups]) const userPublicKey = privateKey ? getPublicKey(privateKey) : null - const userProfile = userPublicKey ? profileMap?.get(userPublicKey) : null - // Android status bar height + // ------------------------------- + // Status bar height for Android + // ------------------------------- const statusBarHeight = Platform.OS === "android" ? StatusBar.currentHeight || 0 : 0 + // ------------------------------- + // List empty / search content + // ------------------------------- + const ListEmptyContent = ( + + + {LL.ChatScreen.noChatsTitle()} + + {LL.ChatScreen.noChatsYet()} + + ) + + const SearchBarContent = + return ( @@ -169,23 +162,14 @@ export const NIP17Chat: React.FC = () => { { - const label = "Profile" return { - // tabBarLabelStyle: { fontSize: 18, fontWeight: "600" }, - // tabBarIndicatorStyle: { backgroundColor: "#60aa55" }, - // tabBarIndicatorStyle: { backgroundColor: "#60aa55" }, tabBarIcon: ({ color }) => { let iconName: string - if (route.name === "Profile") { - iconName = "person" - } - if (route.name === "Chats") { - iconName = "chatbubble-ellipses-outline" // Chat icon - } else if (route.name === "Contacts") { - iconName = "people-outline" // Contacts icon - } else { - iconName = "person-circle-outline" - } + if (route.name === "Profile") iconName = "person" + else if (route.name === "Chats") + iconName = "chatbubble-ellipses-outline" + else if (route.name === "Contacts") iconName = "people-outline" + else iconName = "person-circle-outline" return }, tabBarShowLabel: false, @@ -193,13 +177,13 @@ export const NIP17Chat: React.FC = () => { tabBarIndicatorStyle: { backgroundColor: colors.primary }, } }} - style={{ borderColor: colors.primary }} > + {/* ------------------- Chats Tab ------------------- */} {() => ( {SearchBarContent} - {searchedUsers.length !== 0 ? ( + {searchedUsers.length > 0 ? ( { keyExtractor={(item) => item.id} /> ) : ( - + {/* Signed in as */} - {}}> + signed in as:{" "} {userData?.username || @@ -221,30 +205,18 @@ export const NIP17Chat: React.FC = () => { + + {/* Support Group */} RootNavigator.navigate("Nip29GroupChat", { groupId: "support-group-id", }) } - style={{ marginRight: 20, marginLeft: 20, marginBottom: 4 }} + style={{ marginHorizontal: 20, marginBottom: 4 }} > - - + + { style={{ ...styles.itemText, fontWeight: "bold", - marginBottom: 4, - marginTop: 4, + marginVertical: 4, }} > {groupMetadata.name || "Support Group Chat"} @@ -280,13 +251,9 @@ export const NIP17Chat: React.FC = () => { /> {groupMetadata.about || "..."} @@ -294,6 +261,8 @@ export const NIP17Chat: React.FC = () => { + + {/* Chat Groups */} { )} + + {/* ------------------- Profile Tab ------------------- */} + + {/* ------------------- Contacts Tab ------------------- */} {() => ( - + )} ) : ( - Loading your nostr keys... + + + Loading your nostr keys... + )} + + {/* ------------------- Import Modal ------------------- */} { - setShowImportModal(false) - }} + onCancel={() => setShowImportModal(false)} onSubmit={() => { - resetChat() + setShowImportModal(false) }} /> diff --git a/app/screens/chat/chatContext.tsx b/app/screens/chat/chatContext.tsx index 04fa60a28..8a608a54a 100644 --- a/app/screens/chat/chatContext.tsx +++ b/app/screens/chat/chatContext.tsx @@ -10,6 +10,7 @@ import { loadGiftwrapsFromStorage, saveGiftwrapsToStorage, } from "@app/utils/nostr" +import { pool } from "@app/utils/nostr/pool" import { Event, SimplePool, SubCloser, getPublicKey, nip19 } from "nostr-tools" import React from "react" import { PropsWithChildren, createContext, useContext, useRef, useState } from "react" @@ -70,7 +71,7 @@ export const ChatContextProvider: React.FC = ({ children }) = const [userProfileEvent, setUserProfileEvent] = useState(null) const [userPublicKey, setUserPublicKey] = useState(null) const profileMap = useRef>(new Map()) - const poolRef = useRef(new SimplePool()) + const poolRef = useRef(pool) const processedEventIds = useRef(new Set()) const [contactsEvent, setContactsEvent] = useState() const { From be69122f5847813d468770b49eca73a432ab0671 Mon Sep 17 00:00:00 2001 From: Abhay Date: Tue, 20 Jan 2026 19:08:56 +0530 Subject: [PATCH 2/4] Use NostrRuntime throughout app --- app/nostr/hooks/useNostrEvents.ts | 9 +- app/nostr/runtime/NostrRuntime.ts | 25 +- app/nostr/runtime/RelayManager.ts | 6 +- app/nostr/runtime/SubscriptionRegistry.ts | 25 +- app/nostr/store/EventStore.ts | 3 + .../chat/GroupChat/GroupChatProvider.tsx | 104 +-- app/screens/chat/NIP17Chat.tsx | 194 +++-- app/screens/chat/chatContext.tsx | 329 +++----- app/screens/chat/chatMessage.tsx | 68 +- app/screens/chat/contactCard.tsx | 5 +- app/screens/chat/contactDetailsScreen.tsx | 774 ++++-------------- app/screens/chat/contacts.tsx | 67 +- app/screens/chat/historyListItem.tsx | 143 ++-- app/screens/chat/messages.tsx | 283 +++---- app/screens/chat/searchListItem.tsx | 7 +- 15 files changed, 759 insertions(+), 1283 deletions(-) diff --git a/app/nostr/hooks/useNostrEvents.ts b/app/nostr/hooks/useNostrEvents.ts index a6f0477c3..de435251f 100644 --- a/app/nostr/hooks/useNostrEvents.ts +++ b/app/nostr/hooks/useNostrEvents.ts @@ -1,11 +1,16 @@ import { useSyncExternalStore } from "react" import { nostrRuntime } from "../runtime/NostrRuntime" -export function useNostrEvents() { +/** + * 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), - () => store.getAllCanonical(), + () => (canonicalKey ? store.getAllVersions(canonicalKey) : store.getAllCanonical()), ) } diff --git a/app/nostr/runtime/NostrRuntime.ts b/app/nostr/runtime/NostrRuntime.ts index 98d4046b7..1ef7284ae 100644 --- a/app/nostr/runtime/NostrRuntime.ts +++ b/app/nostr/runtime/NostrRuntime.ts @@ -32,12 +32,25 @@ export class NostrRuntime { return this.events } - ensureSubscription(key: string, filters: Filter[], onEvent?: (event: Event) => void) { - return this.subscriptions.ensure(key, filters, (event: Event) => { - if (this.events.add(event)) { - onEvent?.(event) - } - }) + ensureSubscription( + key: string, + filters: Filter[], + onEvent?: (event: Event) => void, + onEose?: () => void, + relays?: string[], + ) { + console.log("Got relays ensureSubscription", relays, filters) + return this.subscriptions.ensure( + key, + filters, + (event: Event) => { + if (this.events.add(event)) { + onEvent?.(event) + } + }, + onEose, + relays, + ) } releaseSubscription(key: string) { diff --git a/app/nostr/runtime/RelayManager.ts b/app/nostr/runtime/RelayManager.ts index fdec9f754..647707540 100644 --- a/app/nostr/runtime/RelayManager.ts +++ b/app/nostr/runtime/RelayManager.ts @@ -41,8 +41,10 @@ export class RelayManager { this.pool.close(DEFAULT_RELAYS) } - subscribe(filters: any[], handlers: any) { - return this.pool.subscribeMany(this.getReadRelays(), filters, handlers) + 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) { diff --git a/app/nostr/runtime/SubscriptionRegistry.ts b/app/nostr/runtime/SubscriptionRegistry.ts index dd8ef66ad..5abb74f38 100644 --- a/app/nostr/runtime/SubscriptionRegistry.ts +++ b/app/nostr/runtime/SubscriptionRegistry.ts @@ -12,7 +12,13 @@ export class SubscriptionRegistry { constructor(private relayManager: RelayManager) {} - ensure(key: string, filters: Filter[], onEvent: (event: Event) => void) { + ensure( + key: string, + filters: Filter[], + onEvent: (event: Event) => void, + onEose?: () => void, + relays?: string[], + ) { const existing = this.subs.get(key) if (existing) { @@ -20,12 +26,19 @@ export class SubscriptionRegistry { return } - const closer = this.relayManager.subscribe(filters, { - onevent: onEvent, - onclose: () => { - this.subs.delete(key) + const closer = this.relayManager.subscribe( + filters, + { + onevent: onEvent, + onclose: () => { + this.subs.delete(key) + }, + oneose: () => { + onEose?.() + }, }, - }) + relays, + ) this.subs.set(key, { filters, diff --git a/app/nostr/store/EventStore.ts b/app/nostr/store/EventStore.ts index 97bbfc523..e5fd14895 100644 --- a/app/nostr/store/EventStore.ts +++ b/app/nostr/store/EventStore.ts @@ -6,6 +6,9 @@ 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 { 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>(new Map()) @@ -77,38 +79,33 @@ export const NostrGroupChatProvider: React.FC = ({ // ----- Sub: group messages (kind 9) ----- useEffect(() => { - if (!poolRef?.current) return - const unsub = poolRef.current.subscribeMany( - relayUrls, + nostrRuntime.ensureSubscription( + `nip29:messages`, [ { "#h": [groupId], "kinds": [9], }, ], - { - onevent: (event: Event) => { - const msg: MessageType.Text = { - id: event.id, - author: { id: event.pubkey }, - createdAt: event.created_at * 1000, - type: "text", - text: event.content, - } - setMessagesMap((prev) => { - if (prev.has(msg.id)) return prev - const next = new Map(prev) - next.set(msg.id, msg) - return next - }) - }, + (event: Event) => { + const msg: MessageType.Text = { + id: event.id, + author: { id: event.pubkey }, + createdAt: event.created_at * 1000, + type: "text", + text: event.content, + } + setMessagesMap((prev) => { + if (prev.has(msg.id)) return prev + const next = new Map(prev) + next.set(msg.id, msg) + return next + }) }, + () => {}, + relayUrls, ) - - return () => { - if (unsub) unsub.close() - } - }, [poolRef?.current, relayUrls.join("|"), groupId]) + }, [relayUrls.join("|"), groupId]) //metadata useEffect(() => { @@ -122,34 +119,33 @@ export const NostrGroupChatProvider: React.FC = ({ } return result } - const unsub = poolRef?.current.subscribeMany( - relayUrls, + const unsub = nostrRuntime.ensureSubscription( + `nip29:group_metadata`, [{ "kinds": [39000], "#d": [groupId] }], - { - onevent: (event) => { - console.log("==============GOT METADATA EVENT=============") - const parsed = parseGroupTags(event.tags) - setMetadata(parsed) - }, - oneose: () => { - console.log("EOSE TRIGGERED FOR ", 39000) - }, + (event) => { + console.log("==============GOT METADATA EVENT=============") + const parsed = parseGroupTags(event.tags) + setMetadata(parsed) }, + () => { + console.log("EOSE TRIGGERED FOR ", 39000) + }, + relayUrls, ) - return () => unsub?.close() - }, [poolRef?.current, groupId]) + }, [groupId]) // ----- Sub: membership roster (kind 39002) ----- useEffect(() => { - if (!poolRef?.current) return const filters: any = { "kinds": [39002], "#d": [groupId], } if (adminPubkeys?.length) filters.authors = adminPubkeys - const unsub = poolRef.current.subscribeMany(relayUrls, [filters], { - onevent: (event: any) => { + const unsub = nostrRuntime.ensureSubscription( + `nip29:membership`, + [filters], + (event: any) => { // Extract all `p` tags as pubkeys console.log("==============GOT MEMBERSHIP EVENT=============") const currentMembers: string[] = event.tags @@ -202,23 +198,14 @@ export const NostrGroupChatProvider: React.FC = ({ // Update knownMembers state setKnownMembers(currentSet) }, - }) - - return () => { - if (unsub) unsub.close() - } - }, [ - poolRef?.current, - relayUrls.join("|"), - groupId, - userPublicKey, - adminPubkeys?.join("|"), - ]) + () => {}, + relayUrls, + ) + }, [relayUrls.join("|"), groupId, userPublicKey, adminPubkeys?.join("|")]) // ----- Actions ----- const sendMessage = useCallback( async (text: string) => { - if (!poolRef?.current) throw Error("No PoolRef present") if (!userPublicKey) throw Error("No user pubkey present") const secretKey = await getSecretKey() @@ -233,13 +220,12 @@ export const NostrGroupChatProvider: React.FC = ({ } const signedEvent = finalizeEvent(nostrEvent as any, secretKey) - poolRef.current.publish(relayUrls, signedEvent) + pool.publish(relayUrls, signedEvent) }, - [poolRef?.current, userPublicKey, groupId, relayUrls], + [userPublicKey, groupId, relayUrls], ) const requestJoin = useCallback(async () => { - if (!poolRef?.current) throw Error("No PoolRef present") if (!userPublicKey) throw Error("No user pubkey present") const secretKey = await getSecretKey() @@ -254,7 +240,7 @@ export const NostrGroupChatProvider: React.FC = ({ } const signedJoinEvent = finalizeEvent(joinEvent as any, secretKey) - poolRef.current.publish(relayUrls, signedJoinEvent) + pool.publish(relayUrls, signedJoinEvent) // Optimistic system note setMessagesMap((prev) => { @@ -262,7 +248,7 @@ export const NostrGroupChatProvider: React.FC = ({ next.set(`sys-join-req-${Date.now()}`, makeSystemText("Join request sent")) return next }) - }, [poolRef?.current, userPublicKey, groupId, relayUrls]) + }, [userPublicKey, groupId, relayUrls]) const value = useMemo( () => ({ diff --git a/app/screens/chat/NIP17Chat.tsx b/app/screens/chat/NIP17Chat.tsx index 3d1968789..71828cb98 100644 --- a/app/screens/chat/NIP17Chat.tsx +++ b/app/screens/chat/NIP17Chat.tsx @@ -1,6 +1,6 @@ import { useTheme } from "@rneui/themed" import * as React from "react" -import { useMemo, useState, useEffect } from "react" +import { useState } from "react" import { ActivityIndicator, Text, @@ -18,16 +18,12 @@ import { bytesToHex } from "@noble/hashes/utils" import { testProps } from "../../utils/testProps" import { useI18nContext } from "@app/i18n/i18n-react" -import { getPublicKey, nip19, Event } from "nostr-tools" -import { - convertRumorsToGroups, - fetchSecretFromLocalStorage, - getRumorFromWrap, - Rumor, -} from "@app/utils/nostr" +import { getPublicKey, nip19 } from "nostr-tools" +import { convertRumorsToGroups, fetchSecretFromLocalStorage } from "@app/utils/nostr" import { useStyles } from "./style" import { HistoryListItem } from "./historyListItem" -import { useNavigation, useFocusEffect } from "@react-navigation/native" +import { useChatContext } from "./chatContext" +import { useFocusEffect, useNavigation } from "@react-navigation/native" import { useAppSelector } from "@app/store/redux" import { ImportNsecModal } from "../../components/import-nsec/import-nsec-modal" import { useIsAuthed } from "@app/graphql/is-authed-context" @@ -40,7 +36,6 @@ import { SearchListItem } from "./searchListItem" import Contacts from "./contacts" import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs" import ContactDetailsScreen from "./contactDetailsScreen" -import { nostrRuntime } from "@app/nostr/runtime/NostrRuntime" const Tab = createMaterialTopTabNavigator() @@ -56,10 +51,22 @@ export const NIP17Chat: React.FC = () => { errorPolicy: "all", }) - const [privateKey, setPrivateKey] = useState() - const [showImportModal, setShowImportModal] = useState(false) - const [skipMismatchCheck, setSkipMismatchCheck] = useState(false) + const { + rumors, + profileMap, + resetChat, + initializeChat, + userProfileEvent, + userPublicKey, + contactsEvent, + setContactsEvent, + } = useChatContext() + + const [initialized, setInitialized] = useState(false) const [searchedUsers, setSearchedUsers] = useState([]) + const [privateKey, setPrivateKey] = useState() + const [showImportModal, setShowImportModal] = useState(false) + const [skipMismatchCheck, setskipMismatchCheck] = useState(false) const { LL } = useI18nContext() const { userData } = useAppSelector((state) => state.user) const navigation = useNavigation>() @@ -67,16 +74,15 @@ export const NIP17Chat: React.FC = () => { useNavigation>() const { groupMetadata } = useNostrGroupChat() - // ------------------------------- - // Initialize private key and check mismatch - // ------------------------------- - useEffect(() => { - let mounted = true - - async function initKey() { + // ------------------------ + // Initialize on mount + // ------------------------ + React.useEffect(() => { + async function initialize() { + console.log("Initializing nip17 screen use effect") const secretKeyString = await fetchSecretFromLocalStorage() - if (!mounted) return if (!secretKeyString) { + console.log("Couldn't find secret key in local storage") setShowImportModal(true) return } @@ -87,64 +93,63 @@ export const NIP17Chat: React.FC = () => { const accountNpub = dataAuthed?.me?.npub const storedNpub = nip19.npubEncode(getPublicKey(secret)) if (!skipMismatchCheck && accountNpub && storedNpub !== accountNpub) { + console.log("Account Info mismatch", accountNpub, storedNpub) setShowImportModal(true) } - // Ensure giftwrap subscription - const userPubkey = getPublicKey(secret) - const giftwrapFilter = [{ "kinds": [1059], "#p": [userPubkey], "limit": 150 }] - nostrRuntime.ensureSubscription("giftwraps", giftwrapFilter) - } - - initKey() - return () => { - mounted = false - nostrRuntime.releaseSubscription("giftwraps") + if (!initialized) { + await initializeChat() // runtime handles all subscriptions + setInitialized(true) + } } - }, [dataAuthed, skipMismatchCheck]) - // ------------------------------- - // Get decrypted rumors - // ------------------------------- - const decryptedRumors = useMemo(() => { - if (!privateKey) return [] + initialize() + }, [dataAuthed, initialized, skipMismatchCheck]) - const allEvents: Event[] = nostrRuntime.getAllEvents() - const giftwraps = allEvents.filter((e) => e.kind === 1059) - - const rumors: Rumor[] = [] - for (const wrap of giftwraps) { - try { - const rumor = getRumorFromWrap(wrap, privateKey) - rumors.push(rumor) - } catch (e) { - console.warn("Failed to decrypt giftwrap", e) + // ------------------------ + // Focus effect for secret key / import modal check + // ------------------------ + useFocusEffect( + React.useCallback(() => { + let isMounted = true + async function checkSecretKey() { + if (!isMounted) return + const secretKeyString = await fetchSecretFromLocalStorage() + if (!secretKeyString) { + setShowImportModal(true) + return + } + const secret = nip19.decode(secretKeyString).data as Uint8Array + const accountNpub = dataAuthed?.me?.npub + const storedNpub = nip19.npubEncode(getPublicKey(secret)) + if (!skipMismatchCheck && accountNpub && storedNpub !== accountNpub) { + setShowImportModal(true) + } } - } - return rumors - }, [privateKey, nostrRuntime.getAllEvents()]) - - const groups = useMemo(() => convertRumorsToGroups(decryptedRumors), [decryptedRumors]) - const groupIds = useMemo(() => { - return Array.from(groups.keys()).sort((a, b) => { - const lastA = groups.get(a)?.at(-1) - const lastB = groups.get(b)?.at(-1) - return (lastB?.created_at || 0) - (lastA?.created_at || 0) - }) - }, [groups]) + if (initialized) { + setSearchedUsers([]) + checkSecretKey() + } - const userPublicKey = privateKey ? getPublicKey(privateKey) : null + return () => { + isMounted = false + } + }, [setSearchedUsers, dataAuthed, isAuthed, skipMismatchCheck, initialized]), + ) - // ------------------------------- - // Status bar height for Android - // ------------------------------- + // ------------------------ + // UI helpers + // ------------------------ const statusBarHeight = Platform.OS === "android" ? StatusBar.currentHeight || 0 : 0 - // ------------------------------- - // List empty / search content - // ------------------------------- - const ListEmptyContent = ( + const SearchBarContent = + + const ListEmptyContent = !initialized ? ( + + + + ) : ( {LL.ChatScreen.noChatsTitle()} @@ -153,8 +158,22 @@ export const NIP17Chat: React.FC = () => { ) - const SearchBarContent = + // ------------------------ + // Groups derived from rumors + // ------------------------ + const groups = convertRumorsToGroups(rumors || []) + const groupIds = Array.from(groups.keys()).sort((a, b) => { + const lastARumor = groups.get(a)?.at(-1) + const lastBRumor = groups.get(b)?.at(-1) + return (lastBRumor?.created_at || 0) - (lastARumor?.created_at || 0) + }) + const currentUserPubKey = privateKey ? getPublicKey(privateKey) : null + const userProfile = currentUserPubKey ? profileMap?.get(currentUserPubKey) : null + + // ------------------------ + // Render + // ------------------------ return ( @@ -177,13 +196,13 @@ export const NIP17Chat: React.FC = () => { tabBarIndicatorStyle: { backgroundColor: colors.primary }, } }} + style={{ borderColor: colors.primary }} > - {/* ------------------- Chats Tab ------------------- */} {() => ( {SearchBarContent} - {searchedUsers.length > 0 ? ( + {searchedUsers.length !== 0 ? ( { keyExtractor={(item) => item.id} /> ) : ( - + {/* Signed in as */} @@ -206,14 +225,13 @@ export const NIP17Chat: React.FC = () => { - {/* Support Group */} RootNavigator.navigate("Nip29GroupChat", { groupId: "support-group-id", }) } - style={{ marginHorizontal: 20, marginBottom: 4 }} + style={{ marginRight: 20, marginLeft: 20, marginBottom: 4 }} > @@ -239,7 +257,8 @@ export const NIP17Chat: React.FC = () => { style={{ ...styles.itemText, fontWeight: "bold", - marginVertical: 4, + marginBottom: 4, + marginTop: 4, }} > {groupMetadata.name || "Support Group Chat"} @@ -251,7 +270,11 @@ export const NIP17Chat: React.FC = () => { /> @@ -262,7 +285,6 @@ export const NIP17Chat: React.FC = () => { - {/* Chat Groups */} { )} - {/* ------------------- Profile Tab ------------------- */} - {/* ------------------- Contacts Tab ------------------- */} {() => ( - + )} ) : ( - - - Loading your nostr keys... - + Loading your nostr keys... )} - {/* ------------------- Import Modal ------------------- */} setShowImportModal(false)} - onSubmit={() => { - setShowImportModal(false) - }} + onSubmit={() => resetChat()} /> ) diff --git a/app/screens/chat/chatContext.tsx b/app/screens/chat/chatContext.tsx index 8a608a54a..3195a7f8a 100644 --- a/app/screens/chat/chatContext.tsx +++ b/app/screens/chat/chatContext.tsx @@ -1,31 +1,26 @@ +import React, { createContext, useContext, useState, useRef } from "react" +import { PropsWithChildren } from "react" +import { Event, nip19, getPublicKey } from "nostr-tools" import { useAppConfig } from "@app/hooks" + import { Rumor, - fetchContactList, - fetchGiftWrapsForPublicKey, - fetchNostrUsers, - fetchSecretFromLocalStorage, getRumorFromWrap, - getSecretKey, loadGiftwrapsFromStorage, saveGiftwrapsToStorage, + fetchSecretFromLocalStorage, } from "@app/utils/nostr" -import { pool } from "@app/utils/nostr/pool" -import { Event, SimplePool, SubCloser, getPublicKey, nip19 } from "nostr-tools" -import React from "react" -import { PropsWithChildren, createContext, useContext, useRef, useState } from "react" +import { nostrRuntime } from "@app/nostr/runtime/NostrRuntime" type ChatContextType = { giftwraps: Event[] rumors: Rumor[] setGiftWraps: React.Dispatch> setRumors: React.Dispatch> - poolRef?: React.MutableRefObject - profileMap: Map | undefined + profileMap: Map addEventToProfiles: (event: Event) => void resetChat: () => Promise initializeChat: (count?: number) => void - activeSubscription: SubCloser | null userProfileEvent: Event | null userPublicKey: string | null refreshUserProfile: () => Promise @@ -34,30 +29,20 @@ type ChatContextType = { getContactPubkeys: () => string[] | null } -const publicRelays = [ - "wss://relay.damus.io", - "wss://relay.primal.net", - "wss://relay.staging.flashapp.me", - "wss://relay.snort.social", - "wss//nos.lol", -] - const ChatContext = createContext({ giftwraps: [], setGiftWraps: () => {}, rumors: [], setRumors: () => {}, - poolRef: undefined, - profileMap: undefined, - addEventToProfiles: (event: Event) => {}, - resetChat: () => new Promise(() => {}), + profileMap: new Map(), + addEventToProfiles: () => {}, + resetChat: async () => {}, initializeChat: () => {}, - activeSubscription: null, userProfileEvent: null, userPublicKey: null, refreshUserProfile: async () => {}, contactsEvent: undefined, - setContactsEvent: (event: Event) => {}, + setContactsEvent: () => {}, getContactPubkeys: () => null, }) @@ -66,228 +51,180 @@ export const useChatContext = () => useContext(ChatContext) export const ChatContextProvider: React.FC = ({ children }) => { const [giftwraps, setGiftWraps] = useState([]) const [rumors, setRumors] = useState([]) - const [_, setLastEvent] = useState() - const [closer, setCloser] = useState(null) const [userProfileEvent, setUserProfileEvent] = useState(null) const [userPublicKey, setUserPublicKey] = useState(null) - const profileMap = useRef>(new Map()) - const poolRef = useRef(pool) - const processedEventIds = useRef(new Set()) const [contactsEvent, setContactsEvent] = useState() + const profileMap = useRef>(new Map()) + const processedEventIds = useRef>(new Set()) + const { appConfig: { galoyInstance: { relayUrl }, }, } = useAppConfig() - const handleGiftWraps = (event: Event, secret: Uint8Array) => { - setGiftWraps((prevEvents) => [...(prevEvents || []), event]) + // ------------------------ + // Helper: handle new giftwrap event + // ------------------------ + const handleGiftWrapEvent = async (event: Event, secret: Uint8Array) => { + if (processedEventIds.current.has(event.id)) return + processedEventIds.current.add(event.id) + + setGiftWraps((prev) => { + const updated = [...prev, event].sort((a, b) => a.created_at - b.created_at) + saveGiftwrapsToStorage(updated) + return updated + }) + try { - let rumor = getRumorFromWrap(event, secret) - setRumors((prevRumors) => { - let previousRumors = prevRumors || [] - if (!previousRumors.map((r) => r.id).includes(rumor)) { - return [...(prevRumors || []), rumor] + const rumor = getRumorFromWrap(event, secret) + setRumors((prev) => { + if (!prev.map((r) => r.id).includes(rumor.id)) { + return [...prev, rumor] } - return prevRumors + return prev }) } catch (e) { - console.log("Error in decrypting...", e) - } - } - - React.useEffect(() => { - let closer: SubCloser | undefined - if (poolRef && !closer) initializeChat() - async function initialize(count = 0) { - let secretKeyString = await fetchSecretFromLocalStorage() - if (!secretKeyString) { - if (count >= 3) return - setTimeout(() => initialize(count + 1), 500) - return - } - let secret = nip19.decode(secretKeyString).data as Uint8Array - const publicKey = getPublicKey(secret) - const cachedGiftwraps = await loadGiftwrapsFromStorage() - setGiftWraps(cachedGiftwraps) - let cachedRumors - cachedRumors = cachedGiftwraps - .map((wrap) => { - try { - return getRumorFromWrap(wrap, secret) - } catch (e) { - return null - } - }) - .filter((r) => r !== null) - setRumors(cachedRumors || []) - let closer = await fetchNewGiftwraps(cachedGiftwraps, publicKey) - fetchContactList(getPublicKey(secret), (event: Event) => { - setContactsEvent(event) - }) - setCloser(closer) - } - if (poolRef && !closer) initialize() - }, [poolRef]) - - React.useEffect(() => { - const initializeUserProfile = async () => { - if (!poolRef.current || !userPublicKey) return - - await refreshUserProfile() - } - - if (userPublicKey) { - initializeUserProfile() + console.log("Failed to decrypt giftwrap", e) } - }, [userPublicKey]) - - const refreshUserProfile = async () => { - if (!poolRef.current) return - let publicKey = userPublicKey - if (!publicKey) { - let secret = await getSecretKey() - if (!secret) { - setUserProfileEvent(null) - return - } - publicKey = getPublicKey(secret) - setUserPublicKey(publicKey) - } - fetchContactList(publicKey, (event: Event) => { - setContactsEvent(event) - }) - return new Promise((resolve) => { - fetchNostrUsers([publicKey], poolRef.current, (event: Event, profileCloser) => { - setUserProfileEvent(event) - try { - let content = JSON.parse(event.content) - profileMap.current.set(event.pubkey, content) - } catch (e) { - console.log("Couldn't parse the profile", e) - } - profileCloser.close() - resolve() - }) - }) - } - - const getContactPubkeys = () => { - if (!contactsEvent) return null - return contactsEvent.tags - .filter((t: string[]) => { - if (t[0] === "p") return true - return false - }) - .map((t: string[]) => t[1]) } + // ------------------------ + // Initialize chat (fetch secret, set pubkey) + // ------------------------ const initializeChat = async (count = 0) => { - if (closer) closer.close() - let secretKeyString = await fetchSecretFromLocalStorage() + const secretKeyString = await fetchSecretFromLocalStorage() if (!secretKeyString) { if (count >= 3) return setTimeout(() => initializeChat(count + 1), 500) return } - let secret = nip19.decode(secretKeyString).data as Uint8Array + + const secret = nip19.decode(secretKeyString).data as Uint8Array const publicKey = getPublicKey(secret) setUserPublicKey(publicKey) + + // Load cached giftwraps const cachedGiftwraps = await loadGiftwrapsFromStorage() setGiftWraps(cachedGiftwraps) - let cachedRumors = [] - try { - cachedRumors = cachedGiftwraps.map((wrap) => getRumorFromWrap(wrap, secret)) - } catch (e) { - console.log("ERROR WHILE DECRYPTING RUMORS", e) - } - setRumors(cachedRumors) - let newCloser = await fetchNewGiftwraps(cachedGiftwraps, publicKey) - setCloser(newCloser) - } + setRumors( + cachedGiftwraps + .map((wrap) => { + try { + return getRumorFromWrap(wrap, secret) + } catch { + return null + } + }) + .filter((r) => r !== null) as Rumor[], + ) - const fetchNewGiftwraps = async (cachedGiftwraps: Event[], publicKey: string) => { - cachedGiftwraps = cachedGiftwraps.sort((a, b) => a.created_at - b.created_at) - const lastCachedEvent = cachedGiftwraps[cachedGiftwraps.length - 1] - let secretKeyString = await fetchSecretFromLocalStorage() - if (!secretKeyString) { - return null - } - let secret = nip19.decode(secretKeyString).data as Uint8Array - let closer = fetchGiftWrapsForPublicKey( - publicKey, + // ------------------------ + // Subscribe to giftwraps via NostrRuntime + // ------------------------ + nostrRuntime.ensureSubscription( + `giftwraps:${publicKey}`, + [ + { + "kinds": [1059], + "#p": [publicKey], + "limit": 150, + }, + ], + (event) => handleGiftWrapEvent(event, secret), + ) + + // Subscribe to contact list + nostrRuntime.ensureSubscription( + `contacts:${publicKey}`, + [ + { + kinds: [3], + authors: [publicKey], + }, + ], (event) => { - if (!processedEventIds.current.has(event.id)) { - processedEventIds.current.add(event.id) - setGiftWraps((prev) => { - const updatedGiftwraps = mergeGiftwraps(prev, [event]) - saveGiftwrapsToStorage(updatedGiftwraps) - return updatedGiftwraps - }) - let rumor = getRumorFromWrap(event, secret) - setRumors((prevRumors) => { - let previousRumors = prevRumors || [] - if (!previousRumors.map((r) => r.id).includes(rumor)) { - return [...(prevRumors || []), rumor] - } - return prevRumors - }) + setContactsEvent(event) + }, + ) + + // Subscribe to user profile + nostrRuntime.ensureSubscription( + `profile:${publicKey}`, + [ + { + kinds: [0], + authors: [publicKey], + }, + ], + (event) => { + setUserProfileEvent(event) + try { + const content = JSON.parse(event.content) + profileMap.current.set(event.pubkey, content) + } catch { + console.log("Failed to parse profile content") } }, - poolRef.current, - lastCachedEvent?.created_at - 20 * 60, ) - return closer } - const mergeGiftwraps = (cachedGiftwraps: Event[], fetchedGiftwraps: Event[]) => { - const existingIds = new Set(cachedGiftwraps.map((wrap) => wrap.id)) - const newGiftwraps = fetchedGiftwraps.filter((wrap) => !existingIds.has(wrap.id)) - return [...cachedGiftwraps, ...newGiftwraps] + // ------------------------ + // Refresh user profile (re-fetch from runtime) + // ------------------------ + const refreshUserProfile = async () => { + if (!userPublicKey) return + return new Promise((resolve) => { + const canonicalKey = `profile:${userPublicKey}` + const latest = nostrRuntime.getEventStore().getLatest(canonicalKey) + if (latest) { + setUserProfileEvent(latest) + try { + profileMap.current.set(latest.pubkey, JSON.parse(latest.content)) + } catch {} + } + resolve() + }) } - const addEventToProfiles = (event: Event) => { - try { - let content = JSON.parse(event.content) - profileMap.current.set(event.pubkey, content) - setLastEvent(event) - } catch (e) { - console.log("Couldn't parse the profile") - } + const getContactPubkeys = () => { + if (!contactsEvent) return null + return contactsEvent.tags.filter((t) => t[0] === "p").map((t) => t[1]) } + // ------------------------ + // Reset chat (clear events & resubscribe) + // ------------------------ const resetChat = async () => { setGiftWraps([]) setRumors([]) - setUserPublicKey(null) setUserProfileEvent(null) - closer?.close() - let secretKeyString = await fetchSecretFromLocalStorage() - if (!secretKeyString) return - let secret = nip19.decode(secretKeyString).data as Uint8Array - const publicKey = getPublicKey(secret) - setUserPublicKey(getPublicKey(secret)) - let newCloser = fetchGiftWrapsForPublicKey( - publicKey, - (event) => handleGiftWraps(event, secret), - poolRef!.current, - ) - setCloser(newCloser) + setUserPublicKey(null) + processedEventIds.current.clear() + nostrRuntime.getEventStore().clear() + await initializeChat() } + // Initialize on mount + React.useEffect(() => { + initializeChat() + }, []) + return ( { + try { + profileMap.current.set(event.pubkey, JSON.parse(event.content)) + } catch {} + }, resetChat, - activeSubscription: closer, + initializeChat, userProfileEvent, userPublicKey, refreshUserProfile, diff --git a/app/screens/chat/chatMessage.tsx b/app/screens/chat/chatMessage.tsx index 0e922aa31..02a1624c2 100644 --- a/app/screens/chat/chatMessage.tsx +++ b/app/screens/chat/chatMessage.tsx @@ -3,38 +3,39 @@ import "react-native-get-random-values" import React, { useEffect, useRef } from "react" import { View, Text } from "react-native" -import { Icon, makeStyles } from "@rneui/themed" +import { makeStyles } from "@rneui/themed" import { MessageType } from "@flyerhq/react-native-chat-ui" import { GaloyIcon } from "@app/components/atomic/galoy-icon" import { useChatContext } from "./chatContext" -import { fetchNostrUsers } from "@app/utils/nostr" -import { Event, nip19, SubCloser } from "nostr-tools" +import { Event, nip19 } from "nostr-tools" import { SUPPORT_AGENTS } from "@app/config/supportAgents" +import { nostrRuntime } from "@app/nostr/runtime/NostrRuntime" type Props = { message: MessageType.Text nextMessage: number prevMessage: boolean - showSender?: boolean // ✅ Add this line + showSender?: boolean } const USER_COLORS = [ - "#d32f2f", // deep red - "#388e3c", // dark green - "#1976d2", // blue - "#f57c00", // orange - "#7b1fa2", // purple - "#0097a7", // cyan - "#c2185b", // pink - "#512da8", // indigo - "#00796b", // teal - "#689f38", // lime green - "#5d4037", // brown - "#455a64", // blue-grey - "#0288d1", // sky blue - "#c62828", // crimson - "#fbc02d", // yellow (deep but readable) + "#d32f2f", + "#388e3c", + "#1976d2", + "#f57c00", + "#7b1fa2", + "#0097a7", + "#c2185b", + "#512da8", + "#00796b", + "#689f38", + "#5d4037", + "#455a64", + "#0288d1", + "#c62828", + "#fbc02d", ] + function getColorForUserId(userId: string): string { let hash = 0 for (let i = 0; i < userId.length; i++) { @@ -44,34 +45,35 @@ function getColorForUserId(userId: string): string { return USER_COLORS[index] } -export const ChatMessage: React.FC = ({ - message, - showSender = false, // ✅ Default to false for backward compatibility -}) => { +export const ChatMessage: React.FC = ({ message, showSender = false }) => { const styles = useStyles() const isMounted = useRef(false) - const { profileMap, addEventToProfiles, poolRef } = useChatContext() - + const { profileMap, addEventToProfiles } = useChatContext() const isAgent = SUPPORT_AGENTS.has(message.author.id) useEffect(() => { isMounted.current = true if (!showSender) return if (profileMap?.get(message.author.id)) return - if (!poolRef) return - else { - fetchNostrUsers([message.author.id], poolRef.current, (event: Event) => { + + // ✅ Subscribe to profile event via nostrRuntime + const key = `profile:${message.author.id}` + nostrRuntime.ensureSubscription( + key, + [{ kinds: [0], authors: [message.author.id] }], + (event: Event) => { addEventToProfiles(event) - }) - } + }, + ) + return () => { isMounted.current = false } - }, [poolRef]) + }, [showSender, message.author.id, addEventToProfiles, profileMap]) + return ( - {/* ✅ Optional sender display */} {showSender && ( ({ marginBottom: 6, paddingVertical: 2, borderRadius: 8, - backgroundColor: "#1976d2", // blue badge + backgroundColor: "#1976d2", }, badgeText: { color: "white", diff --git a/app/screens/chat/contactCard.tsx b/app/screens/chat/contactCard.tsx index b62129555..7cd62f447 100644 --- a/app/screens/chat/contactCard.tsx +++ b/app/screens/chat/contactCard.tsx @@ -1,12 +1,9 @@ // ContactCard.tsx import React from "react" -import { Image, Text, View } from "react-native" +import { Image } from "react-native" import { ListItem } from "@rneui/themed" import { useTheme } from "@rneui/themed" import { nip19 } from "nostr-tools" -import { GaloyIconButton } from "@app/components/atomic/galoy-icon-button" -import ChatIcon from "@app/assets/icons/chat.svg" -import { hexToBytes } from "@noble/curves/abstract/utils" interface ContactCardProps { item: any diff --git a/app/screens/chat/contactDetailsScreen.tsx b/app/screens/chat/contactDetailsScreen.tsx index b11d2296e..6d200a1a4 100644 --- a/app/screens/chat/contactDetailsScreen.tsx +++ b/app/screens/chat/contactDetailsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react" +import React, { useState, useCallback } from "react" import { View, Text, @@ -6,8 +6,7 @@ import { TouchableOpacity, ScrollView, ActivityIndicator, - Platform, - StatusBar, + Linking, } from "react-native" import { useTheme, makeStyles } from "@rneui/themed" import { @@ -18,25 +17,26 @@ import { } from "@react-navigation/native" import { StackNavigationProp } from "@react-navigation/stack" import { ChatStackParamList } from "@app/navigation/stack-param-lists" -import { useChatContext } from "./chatContext" import Icon from "react-native-vector-icons/Ionicons" -import ChatIcon from "@app/assets/icons/chat.svg" -import { nip19, getPublicKey, Event } from "nostr-tools" -import { publicRelays } from "@app/utils/nostr" -import { bytesToHex, hexToBytes } from "@noble/hashes/utils" +import LinearGradient from "react-native-linear-gradient" +import Clipboard from "@react-native-clipboard/clipboard" + import { Screen } from "../../components/screen" import { useI18nContext } from "@app/i18n/i18n-react" -import { pool } from "@app/utils/nostr/pool" +import { nip19, getPublicKey, Event } from "nostr-tools" +import { bytesToHex, hexToBytes } from "@noble/hashes/utils" + import { FeedItem } from "@app/components/nostr-feed/FeedItem" -import { useBusinessMapMarkersQuery } from "@app/graphql/generated" -import ArrowUp from "@app/assets/icons/arrow-up.svg" -import { Linking } from "react-native" -import LinearGradient from "react-native-linear-gradient" -import Clipboard from "@react-native-clipboard/clipboard" -import { toastShow } from "@app/utils/toast" -import { Pressable } from "react-native" -import { SvgUri } from "react-native-svg" import { ExplainerVideo } from "@app/components/explainer-video" +import ChatIcon from "@app/assets/icons/chat.svg" +import ArrowUp from "@app/assets/icons/arrow-up.svg" + +import { useChatContext } from "./chatContext" +import { useBusinessMapMarkersQuery } from "@app/graphql/generated" +import { nostrRuntime } from "@app/nostr/runtime/NostrRuntime" +import { pool } from "@app/utils/nostr/pool" + +type ContactDetailsRouteProp = RouteProp const RELAYS = [ "wss://relay.damus.io", @@ -45,8 +45,6 @@ const RELAYS = [ "wss://relay.snort.social", ] -type ContactDetailsRouteProp = RouteProp - const ContactDetailsScreen: React.FC = () => { const route = useRoute() const navigation = @@ -54,31 +52,23 @@ const ContactDetailsScreen: React.FC = () => { const { theme } = useTheme() const colors = theme.colors const styles = useStyles() - const { profileMap, contactsEvent, poolRef, setContactsEvent } = useChatContext() const { LL } = useI18nContext() + const { profileMap, contactsEvent, setContactsEvent } = useChatContext() const { contactPubkey, userPrivateKey } = route.params - const profile = profileMap?.get(contactPubkey) const npub = nip19.npubEncode(contactPubkey) - // State for managing Nostr posts (kind 1) and reposts (kind 6) + // Posts, reposts, and profiles const [posts, setPosts] = useState([]) const [loadingPosts, setLoadingPosts] = useState(true) const [repostedEvents, setRepostedEvents] = useState>(new Map()) const [repostedProfiles, setRepostedProfiles] = useState>(new Map()) - React.useEffect(() => { - navigation.setOptions({ - title: profile?.name || profile?.username || profile?.nip05 || "Nostr User", - }) - }, [profile, navigation]) - - // Detect if this contact is a business account (Level 2 or 3) by checking if their username is in businessMapMarkers + // Business check const { data: businessMapData } = useBusinessMapMarkersQuery({ fetchPolicy: "cache-first", }) - const businessUsernames = businessMapData?.businessMapMarkers.map((m) => m.username) || [] const isBusiness = profile?.username @@ -87,185 +77,140 @@ const ContactDetailsScreen: React.FC = () => { const userPrivateKeyHex = typeof userPrivateKey === "string" ? userPrivateKey : bytesToHex(userPrivateKey) - - // Check if viewing own profile const selfPubkey = userPrivateKey ? getPublicKey(hexToBytes(userPrivateKeyHex)) : null - const isSelf = contactPubkey === selfPubkey - + const isOwnProfile = selfPubkey === contactPubkey const userPubkey = getPublicKey( typeof userPrivateKey === "string" ? hexToBytes(userPrivateKey) : userPrivateKey, ) - - // Check if viewing own profile to hide message/send payment buttons - const isOwnProfile = userPubkey === contactPubkey const groupId = [userPubkey, contactPubkey].sort().join(",") - const handleUnfollow = () => { - if (!poolRef || !contactsEvent) return - - let profiles = contactsEvent.tags.filter((p) => p[0] === "p").map((p) => p[1]) - let tagsWithoutProfiles = contactsEvent.tags.filter((p) => p[0] !== "p") - let newProfiles = profiles.filter((p) => p !== contactPubkey) + // Copy npub + const handleCopy = () => { + if (!npub) return + Clipboard.setString(npub) + } - let newContactsEvent = { + // Actions + const handleUnfollow = () => { + if (!contactsEvent) return + const profiles = contactsEvent.tags.filter((p) => p[0] === "p").map((p) => p[1]) + const tagsWithoutProfiles = contactsEvent.tags.filter((p) => p[0] !== "p") + const newProfiles = profiles.filter((p) => p !== contactPubkey) + const newContactsEvent = { ...contactsEvent, tags: [...tagsWithoutProfiles, ...newProfiles.map((p) => ["p", p])], } - poolRef.current.publish(publicRelays, newContactsEvent) + pool.publish(RELAYS, newContactsEvent) setContactsEvent(newContactsEvent) navigation.goBack() } const handleSendPayment = () => { const lud16 = profile?.lud16 || "" - navigation.navigate("sendBitcoinDestination", { - username: lud16, - }) + navigation.navigate("sendBitcoinDestination", { username: lud16 }) } const handleStartChat = () => { - navigation.replace("messages", { - groupId: groupId, - userPrivateKey: userPrivateKeyHex, - }) + navigation.replace("messages", { groupId, userPrivateKey: userPrivateKeyHex }) } - const handleCopy = () => { - if (!npub) return - Clipboard.setString(npub) - toastShow({ type: "success", message: "npub copied to clipboard", autoHide: true }) - } - - // Fetch Nostr posts (kind 1) and reposts (kind 6) from this contact - // For reposts, we parse the embedded event and fetch the original author's profile - const fetchPosts = useCallback(async () => { - try { - setLoadingPosts(true) - const fetchedEvents: Event[] = [] - const repostMap = new Map() - const profilesMap = new Map() - - const sub = pool.subscribeMany( - RELAYS, - [ - { - kinds: [1, 6], - authors: [contactPubkey], - limit: 10, - }, - ], - { - onevent(event) { - if (!fetchedEvents.find((e) => e.id === event.id)) { - fetchedEvents.push(event) - - if (event.kind === 6) { - const eTag = event.tags.find((tag) => tag[0] === "e") - const pTag = event.tags.find((tag) => tag[0] === "p") - - if (eTag && eTag[1]) { - try { - const repostedEvent = JSON.parse(event.content) - repostMap.set(event.id, repostedEvent) - - if (pTag && pTag[1]) { - pool.subscribeMany( - RELAYS, - [{ kinds: [0], authors: [pTag[1]], limit: 1 }], - { - onevent(profileEvent) { - const profileData = JSON.parse(profileEvent.content) - profilesMap.set(pTag[1], profileData) - setRepostedProfiles(new Map(profilesMap)) - }, - }, - ) - } - } catch (e) { - console.error("Error parsing reposted event", e) - } - } + // Fetch posts and reposts using nostrRuntime + const fetchPosts = useCallback(() => { + setLoadingPosts(true) + const fetchedEvents: Event[] = [] + const repostMap = new Map() + const profilesMap = new Map() + + const subKey = `posts:${contactPubkey}` + + nostrRuntime.ensureSubscription( + subKey, + [{ kinds: [1, 6], authors: [contactPubkey], limit: 10 }], + (event: Event) => { + if (fetchedEvents.find((e) => e.id === event.id)) return + fetchedEvents.push(event) + + if (event.kind === 6) { + const eTag = event.tags.find((tag) => tag[0] === "e") + const pTag = event.tags.find((tag) => tag[0] === "p") + + if (eTag && eTag[1]) { + try { + const repostedEvent = JSON.parse(event.content) + repostMap.set(event.id, repostedEvent) + + if (pTag && pTag[1]) { + nostrRuntime.ensureSubscription( + `profile:${pTag[1]}`, + [{ kinds: [0], authors: [pTag[1]], limit: 1 }], + (profileEvent) => { + const profileData = JSON.parse(profileEvent.content) + profilesMap.set(pTag[1], profileData) + setRepostedProfiles(new Map(profilesMap)) + }, + undefined, // EOSE optional + ) } + } catch (err) { + console.error("Error parsing reposted event", err) } - }, - oneose() { - fetchedEvents.sort((a, b) => b.created_at - a.created_at) - setPosts(fetchedEvents) - setRepostedEvents(repostMap) - sub.close() - setLoadingPosts(false) - }, - }, - ) - - setTimeout(() => { - sub.close() + } + } + }, + () => { + // On EOSE + fetchedEvents.sort((a, b) => b.created_at - a.created_at) + setPosts(fetchedEvents) + setRepostedEvents(repostMap) setLoadingPosts(false) - }, 5000) - } catch (error) { - console.error("Error fetching posts:", error) + }, + ) + + // Safety timeout + setTimeout(() => { setLoadingPosts(false) - } + }, 5000) }, [contactPubkey]) useFocusEffect( - React.useCallback(() => { - console.log("fetching post") - const sub = fetchPosts() + useCallback(() => { + fetchPosts() }, [fetchPosts]), ) + return ( - {/* Banner section - Primal style */} - {profile?.banner && - (profile.banner.endsWith(".svg") ? ( - - ) : ( - - ))} - - {/* Profile section with overlapping profile picture */} - - {/* Profile picture overlaps the banner */} + {/* Banner */} + {profile?.banner && ( + + )} + + {/* Profile info */} + - {/* Profile info section */} - {profile?.name || profile?.username || LL.Nostr.Contacts.nostrUser()} + {profile?.name || profile?.username || "Nostr User"} {isOwnProfile && ( navigation.navigate("EditNostrProfile" as any)} - hitSlop={8} - style={styles.editIconButton} > @@ -278,204 +223,74 @@ const ContactDetailsScreen: React.FC = () => { )} - [styles.profileNpub]} - hitSlop={8} - > + {npub?.slice(0, 8)}...{npub?.slice(-6)} - - - {profile?.lud16 && ( - - - {profile.lud16} - - )} + + {profile?.lud16 && {profile.lud16}} {profile?.website && ( - - - {profile.website} - + {profile.website} )} - {profile?.about && {profile.about}} - {/* Action buttons (message/send payment) - hidden when viewing own profile */} + {/* Actions */} {!isOwnProfile && ( - - - - - {LL.Nostr.Contacts.message()} - - - - - - - {LL.Nostr.Contacts.sendPayment()} - + + + + + + )} + + {/* Posts */} - - - {isBusiness ? "Business Updates" : "Recent Posts"} - - {posts.length > 0 && ( - - {posts.length} {posts.length === 1 ? "post" : "posts"} - - )} - {loadingPosts ? ( - - - - Loading... - - + ) : posts.length === 0 ? ( - - - - {isBusiness ? "No business updates yet" : "No posts yet"} - - navigation.navigate("makeNostrPost")} - activeOpacity={0.7} - > - - - - {LL.NostrQuickStart.postHeading()} - - - {LL.NostrQuickStart.postDesc()} - - - - - + No posts yet ) : ( - <> - {posts.map((post) => ( - tag[0] === "p")?.[1] || "", - ) - : undefined - } - /> - ))} - + posts.map((post) => ( + tag[0] === "p")?.[1] || "", + ) + : undefined + } + /> + )) )} - - {/* Explainer video */} - - - {/* CTA button to explore full Nostr experience on Primal */} - Linking.openURL(`https://primal.net/p/${userPubkey}`)} - activeOpacity={0.8} - > - - - - - - - Explore more on Primal - - Full Nostr experience with feeds, notifications & more - - - - - - - - - {selfPubkey !== contactPubkey ? ( - - - {LL.Nostr.Contacts.contactManagement()} - - - - - {LL.Nostr.Contacts.unfollowContact()} - - - - ) : null} + + {/* Explainer video */} + - {/* Floating Action Button to create new post - only shown when user has posts */} - {posts.length > 0 && ( + {/* Unfollow */} + {!isOwnProfile && ( navigation.navigate("makeNostrPost")} - activeOpacity={0.8} + style={[styles.unfollowButton, { backgroundColor: colors.error }]} + onPress={handleUnfollow} > - + Unfollow )} @@ -483,46 +298,19 @@ const ContactDetailsScreen: React.FC = () => { } const useStyles = makeStyles(({ colors }) => ({ - headerContainer: { - borderBottomWidth: 2, - shadowColor: colors.grey5, - shadowOffset: { width: 2, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 2, - elevation: 2, - }, - scrollView: { - flex: 1, - }, - bannerImage: { - width: "100%", - height: 150, - backgroundColor: colors.grey5, - }, - profileContainer: { - paddingHorizontal: 16, - }, - profileImageWrapper: { - marginTop: -40, - marginBottom: 12, - }, + scrollView: { flex: 1 }, + bannerImage: { width: "100%", height: 150, backgroundColor: colors.grey5 }, + profileContainer: { paddingHorizontal: 16 }, + profileImageWrapper: { marginTop: -40, marginBottom: 12 }, profileImage: { width: 96, height: 96, borderRadius: 48, borderWidth: 4, borderColor: colors.background, - backgroundColor: colors.grey5, - }, - profileInfoSection: { - paddingBottom: 12, - }, - nameRow: { - flexDirection: "row", - alignItems: "center", - gap: 8, - marginBottom: 8, }, + profileInfoSection: { paddingBottom: 12 }, + nameRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 }, businessBadge: { flexDirection: "row", alignItems: "center", @@ -532,252 +320,34 @@ const useStyles = makeStyles(({ colors }) => ({ borderRadius: 10, gap: 3, }, - businessBadgeText: { - color: "#FFFFFF", - fontSize: 11, - fontWeight: "600", - }, - profileName: { - fontSize: 20, - fontWeight: "bold", - color: colors.black, - }, - profileNpub: { - flexDirection: "row", - alignItems: "center", - gap: 4, - marginBottom: 3, - }, - npubText: { - fontSize: 12, - color: "#888", - }, - profileLud16: { - flexDirection: "row", - alignItems: "center", - gap: 4, - marginBottom: 3, - paddingHorizontal: 8, - paddingVertical: 3, - backgroundColor: "rgba(255, 165, 0, 0.1)", - borderRadius: 8, - alignSelf: "flex-start", - }, - lud16Text: { - fontSize: 12, - fontWeight: "500", - color: colors.grey1, - }, - profileWebsite: { - flexDirection: "row", - alignItems: "center", - gap: 4, - }, - websiteText: { - fontSize: 12, - color: colors.grey3, - }, - aboutText: { - fontSize: 14, - paddingTop: 8, - lineHeight: 20, - color: colors.grey1, - }, + businessBadgeText: { color: "#FFF", fontSize: 11, fontWeight: "600" }, + profileName: { fontSize: 20, fontWeight: "bold", color: colors.black }, + profileNpub: { flexDirection: "row", alignItems: "center", gap: 4, marginBottom: 3 }, + npubText: { fontSize: 12, color: "#888" }, + lud16Text: { fontSize: 12, color: colors.grey1 }, + websiteText: { fontSize: 12, color: colors.grey3 }, + aboutText: { fontSize: 14, lineHeight: 20, paddingTop: 8, color: colors.grey1 }, actionsContainer: { flexDirection: "row", alignItems: "center", justifyContent: "center", marginTop: 12, - paddingVertical: 8, - }, - iconBtnContainer: { - alignItems: "center", - justifyContent: "center", - paddingHorizontal: 8, - marginHorizontal: 8, - }, - iconButton: { - height: 64, - width: 64, - alignItems: "center", - justifyContent: "center", - borderRadius: 100, - marginBottom: 5, }, messageButton: { + marginHorizontal: 8, backgroundColor: "#60aa55", + padding: 16, + borderRadius: 32, }, sendButton: { + marginHorizontal: 8, backgroundColor: "#FF8C42", - }, - iconBtnLabel: { - textAlign: "center", - fontSize: 14, - fontWeight: "600", - color: colors.black, - }, - editIconButton: { - marginLeft: 6, - padding: 2, - }, - icon: { - fontSize: 24, - marginRight: 12, - }, - sectionTitle: { - fontSize: 16, - fontWeight: "bold", - marginBottom: 12, - }, - dangerZoneContainer: { padding: 16, - marginTop: 16, - borderTopWidth: 1, - marginBottom: 24, - }, - dangerZoneTitle: { - fontSize: 18, - fontWeight: "bold", - marginBottom: 16, - }, - unfollowButton: { - flexDirection: "row", - alignItems: "center", - padding: 16, - borderRadius: 8, - marginTop: 8, - }, - postsSection: { - padding: 12, - marginTop: 8, - }, - postsSectionHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 12, - }, - postsCount: { - fontSize: 14, - fontWeight: "500", - }, - loadingContainer: { - alignItems: "center", - paddingVertical: 16, - }, - loadingText: { - marginTop: 8, - fontSize: 14, - }, - emptyPostsContainer: { - alignItems: "center", - paddingVertical: 20, - }, - emptyPostsText: { - fontSize: 16, - textAlign: "center", - marginBottom: 16, - }, - makePostCTA: { - flexDirection: "row", - alignItems: "center", - borderRadius: 12, - padding: 16, - marginTop: 8, - marginHorizontal: 16, - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, - }, - makePostTextContainer: { - flex: 1, - }, - makePostTitle: { - fontSize: 16, - fontWeight: "700", - marginBottom: 4, - }, - makePostDesc: { - fontSize: 13, - lineHeight: 18, - }, - explainerVideo: { - marginTop: 16, - marginHorizontal: 8, - marginBottom: 8, - }, - primalButton: { - marginTop: 8, - marginHorizontal: 8, - borderRadius: 16, - overflow: "hidden", - shadowColor: "#000", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.15, - shadowRadius: 8, - elevation: 4, - }, - primalButtonGradient: { - borderRadius: 16, - }, - primalButtonContent: { - flexDirection: "row", - alignItems: "center", - padding: 18, - gap: 14, - }, - primalLogoContainer: { - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: "rgba(255, 255, 255, 0.2)", - justifyContent: "center", - alignItems: "center", - }, - primalLogo: { - width: 32, - height: 32, - borderRadius: 16, - }, - primalTextContainer: { - flex: 1, - }, - primalTitle: { - fontSize: 17, - fontWeight: "700", - marginBottom: 4, - color: "#FFFFFF", - }, - primalSubtitle: { - fontSize: 13, - lineHeight: 18, - color: "rgba(255, 255, 255, 0.85)", - }, - primalArrowContainer: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: "rgba(255, 255, 255, 0.2)", - justifyContent: "center", - alignItems: "center", - }, - fab: { - position: "absolute", - right: 20, - bottom: 20, - width: 56, - height: 56, - borderRadius: 28, - justifyContent: "center", - alignItems: "center", - elevation: 6, - shadowColor: "#000", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, + borderRadius: 32, }, + postsSection: { padding: 12, marginTop: 8 }, + explainerVideo: { marginTop: 16, marginHorizontal: 8, marginBottom: 8 }, + unfollowButton: { padding: 16, borderRadius: 8, marginTop: 8, alignItems: "center" }, })) export default ContactDetailsScreen diff --git a/app/screens/chat/contacts.tsx b/app/screens/chat/contacts.tsx index 90fc2e6ea..47a011391 100644 --- a/app/screens/chat/contacts.tsx +++ b/app/screens/chat/contacts.tsx @@ -2,80 +2,73 @@ import React, { useEffect, useState } from "react" import { FlatList, Text, View, ActivityIndicator } from "react-native" import { useStyles } from "./style" import { useChatContext } from "./chatContext" -import { nip19, Event } from "nostr-tools" -import { useNavigation, useRoute, RouteProp } from "@react-navigation/native" // <-- Added useRoute, RouteProp +import { Event } from "nostr-tools" +import { useNavigation, useRoute, RouteProp } from "@react-navigation/native" import { useTheme } from "@rneui/themed" import { StackNavigationProp } from "@react-navigation/stack" -import { ChatStackParamList, RootStackParamList } from "@app/navigation/stack-param-lists" // <-- Ensure RootStackParamList is imported +import { ChatStackParamList, RootStackParamList } from "@app/navigation/stack-param-lists" import { UserSearchBar } from "./UserSearchBar" import { SearchListItem } from "./searchListItem" import { hexToBytes } from "@noble/curves/abstract/utils" import { getContactsFromEvent } from "./utils" import ContactCard from "./contactCard" -import { fetchNostrUsers } from "@app/utils/nostr" import { useI18nContext } from "@app/i18n/i18n-react" +import { nostrRuntime } from "@app/nostr/runtime/NostrRuntime" -// 1. Define the shape of the route params +// Route params type type ContactsRouteProp = RouteProp interface ContactsProps { - // 2. Make this OPTIONAL so Stack.Screen doesn't yell at you userPrivateKey?: string } const Contacts: React.FC = ({ userPrivateKey: propKey }) => { const baseStyles = useStyles() const [searchedUsers, setSearchedUsers] = useState([]) - const { poolRef, profileMap, contactsEvent, addEventToProfiles } = useChatContext() + const { profileMap, contactsEvent, addEventToProfiles } = useChatContext() const navigation = useNavigation>() - - // 3. Hook into the route to get params from Settings const route = useRoute() const { theme } = useTheme() - const { LL } = useI18nContext() const colors = theme.colors + const { LL } = useI18nContext() - // 4. Determine the actual key to use - // If passed as a prop (Tabs), use it. If not, look in navigation params (Settings). const realUserKey = propKey || route.params?.userPrivateKey - const [showAltMessage, setShowAltMessage] = useState(false) + // Show alternative message if loading takes too long useEffect(() => { - const timer = setTimeout(() => { - setShowAltMessage(true) - }, 3000) + const timer = setTimeout(() => setShowAltMessage(true), 3000) return () => clearTimeout(timer) }, []) + // Fetch contacts once and subscribe for live updates useEffect(() => { - if (!poolRef) return - let contactPubkeys = - contactsEvent?.tags.filter((p) => p[0] === "p").map((p) => p[1]) || [] - let closer = fetchNostrUsers(contactPubkeys, poolRef.current, (event: Event) => { - addEventToProfiles(event) - }) - return () => { - if (closer) closer.close() - } - }, [poolRef, contactsEvent]) + if (!contactsEvent) return + + const contactPubkeys = contactsEvent.tags.filter((p) => p[0] === "p").map((p) => p[1]) + const subKey = `contacts:${contactPubkeys.join(",")}` - // 5. Safety check: If we still don't have a key, handle it (optional but recommended) + nostrRuntime.ensureSubscription( + subKey, + [{ kinds: [0], authors: contactPubkeys }], + (event: Event) => { + addEventToProfiles(event) + }, + ) + }, [contactsEvent]) + + // Safety check: if we still don't have a key if (!realUserKey) { return ( - + ) } - // ... The rest of your styles and logic ... - const styles = { ...baseStyles, - container: { - flex: 1, - }, + container: { flex: 1 }, contactCardWrapper: { borderRadius: 8, marginBottom: 10, @@ -87,7 +80,7 @@ const Contacts: React.FC = ({ userPrivateKey: propKey }) => { }, } - let ListEmptyContent = ( + const ListEmptyContent = ( @@ -96,13 +89,14 @@ const Contacts: React.FC = ({ userPrivateKey: propKey }) => { const navigateToContactDetails = (contactPubkey: string) => { navigation.navigate("contactDetails", { contactPubkey, - userPrivateKey: realUserKey, // Use the resolved key + userPrivateKey: realUserKey, }) } return ( + {searchedUsers.length !== 0 ? ( = ({ userPrivateKey: propKey }) => { renderItem={({ item }) => ( )} @@ -119,7 +112,7 @@ const Contacts: React.FC = ({ userPrivateKey: propKey }) => { /> ) : contactsEvent ? ( {LL.Nostr.Contacts.noCantacts()}} renderItem={({ item }) => ( diff --git a/app/screens/chat/historyListItem.tsx b/app/screens/chat/historyListItem.tsx index 0227c7b89..5146b164c 100644 --- a/app/screens/chat/historyListItem.tsx +++ b/app/screens/chat/historyListItem.tsx @@ -1,63 +1,67 @@ import { ListItem } from "@rneui/themed" import { useStyles } from "./style" import { Image, Text, View } from "react-native" -import { nip19, Event, SubCloser, getPublicKey } from "nostr-tools" +import { nip19, Event, getPublicKey } from "nostr-tools" import { useFocusEffect, useNavigation } from "@react-navigation/native" import { StackNavigationProp } from "@react-navigation/stack" import { ChatStackParamList } from "@app/navigation/stack-param-lists" import { useEffect, useState } from "react" import { useChatContext } from "./chatContext" -import { Rumor, fetchNostrUsers } from "@app/utils/nostr" +import { Rumor } from "@app/utils/nostr" import { getLastSeen } from "./utils" import { bytesToHex } from "@noble/hashes/utils" import Icon from "react-native-vector-icons/Ionicons" +import { nostrRuntime } from "@app/nostr/runtime/NostrRuntime" interface HistoryListItemProps { item: string userPrivateKey: Uint8Array groups: Map } + export const HistoryListItem: React.FC = ({ item, userPrivateKey, groups, }) => { - const { poolRef, profileMap, addEventToProfiles } = useChatContext() + const { profileMap, addEventToProfiles } = useChatContext() const [hasUnread, setHasUnread] = useState(false) + const [subscribedPubkeys, setSubscribedPubkeys] = useState>(new Set()) const userPublicKey = userPrivateKey ? getPublicKey(userPrivateKey) : "" const selfNote = item.split(",").length === 1 - function handleProfileEvent(event: Event) { - addEventToProfiles(event) - } + const navigation = useNavigation>() + const styles = useStyles() + // Last message in this conversation + const lastRumor = (groups.get(item) || []).sort( + (a, b) => b.created_at - a.created_at, + )[0] + + // Subscribe to profiles using nostrRuntime useEffect(() => { - let closer: SubCloser | null = null - if (poolRef?.current && !closer) { - let fetchPubkeys: string[] = [] - item.split(",").forEach((p) => { - if (!profileMap?.get(p)) { - fetchPubkeys.push(p) - } - }) - if (fetchPubkeys.length !== 0) - closer = fetchNostrUsers(fetchPubkeys, poolRef?.current, handleProfileEvent) - } + const pubkeys = item + .split(",") + .filter((p) => !profileMap?.get(p) && !subscribedPubkeys.has(p)) + if (pubkeys.length === 0) return - return () => { - if (closer) { - closer.close() - } - } - }, [poolRef, profileMap]) + pubkeys.forEach((pubkey) => subscribedPubkeys.add(pubkey)) + setSubscribedPubkeys(new Set(subscribedPubkeys)) + + const unsub = nostrRuntime.ensureSubscription( + `historyProfile:${pubkeys.join(",")}`, + [{ kinds: [0], authors: pubkeys }], + (event: Event) => { + addEventToProfiles(event) + }, + ) + }, [profileMap, subscribedPubkeys, item]) + // Check unread messages useFocusEffect(() => { const checkUnreadStatus = async () => { const lastSeen = await getLastSeen(item) - const lastRumor = (groups.get(item) || []).sort( - (a, b) => b.created_at - a.created_at, - )[0] if (lastRumor && (!lastSeen || lastSeen < lastRumor.created_at)) { setHasUnread(true) } else { @@ -67,11 +71,6 @@ export const HistoryListItem: React.FC = ({ checkUnreadStatus() }) - const styles = useStyles() - const navigation = useNavigation>() - const lastRumor = (groups.get(item) || []).sort( - (a, b) => b.created_at - a.created_at, - )[0] return ( = ({ }) } > + {/* Profile Images */} {item .split(",") .filter((p) => p !== userPublicKey) - .map((p: any) => { - return ( - - ) - })} - {selfNote ? ( + .map((p) => ( + + ))} + + {/* Self note indicator */} + {selfNote && ( - ) : null} + )} + + {/* Names and last message */} - {" "} {item .split(",") .filter((p) => p !== userPublicKey) .map((pubkey) => { + const profile = profileMap?.get(pubkey) return ( - (profileMap?.get(pubkey) as NostrProfile)?.nip05 || - (profileMap?.get(pubkey) as NostrProfile)?.name || - (profileMap?.get(pubkey) as NostrProfile)?.username || + profile?.nip05 || + profile?.name || + profile?.username || nip19.npubEncode(pubkey).slice(0, 9) + ".." ) }) .join(", ")} - {selfNote ? ( - + {selfNote && ( + Note to Self @@ -136,30 +136,29 @@ export const HistoryListItem: React.FC = ({ name="checkmark-done-circle-outline" size={20} style={styles.verifiedIcon} - > + /> - ) : null} + )} + - - - {(profileMap?.get(lastRumor.pubkey) as NostrProfile)?.name || - (profileMap?.get(lastRumor.pubkey) as NostrProfile)?.nip05 || - (profileMap?.get(lastRumor.pubkey) as NostrProfile)?.username || - nip19.npubEncode(lastRumor.pubkey).slice(0, 9) + "..."} - {": "} - {lastRumor.content.replace(/\s+/g, " ").slice(0, 55)} - {lastRumor.content.length > 45 ? "..." : ""} - + + {lastRumor && ( + + {(profileMap?.get(lastRumor.pubkey)?.name || + profileMap?.get(lastRumor.pubkey)?.nip05 || + profileMap?.get(lastRumor.pubkey)?.username || + nip19.npubEncode(lastRumor.pubkey).slice(0, 9)) + ": "} + {lastRumor.content.replace(/\s+/g, " ").slice(0, 55)} + {lastRumor.content.length > 45 ? "..." : ""} + + )} + + {/* Unread indicator */} {hasUnread && } ) diff --git a/app/screens/chat/messages.tsx b/app/screens/chat/messages.tsx index a211e8738..8064b853c 100644 --- a/app/screens/chat/messages.tsx +++ b/app/screens/chat/messages.tsx @@ -2,7 +2,7 @@ import "react-native-get-random-values" import * as React from "react" import { ActivityIndicator, Image, Platform, View } from "react-native" import { useI18nContext } from "@app/i18n/i18n-react" -import { RouteProp, useNavigation, useRoute } from "@react-navigation/native" +import { RouteProp, useNavigation } from "@react-navigation/native" import { StackNavigationProp } from "@react-navigation/stack" import { Screen } from "../../components/screen" import type { @@ -15,61 +15,56 @@ import { isIos } from "@app/utils/helper" import { Chat, MessageType, defaultTheme } from "@flyerhq/react-native-chat-ui" import { ChatMessage } from "./chatMessage" import Icon from "react-native-vector-icons/Ionicons" -import { getPublicKey, Event, nip19, SubCloser } from "nostr-tools" -import { - Rumor, - convertRumorsToGroups, - fetchNostrUsers, - fetchPreferredRelays, - sendNip17Message, -} from "@app/utils/nostr" +import { getPublicKey, nip19, Event } from "nostr-tools" +import { Rumor, convertRumorsToGroups, sendNip17Message } from "@app/utils/nostr" import { useEffect, useState } from "react" import { useChatContext } from "./chatContext" import { SafeAreaProvider } from "react-native-safe-area-context" import { updateLastSeen } from "./utils" import { hexToBytes } from "@noble/hashes/utils" +import { nostrRuntime } from "@app/nostr/runtime/NostrRuntime" +import { pool } from "@app/utils/nostr/pool" type MessagesProps = { route: RouteProp } export const Messages: React.FC = ({ route }) => { - let userPubkey = getPublicKey(hexToBytes(route.params.userPrivateKey)) - let groupId = route.params.groupId - const { poolRef } = useChatContext() - const [profileMap, setProfileMap] = useState>() - const [preferredRelaysMap, setPreferredRelaysMap] = useState>() + const userPrivateKeyBytes = hexToBytes(route.params.userPrivateKey) + const userPubkey = getPublicKey(userPrivateKeyBytes) + const groupId = route.params.groupId + const [profileMap, setProfileMap] = useState>(new Map()) + const [preferredRelaysMap, setPreferredRelaysMap] = useState>( + new Map(), + ) - function handleProfileEvent(event: Event) { - let profile = JSON.parse(event.content) - setProfileMap((profileMap) => { - let newProfileMap = profileMap || new Map() - newProfileMap.set(event.pubkey, profile) - return newProfileMap - }) + // Helper for handling profile events + const handleProfileEvent = (event: Event) => { + try { + const profile = JSON.parse(event.content) + setProfileMap((prev) => new Map(prev).set(event.pubkey, profile)) + } catch (e) { + console.error("Failed to parse profile event", e) + } } + // Subscribe to profiles using nostrRuntime useEffect(() => { - let closer: SubCloser - if (poolRef) { - closer = fetchNostrUsers(groupId.split(","), poolRef.current, handleProfileEvent) - fetchPreferredRelays(groupId.split(","), poolRef.current).then( - (relayMap: Map) => { - setPreferredRelaysMap(relayMap) - }, - ) - } - return () => { - if (closer) closer.close() - } - }, [groupId, poolRef]) + const pubkeys = groupId.split(",") + nostrRuntime.ensureSubscription( + `messagesProfiles:${pubkeys.join(",")}`, + [{ kinds: [0], authors: pubkeys }], + handleProfileEvent, + ) + }, [groupId]) return ( ) } @@ -77,12 +72,14 @@ export const Messages: React.FC = ({ route }) => { type MessagesScreenProps = { groupId: string userPubkey: string - profileMap?: Map - preferredRelaysMap?: Map + userPrivateKey: Uint8Array + profileMap: Map + preferredRelaysMap: Map } export const MessagesScreen: React.FC = ({ userPubkey, + userPrivateKey, groupId, profileMap, preferredRelaysMap, @@ -90,19 +87,18 @@ export const MessagesScreen: React.FC = ({ const { theme: { colors }, } = useTheme() - let { rumors, poolRef } = useChatContext() + const { rumors } = useChatContext() const styles = useStyles() const navigation = useNavigation>() const { LL } = useI18nContext() - const [initialized, setInitialized] = React.useState(false) + const [initialized, setInitialized] = useState(false) const [messages, setMessages] = useState>(new Map()) - const user = { id: userPubkey } const convertRumorsToMessages = (rumors: Rumor[]) => { - let chatSet: Map = new Map() - ;(rumors || []).forEach((r: Rumor) => { - chatSet.set(r.id, { + const chatMap = new Map() + rumors.forEach((r) => { + chatMap.set(r.id, { author: { id: r.pubkey }, createdAt: r.created_at * 1000, id: r.id, @@ -110,175 +106,120 @@ export const MessagesScreen: React.FC = ({ text: r.content, }) }) - return chatSet + return chatMap } - React.useEffect(() => { - let isMounted = true - async function initialize() { - if (poolRef) setInitialized(true) - } - if (!initialized) initialize() - let chatRumors = convertRumorsToGroups(rumors).get(groupId) - const lastRumor = (chatRumors || []).sort((a, b) => b.created_at - a.created_at)[0] + // Initialize messages map & last-seen tracking + useEffect(() => { + setInitialized(true) + const chatRumors = convertRumorsToGroups(rumors).get(groupId) || [] + const lastRumor = chatRumors.sort((a, b) => b.created_at - a.created_at)[0] if (lastRumor) updateLastSeen(groupId, lastRumor.created_at) - let newChatMap = new Map([...messages, ...convertRumorsToMessages(chatRumors || [])]) - setMessages(newChatMap) - return () => { - isMounted = false - } - }, [poolRef, rumors]) + setMessages((prev) => new Map([...prev, ...convertRumorsToMessages(chatRumors)])) + }, [rumors]) const handleSendPress = async (message: MessageType.PartialText) => { - let textMessage: MessageType.Text = { + const textMessage: MessageType.Text = { author: user, createdAt: Date.now(), text: message.text, type: "text", id: message.text, } + let sent = false - let onSent = (rumor: Rumor) => { - console.log("OnSent") + const onSent = (rumor: Rumor) => { if (!sent) { - console.log("On sent setting") textMessage.id = rumor.id - setMessages((prevChat) => { - let newChatMap = new Map(prevChat) - newChatMap.set(textMessage.id, textMessage) - return newChatMap - }) + setMessages((prev) => new Map(prev).set(textMessage.id, textMessage)) sent = true } } - let result = await sendNip17Message( + + const result = await sendNip17Message( groupId.split(","), message.text, - preferredRelaysMap || new Map(), + preferredRelaysMap, onSent, ) - console.log("Output is", result) - if ( - result.outputs.filter((output) => output.acceptedRelays.length !== 0).length === 0 - ) { - console.log("inside errored message") + + // Mark failed messages + if (result.outputs.filter((o) => o.acceptedRelays.length > 0).length === 0) { textMessage.metadata = { errors: true } textMessage.id = result.rumor.id - - setMessages((prevChat) => { - let newChatMap = new Map(prevChat) - newChatMap.set(textMessage.id, textMessage) - return newChatMap - }) + setMessages((prev) => new Map(prev).set(textMessage.id, textMessage)) } - console.log("setting message with metadata", textMessage) } return ( - + - + {groupId .split(",") .filter((p) => p !== userPubkey) - .map((user) => { - return ( - profileMap?.get(user)?.name || - profileMap?.get(user)?.username || - profileMap?.get(user)?.lud16 || - nip19.npubEncode(user).slice(0, 9) + ".." - ) - }) + .map( + (p) => + profileMap.get(p)?.name || + profileMap.get(p)?.username || + profileMap.get(p)?.lud16 || + nip19.npubEncode(p).slice(0, 9) + "..", + ) .join(", ")} - + { - let ids = groupId.split(",") - let recipientId = ids.filter((id) => id !== userPubkey)[0] + const recipientId = groupId.split(",").filter((id) => id !== userPubkey)[0] navigation.navigate("sendBitcoinDestination", { - username: profileMap?.get(recipientId)?.lud16, + username: profileMap.get(recipientId)?.lud16, }) }} - key="lightning-button" /> {groupId .split(",") .filter((p) => p !== userPubkey) - .map((pubkey) => { - return ( - - ) - })} + .map((pubkey) => ( + + ))} + {!initialized && } - - + + + { - return b.createdAt! - a.createdAt! - })} - key="messages" - onPreviewDataFetched={() => {}} - onSendPress={handleSendPress} - l10nOverride={{ - emptyChatPlaceholder: initialized - ? isIos - ? "No messages here yet" - : "..." - : isIos - ? "Fetching Messages..." - : "...", - }} + messages={Array.from(messages.values()).sort( + (a, b) => b.createdAt! - a.createdAt!, + )} user={user} - renderBubble={({ child, message, nextMessageInGroup }) => { - return ( - - {child} - - ) - }} - renderTextMessage={(message, nextMessage, prevMessage) => ( + onSendPress={handleSendPress} + renderTextMessage={(msg, next, prev) => ( )} - flatListProps={{ - contentContainerStyle: { - paddingTop: messages.size ? (Platform.OS == "ios" ? 50 : 0) : 100, - }, - }} theme={{ ...defaultTheme, colors: { @@ -294,6 +235,20 @@ export const MessagesScreen: React.FC = ({ }, }, }} + l10nOverride={{ + emptyChatPlaceholder: initialized + ? isIos + ? "No messages here yet" + : "..." + : isIos + ? "Fetching Messages..." + : "...", + }} + flatListProps={{ + contentContainerStyle: { + paddingTop: messages.size ? (Platform.OS === "ios" ? 50 : 0) : 100, + }, + }} /> @@ -303,11 +258,7 @@ export const MessagesScreen: React.FC = ({ } const useStyles = makeStyles(({ colors }) => ({ - actionsContainer: { - margin: 12, - }, aliasView: { - display: "flex", flexDirection: "row", alignItems: "center", justifyContent: "space-between", @@ -316,15 +267,8 @@ const useStyles = makeStyles(({ colors }) => ({ paddingBottom: 6, paddingTop: isIos ? 40 : 10, }, - chatBodyContainer: { - flex: 1, - }, - chatView: { - flex: 1, - marginHorizontal: 30, - borderRadius: 24, - overflow: "hidden", - }, + chatBodyContainer: { flex: 1 }, + chatView: { flex: 1, marginHorizontal: 30, borderRadius: 24, overflow: "hidden" }, userPic: { borderRadius: 50, height: 50, @@ -332,8 +276,5 @@ const useStyles = makeStyles(({ colors }) => ({ borderWidth: 1, borderColor: colors.green, }, - backButton: { - fontSize: 26, - color: colors.primary3, - }, + backButton: { fontSize: 26, color: colors.primary3 }, })) diff --git a/app/screens/chat/searchListItem.tsx b/app/screens/chat/searchListItem.tsx index 01bf2c775..52fc4ee55 100644 --- a/app/screens/chat/searchListItem.tsx +++ b/app/screens/chat/searchListItem.tsx @@ -12,6 +12,7 @@ import Icon from "react-native-vector-icons/Ionicons" import { getContactsFromEvent } from "./utils" import { useState } from "react" import { ActivityIndicator } from "react-native" +import { pool } from "@app/utils/nostr/pool" interface SearchListItemProps { item: Chat @@ -21,7 +22,7 @@ export const SearchListItem: React.FC = ({ item, userPrivateKey, }) => { - const { poolRef, contactsEvent } = useChatContext() + const { contactsEvent } = useChatContext() const [isLoading, setIsLoading] = useState(false) const isUserAdded = () => { @@ -49,13 +50,13 @@ export const SearchListItem: React.FC = ({ } const handleAddContact = async () => { - if (isUserAdded() || !poolRef) return + if (isUserAdded()) return try { setIsLoading(true) await addToContactList( userPrivateKey, item.id, - poolRef.current, + pool, () => Promise.resolve(true), contactsEvent, ) From 5d851f69385cf098bce87add7c3dc67f1d633cae Mon Sep 17 00:00:00 2001 From: Abhay Date: Thu, 22 Jan 2026 18:38:41 +0530 Subject: [PATCH 3/4] remove console --- app/nostr/runtime/NostrRuntime.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/nostr/runtime/NostrRuntime.ts b/app/nostr/runtime/NostrRuntime.ts index 1ef7284ae..dd5e0fcea 100644 --- a/app/nostr/runtime/NostrRuntime.ts +++ b/app/nostr/runtime/NostrRuntime.ts @@ -39,7 +39,6 @@ export class NostrRuntime { onEose?: () => void, relays?: string[], ) { - console.log("Got relays ensureSubscription", relays, filters) return this.subscriptions.ensure( key, filters, From 20cced013abf7ff11bf5c88eee40afe81aaa18b1 Mon Sep 17 00:00:00 2001 From: Abhay Date: Thu, 22 Jan 2026 19:19:58 +0530 Subject: [PATCH 4/4] Fix Introductory posts screen --- app/screens/chat/contactDetailsScreen.tsx | 87 ++++++++++------------- 1 file changed, 36 insertions(+), 51 deletions(-) diff --git a/app/screens/chat/contactDetailsScreen.tsx b/app/screens/chat/contactDetailsScreen.tsx index 6d200a1a4..cdab6513a 100644 --- a/app/screens/chat/contactDetailsScreen.tsx +++ b/app/screens/chat/contactDetailsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from "react" +import React, { useState, useCallback, useEffect } from "react" import { View, Text, @@ -6,19 +6,12 @@ import { TouchableOpacity, ScrollView, ActivityIndicator, - Linking, } from "react-native" import { useTheme, makeStyles } from "@rneui/themed" -import { - RouteProp, - useFocusEffect, - useNavigation, - useRoute, -} from "@react-navigation/native" +import { RouteProp, useNavigation, useRoute } from "@react-navigation/native" import { StackNavigationProp } from "@react-navigation/stack" import { ChatStackParamList } from "@app/navigation/stack-param-lists" import Icon from "react-native-vector-icons/Ionicons" -import LinearGradient from "react-native-linear-gradient" import Clipboard from "@react-native-clipboard/clipboard" import { Screen } from "../../components/screen" @@ -58,10 +51,17 @@ const ContactDetailsScreen: React.FC = () => { const { contactPubkey, userPrivateKey } = route.params const profile = profileMap?.get(contactPubkey) const npub = nip19.npubEncode(contactPubkey) - + const postsKey = `posts:${contactPubkey}` // Posts, reposts, and profiles - const [posts, setPosts] = useState([]) - const [loadingPosts, setLoadingPosts] = useState(true) + const [posts, setPosts] = useState(() => { + return nostrRuntime + .getAllEvents() + .filter((e) => e.pubkey === contactPubkey && (e.kind === 1 || e.kind === 6)) + .sort((a, b) => b.created_at - a.created_at) + }) + + const [loadingPosts, setLoadingPosts] = useState(posts.length === 0) + const [repostedEvents, setRepostedEvents] = useState>(new Map()) const [repostedProfiles, setRepostedProfiles] = useState>(new Map()) @@ -116,67 +116,52 @@ const ContactDetailsScreen: React.FC = () => { // Fetch posts and reposts using nostrRuntime const fetchPosts = useCallback(() => { + // already have posts → don't refetch + + if (posts.length > 0) return + setLoadingPosts(true) + const fetchedEvents: Event[] = [] const repostMap = new Map() const profilesMap = new Map() - const subKey = `posts:${contactPubkey}` - nostrRuntime.ensureSubscription( - subKey, + postsKey, [{ kinds: [1, 6], authors: [contactPubkey], limit: 10 }], - (event: Event) => { - if (fetchedEvents.find((e) => e.id === event.id)) return + (event) => { fetchedEvents.push(event) if (event.kind === 6) { - const eTag = event.tags.find((tag) => tag[0] === "e") - const pTag = event.tags.find((tag) => tag[0] === "p") - - if (eTag && eTag[1]) { - try { - const repostedEvent = JSON.parse(event.content) - repostMap.set(event.id, repostedEvent) - - if (pTag && pTag[1]) { - nostrRuntime.ensureSubscription( - `profile:${pTag[1]}`, - [{ kinds: [0], authors: [pTag[1]], limit: 1 }], - (profileEvent) => { - const profileData = JSON.parse(profileEvent.content) - profilesMap.set(pTag[1], profileData) - setRepostedProfiles(new Map(profilesMap)) - }, - undefined, // EOSE optional - ) - } - } catch (err) { - console.error("Error parsing reposted event", err) - } + const pTag = event.tags.find((t) => t[0] === "p")?.[1] + if (pTag) { + nostrRuntime.ensureSubscription( + `profile:${pTag}`, + [{ kinds: [0], authors: [pTag], limit: 1 }], + (profileEvent) => { + profilesMap.set(pTag, JSON.parse(profileEvent.content)) + setRepostedProfiles(new Map(profilesMap)) + }, + ) } } }, () => { - // On EOSE fetchedEvents.sort((a, b) => b.created_at - a.created_at) setPosts(fetchedEvents) setRepostedEvents(repostMap) setLoadingPosts(false) }, ) + }, [contactPubkey, posts.length]) - // Safety timeout - setTimeout(() => { - setLoadingPosts(false) - }, 5000) - }, [contactPubkey]) + useEffect(() => { + fetchPosts() - useFocusEffect( - useCallback(() => { - fetchPosts() - }, [fetchPosts]), - ) + return () => { + nostrRuntime.releaseSubscription(postsKey) + } + }, [fetchPosts]) return (