From 2c71eaa9c083fcd43ebd191df918c71e3b211797 Mon Sep 17 00:00:00 2001 From: Abhay Date: Fri, 12 Jun 2026 15:30:17 +0530 Subject: [PATCH 01/17] Add dec screen for testing nip-29 groups --- app/app.tsx | 30 +- app/navigation/root-navigator.tsx | 6 + app/navigation/stack-param-lists.ts | 1 + .../chat/GroupChat/GroupChatProvider.tsx | 230 +++++++++- app/screens/chat/GroupChat/GroupInfoModal.tsx | 166 +++++++- app/screens/chat/GroupChat/Nip29DevScreen.tsx | 393 ++++++++++++++++++ .../chat/GroupChat/SupportGroupChat.tsx | 19 +- app/screens/chat/GroupChat/constants.ts | 2 + .../persistent-state/state-migrations.ts | 2 + 9 files changed, 825 insertions(+), 24 deletions(-) create mode 100644 app/screens/chat/GroupChat/Nip29DevScreen.tsx create mode 100644 app/screens/chat/GroupChat/constants.ts diff --git a/app/app.tsx b/app/app.tsx index 29f19fa21..9d3519cfa 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -46,6 +46,11 @@ 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 { + NIP29_DEFAULT_GROUP_ID, + NIP29_DEFAULT_RELAY_URL, +} from "./screens/chat/GroupChat/constants" +import { usePersistentStateContext } from "./store/persistent-state" import { PersistGate } from "redux-persist/integration/react" import { useEffect } from "react" import { nostrRuntime } from "./nostr/runtime/NostrRuntime" @@ -91,11 +96,7 @@ export const App = () => { - + @@ -125,7 +126,7 @@ export const App = () => { - + @@ -134,3 +135,20 @@ export const App = () => { ) } + +const Nip29GroupProviderWithOverride: React.FC = ({ + children, +}) => { + const { persistentState } = usePersistentStateContext() + const groupId = persistentState.nip29GroupIdOverride || NIP29_DEFAULT_GROUP_ID + const relayUrl = persistentState.nip29RelayUrlOverride || NIP29_DEFAULT_RELAY_URL + return ( + + {children} + + ) +} diff --git a/app/navigation/root-navigator.tsx b/app/navigation/root-navigator.tsx index 8d519a3d0..dbf978adb 100644 --- a/app/navigation/root-navigator.tsx +++ b/app/navigation/root-navigator.tsx @@ -116,6 +116,7 @@ import { import { NostrSettingsScreen } from "@app/screens/settings-screen/nostr-settings/nostr-settings-screen" import ContactDetailsScreen from "@app/screens/chat/contactDetailsScreen" import { SupportGroupChatScreen } from "@app/screens/chat/GroupChat/SupportGroupChat" +import { Nip29DevScreen } from "@app/screens/chat/GroupChat/Nip29DevScreen" import Contacts from "@app/screens/chat/contacts" import MakeNostrPost from "@app/screens/social/post" import PostSuccess from "@app/screens/social/post-success" @@ -615,6 +616,11 @@ export const RootStack = () => { component={SupportGroupChatScreen} options={{ headerShown: false }} /> + Promise removeMessage: (messageId: string) => Promise removeMember: (pubkey: string) => Promise + editMetadata: (metadata: GroupMetadataInput) => Promise + addAdmin: (pubkey: string) => Promise + createGroup: (metadata: GroupMetadataInput) => Promise } const NostrGroupChatContext = createContext(undefined) @@ -88,10 +101,22 @@ export const NostrGroupChatProvider: React.FC = ({ } }, [userPublicKey, knownMembers]) + // Reset all group-scoped state when the active group changes + useEffect(() => { + setMessagesMap(new Map()) + setDeletedIds(new Set()) + setKnownMembers(new Set()) + setAdminList([]) + setMetadata({}) + setIsMember(false) + setIsAdmin(false) + prevMembersRef.current = new Set() + }, [groupId, relayUrls.join("|")]) + // ----- Sub: group messages (kind 9) ----- useEffect(() => { nostrRuntime.ensureSubscription( - `nip29:messages`, + `nip29:messages:${groupId}`, { "#h": [groupId], "kinds": [9] }, (event: Event) => { const replyTag = event.tags.find( @@ -118,10 +143,12 @@ export const NostrGroupChatProvider: React.FC = ({ // ----- Sub: group metadata (kind 39000) ----- useEffect(() => { + console.log(`[nip29] subscribe metadata group=${groupId} relays=${relayUrls.join(",")}`) nostrRuntime.ensureSubscription( - `nip29:group_metadata`, + `nip29:group_metadata:${groupId}`, { "kinds": [39000], "#d": [groupId] }, (event: Event) => { + console.log(`[nip29] recv 39000 metadata`, { tags: event.tags }) const result: { name?: string; about?: string; picture?: string } = {} for (const [key, value] of event.tags) { if (key === "name") result.name = value @@ -141,12 +168,13 @@ export const NostrGroupChatProvider: React.FC = ({ if (adminPubkeys?.length) filters.authors = adminPubkeys nostrRuntime.ensureSubscription( - `nip29:membership`, + `nip29:membership:${groupId}`, filters, (event: any) => { const currentMembers: string[] = event.tags .filter((tag: string[]) => tag?.[0] === "p" && tag[1]) .map((tag: string[]) => tag[1]) + console.log(`[nip29] recv 39002 members count=${currentMembers.length} group=${groupId}`) const currentSet = new Set(currentMembers) @@ -186,12 +214,17 @@ export const NostrGroupChatProvider: React.FC = ({ // ----- Sub: admin list (kind 39001) ----- useEffect(() => { nostrRuntime.ensureSubscription( - `nip29:admins`, + `nip29:admins:${groupId}`, { "kinds": [39001], "#d": [groupId] }, (event: Event) => { const adminPubkeyList = event.tags .filter((t: string[]) => t[0] === "p") .map((t: string[]) => t[1]) + console.log( + `[nip29] recv 39001 admins count=${adminPubkeyList.length} group=${groupId} includesMe=${ + !!userPublicKey && adminPubkeyList.includes(userPublicKey) + }`, + ) setAdminList(adminPubkeyList) if (userPublicKey) setIsAdmin(adminPubkeyList.includes(userPublicKey)) }, @@ -203,7 +236,7 @@ export const NostrGroupChatProvider: React.FC = ({ // ----- Sub: deleted messages (kind 9005) ----- useEffect(() => { nostrRuntime.ensureSubscription( - `nip29:deletions`, + `nip29:deletions:${groupId}`, { "#h": [groupId], "kinds": [9005] }, (event: Event) => { const deletedId = event.tags.find((t: string[]) => t[0] === "e")?.[1] @@ -219,6 +252,45 @@ export const NostrGroupChatProvider: React.FC = ({ ) }, [groupId, relayUrls.join("|")]) + // Publish an event to the configured relays, responding to NIP-42 AUTH challenges. + // Surfaces errors via console so silent drops become visible during dev. + const publishEvent = useCallback( + async (event: any) => { + const signer = await getSigner() + const results = await Promise.allSettled( + pool.publish(relayUrls, event, { + onauth: async (template: any) => { + const signed = await signer.signEvent({ + ...template, + pubkey: userPublicKey, + } as any) + return signed as any + }, + }), + ) + const failures: string[] = [] + results.forEach((r, i) => { + if (r.status === "rejected") { + const msg = r.reason?.message || String(r.reason) + console.warn( + `[nip29] publish failed kind=${event.kind} relay=${relayUrls[i]} reason=${msg}`, + ) + failures.push(msg) + } else { + console.log(`[nip29] publish ok kind=${event.kind} relay=${relayUrls[i]}`) + } + }) + if (failures.length && failures.length === results.length) { + toastShow({ + type: "error", + message: `Publish kind ${event.kind} failed: ${failures[0]}`, + }) + } + return results + }, + [userPublicKey, relayUrls], + ) + // ----- Actions ----- const sendMessage = useCallback( async (text: string, replyToId?: string) => { @@ -233,9 +305,9 @@ export const NostrGroupChatProvider: React.FC = ({ content: text, pubkey: userPublicKey, } as any) - pool.publish(relayUrls, signedEvent) + await publishEvent(signedEvent) }, - [userPublicKey, groupId, relayUrls], + [userPublicKey, groupId, relayUrls, publishEvent], ) const removeMessage = useCallback( @@ -249,11 +321,11 @@ export const NostrGroupChatProvider: React.FC = ({ content: "", pubkey: userPublicKey, } as any) - pool.publish(relayUrls, signedEvent) + await publishEvent(signedEvent) // Optimistically remove locally setDeletedIds((prev) => new Set([...prev, messageId])) }, - [userPublicKey, groupId, relayUrls], + [userPublicKey, groupId, relayUrls, publishEvent], ) const removeMember = useCallback( @@ -267,14 +339,120 @@ export const NostrGroupChatProvider: React.FC = ({ content: "", pubkey: userPublicKey, } as any) - pool.publish(relayUrls, signedEvent) + await publishEvent(signedEvent) + }, + [userPublicKey, groupId, relayUrls, publishEvent], + ) + + const editMetadata = useCallback( + async (metadata: GroupMetadataInput) => { + if (!userPublicKey) throw Error("No user pubkey present") + const signer = await getSigner() + const tags: string[][] = [["h", groupId]] + if (metadata.name !== undefined) tags.push(["name", metadata.name]) + if (metadata.about !== undefined) tags.push(["about", metadata.about]) + if (metadata.picture !== undefined) tags.push(["picture", metadata.picture]) + const signedEvent = await signer.signEvent({ + kind: 9002, + created_at: Math.floor(Date.now() / 1000), + tags, + content: "", + pubkey: userPublicKey, + } as any) + await publishEvent(signedEvent) + }, + [userPublicKey, groupId, relayUrls, publishEvent], + ) + + const addAdmin = useCallback( + async (pubkey: string) => { + if (!userPublicKey) throw Error("No user pubkey present") + const signer = await getSigner() + const signedEvent = await signer.signEvent({ + kind: 9000, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["h", groupId], + ["p", pubkey, "admin"], + ], + content: "", + pubkey: userPublicKey, + } as any) + await publishEvent(signedEvent) }, - [userPublicKey, groupId, relayUrls], + [userPublicKey, groupId, relayUrls, publishEvent], + ) + + const createGroup = useCallback( + async (metadata: GroupMetadataInput): Promise => { + if (!userPublicKey) throw Error("No user pubkey present") + const signer = await getSigner() + const newGroupId = bytesToHex(generateSecretKey().slice(0, 8)) + + const createEvent = await signer.signEvent({ + kind: 9007, + created_at: Math.floor(Date.now() / 1000), + tags: [["h", newGroupId]], + content: "", + pubkey: userPublicKey, + } as any) + await publishEvent(createEvent) + + const metaTags: string[][] = [["h", newGroupId]] + if (metadata.name !== undefined) metaTags.push(["name", metadata.name]) + if (metadata.about !== undefined) metaTags.push(["about", metadata.about]) + if (metadata.picture !== undefined) metaTags.push(["picture", metadata.picture]) + if (metaTags.length > 1) { + const metaEvent = await signer.signEvent({ + kind: 9002, + created_at: Math.floor(Date.now() / 1000) + 1, + tags: metaTags, + content: "", + pubkey: userPublicKey, + } as any) + await publishEvent(metaEvent) + } + + // Add the creator as an admin member (relay only auto-adds to admins, not members) + const addSelfEvent = await signer.signEvent({ + kind: 9000, + created_at: Math.floor(Date.now() / 1000) + 2, + tags: [ + ["h", newGroupId], + ["p", userPublicKey, "admin"], + ], + content: "", + pubkey: userPublicKey, + } as any) + await publishEvent(addSelfEvent) + + return newGroupId + }, + [userPublicKey, relayUrls, publishEvent], ) const requestJoin = useCallback(async () => { if (!userPublicKey) throw Error("No user pubkey present") const signer = await getSigner() + console.log(`[nip29] requestJoin invoked group=${groupId} isAdmin=${isAdmin}`) + + // If we're already an admin, skip the join-request flow and self-add directly. + if (isAdmin) { + const addSelfEvent = await signer.signEvent({ + kind: 9000, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["h", groupId], + ["p", userPublicKey, "admin"], + ], + content: "", + pubkey: userPublicKey, + } as any) + console.log(`[nip29] self-add 9000`, addSelfEvent) + await publishEvent(addSelfEvent) + return + } + const signedEvent = await signer.signEvent({ kind: 9021, created_at: Math.floor(Date.now() / 1000), @@ -282,17 +460,20 @@ export const NostrGroupChatProvider: React.FC = ({ content: "I'd like to join this group.", pubkey: userPublicKey, } as any) - pool.publish(relayUrls, signedEvent) + console.log(`[nip29] join request 9021`, signedEvent) + await publishEvent(signedEvent) setMessagesMap((prev) => { const next = new Map(prev) next.set(`sys-join-req-${Date.now()}`, makeSystemMessage("Join request sent")) return next }) - }, [userPublicKey, groupId, relayUrls]) + }, [userPublicKey, groupId, relayUrls, isAdmin, publishEvent]) const value = useMemo( () => ({ + groupId, + relayUrls, messages, isMember, isAdmin, @@ -302,9 +483,28 @@ export const NostrGroupChatProvider: React.FC = ({ requestJoin, removeMessage, removeMember, + editMetadata, + addAdmin, + createGroup, groupMetadata: metadata, }), - [messages, isMember, isAdmin, adminList, knownMembers, sendMessage, requestJoin, removeMessage, removeMember, metadata], + [ + groupId, + relayUrls, + messages, + isMember, + isAdmin, + adminList, + knownMembers, + sendMessage, + requestJoin, + removeMessage, + removeMember, + editMetadata, + addAdmin, + createGroup, + metadata, + ], ) return ( diff --git a/app/screens/chat/GroupChat/GroupInfoModal.tsx b/app/screens/chat/GroupChat/GroupInfoModal.tsx index e6ea25211..1484860ec 100644 --- a/app/screens/chat/GroupChat/GroupInfoModal.tsx +++ b/app/screens/chat/GroupChat/GroupInfoModal.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useEffect, useState } from "react" import { Modal, View, @@ -6,11 +6,17 @@ import { ScrollView, TouchableOpacity, TouchableWithoutFeedback, + TextInput, + Alert, } from "react-native" -import { Text, makeStyles, useTheme } from "@rneui/themed" +import { Text, makeStyles, useTheme, Button } from "@rneui/themed" import Icon from "react-native-vector-icons/Ionicons" import { nip19 } from "nostr-tools" import { useSafeAreaInsets } from "react-native-safe-area-context" +import { useNavigation } from "@react-navigation/native" +import { StackNavigationProp } from "@react-navigation/stack" +import type { RootStackParamList } from "../../../navigation/stack-param-lists" +import { useNostrGroupChat } from "./GroupChatProvider" const DEFAULT_AVATAR = "https://pfp.nostr.build/520649f789e06c2a3912765c0081584951e91e3b5f3366d2ae08501162a5083b.jpg" @@ -37,6 +43,42 @@ export const GroupInfoModal: React.FC = ({ const styles = useStyles() const { theme: { colors } } = useTheme() const insets = useSafeAreaInsets() + const navigation = useNavigation>() + const { editMetadata } = useNostrGroupChat() + + const [editing, setEditing] = useState(false) + const [form, setForm] = useState({ + name: groupMetadata.name || "", + about: groupMetadata.about || "", + picture: groupMetadata.picture || "", + }) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!editing) { + setForm({ + name: groupMetadata.name || "", + about: groupMetadata.about || "", + picture: groupMetadata.picture || "", + }) + } + }, [groupMetadata.name, groupMetadata.about, groupMetadata.picture, editing]) + + const onSave = async () => { + try { + setSaving(true) + await editMetadata({ + name: form.name.trim(), + about: form.about.trim(), + picture: form.picture.trim(), + }) + setEditing(false) + } catch (e: any) { + Alert.alert("Failed to update", e?.message || String(e)) + } finally { + setSaving(false) + } + } const getDisplayName = (pubkey: string) => { const p = profileMap.get(pubkey) @@ -90,8 +132,81 @@ export const GroupInfoModal: React.FC = ({ You are an admin )} + {isAdmin && !editing && ( + setEditing(true)} + > + + Edit metadata + + )} + {editing && ( + + Edit metadata + Name + setForm((f) => ({ ...f, name }))} + /> + About + setForm((f) => ({ ...f, about }))} + /> + Picture URL + setForm((f) => ({ ...f, picture }))} + /> + +