diff --git a/app/components/chat-message/chat-message.tsx b/app/components/chat-message/chat-message.tsx index ad9e198b0..f2ed8df34 100644 --- a/app/components/chat-message/chat-message.tsx +++ b/app/components/chat-message/chat-message.tsx @@ -4,11 +4,10 @@ import "react-native-get-random-values" import React, { useEffect, useRef } from "react" import { View, Text } from "react-native" import { makeStyles } from "@rneui/themed" -import { MessageType } from "@flyerhq/react-native-chat-ui" type Props = { recipientId: `npub1${string}` - message: MessageType.Text + message: { id: string; author: { id: string }; text: string } nextMessage: number prevMessage: boolean } diff --git a/app/navigation/root-navigator.tsx b/app/navigation/root-navigator.tsx index 27fd4238f..7f395624b 100644 --- a/app/navigation/root-navigator.tsx +++ b/app/navigation/root-navigator.tsx @@ -1,5 +1,6 @@ import { createBottomTabNavigator } from "@react-navigation/bottom-tabs" import { CardStyleInterpolators, createStackNavigator } from "@react-navigation/stack" +import { getFocusedRouteNameFromRoute } from "@react-navigation/native" import * as React from "react" import { @@ -596,7 +597,7 @@ export const RootStack = () => { ) @@ -720,16 +721,20 @@ export const PrimaryNavigator = () => { ({ headerShown: false, title: LL.ChatScreen.title(), + tabBarStyle: + getFocusedRouteNameFromRoute(route) === "messages" + ? { display: "none" } + : styles.bottomNavigatorStyle, tabBarIcon: ({ color }) => ( ), - }} + })} /> ) : null} - /** - * Send a plain text message to the group - */ - sendMessage: (text: string) => Promise - /** - * Request to join the group (NIP-29 join event) - */ + sendMessage: (text: string, replyToId?: string) => Promise requestJoin: () => Promise + removeMessage: (messageId: string) => Promise + removeMember: (pubkey: string) => Promise } const NostrGroupChatContext = createContext(undefined) // ===== Helpers ===== -const makeSystemText = (text: string): MessageType.Text => ({ +const makeSystemMessage = (text: string): GroupMessage => ({ id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - author: { id: "system" }, + authorId: "system", createdAt: Date.now(), - type: "text", text, + isSystem: true, }) export const NostrGroupChatProvider: React.FC = ({ @@ -60,24 +63,25 @@ export const NostrGroupChatProvider: React.FC = ({ }) => { const { userPublicKey } = useChatContext() - // Internal message map ensures dedupe by id - const [messagesMap, setMessagesMap] = useState>(new Map()) + const [messagesMap, setMessagesMap] = useState>(new Map()) + const [deletedIds, setDeletedIds] = useState>(new Set()) const [isMember, setIsMember] = useState(false) + const [isAdmin, setIsAdmin] = useState(false) + const [adminList, setAdminList] = useState([]) const [knownMembers, setKnownMembers] = useState>(new Set()) const [metadata, setMetadata] = useState<{ name?: string about?: string picture?: string }>({}) - // Track last known membership set to detect new joins for system messages const prevMembersRef = useRef>(new Set()) - // Sorted messages array for the UI (newest first) const messages = useMemo(() => { - return Array.from(messagesMap.values()).sort((a, b) => b.createdAt! - a.createdAt!) - }, [messagesMap]) + return Array.from(messagesMap.values()) + .filter((m) => !deletedIds.has(m.id)) + .sort((a, b) => b.createdAt - a.createdAt) + }, [messagesMap, deletedIds]) - // Sync isMember when userPublicKey becomes available after membership data was already received useEffect(() => { if (userPublicKey && knownMembers.size > 0) { setIsMember(knownMembers.has(userPublicKey)) @@ -88,18 +92,17 @@ export const NostrGroupChatProvider: React.FC = ({ useEffect(() => { nostrRuntime.ensureSubscription( `nip29:messages`, - - { - "#h": [groupId], - "kinds": [9], - }, + { "#h": [groupId], "kinds": [9] }, (event: Event) => { - const msg: MessageType.Text = { + const replyTag = event.tags.find( + (t: string[]) => t[0] === "e" && t[3] === "reply", + ) + const msg: GroupMessage = { id: event.id, - author: { id: event.pubkey }, + authorId: event.pubkey, createdAt: event.created_at * 1000, - type: "text", text: event.content, + replyToId: replyTag?.[1], } setMessagesMap((prev) => { if (prev.has(msg.id)) return prev @@ -113,99 +116,66 @@ export const NostrGroupChatProvider: React.FC = ({ ) }, [relayUrls.join("|"), groupId]) - //metadata + // ----- Sub: group metadata (kind 39000) ----- useEffect(() => { - function parseGroupTags(tags: string[][]) { - const result: { name?: string; about?: string; picture?: string } = {} - for (const tag of tags) { - const [key, value] = tag - if (key === "name") result.name = value - else if (key === "about") result.about = value - else if (key === "picture") result.picture = value - } - return result - } - const unsub = nostrRuntime.ensureSubscription( + nostrRuntime.ensureSubscription( `nip29:group_metadata`, { "kinds": [39000], "#d": [groupId] }, - (event) => { - console.log("==============GOT METADATA EVENT=============") - const parsed = parseGroupTags(event.tags) - setMetadata(parsed) - }, - () => { - console.log("EOSE TRIGGERED FOR ", 39000) + (event: Event) => { + const result: { name?: string; about?: string; picture?: string } = {} + for (const [key, value] of event.tags) { + if (key === "name") result.name = value + else if (key === "about") result.about = value + else if (key === "picture") result.picture = value + } + setMetadata(result) }, + () => {}, relayUrls, ) }, [groupId]) // ----- Sub: membership roster (kind 39002) ----- useEffect(() => { - const filters: any = { - "kinds": [39002], - "#d": [groupId], - } + const filters: any = { "kinds": [39002], "#d": [groupId] } if (adminPubkeys?.length) filters.authors = adminPubkeys - const unsub = nostrRuntime.ensureSubscription( + nostrRuntime.ensureSubscription( `nip29:membership`, filters, (event: any) => { - // Extract all `p` tags as pubkeys - console.log("==============GOT MEMBERSHIP EVENT=============") const currentMembers: string[] = event.tags .filter((tag: string[]) => tag?.[0] === "p" && tag[1]) .map((tag: string[]) => tag[1]) - // Convert to Set for easy diff const currentSet = new Set(currentMembers) - // Notify self if just joined (transition only – for the system message) if ( userPublicKey && !prevMembersRef.current.has(userPublicKey) && currentSet.has(userPublicKey) ) { - setMessagesMap((prevMap) => { - const next = new Map(prevMap) - next.set( - `sys-joined-self-${Date.now()}`, - makeSystemText("You joined the group"), - ) + setMessagesMap((prev) => { + const next = new Map(prev) + next.set(`sys-joined-self-${Date.now()}`, makeSystemMessage("You joined the group")) return next }) } - // Always sync isMember with the current roster - if (userPublicKey) { - setIsMember(currentSet.has(userPublicKey)) - } + if (userPublicKey) setIsMember(currentSet.has(userPublicKey)) - // Notify other new members - // Track new members currentMembers.forEach((pk) => { - if ( - pk !== userPublicKey && - !prevMembersRef.current.has(pk) && - prevMembersRef.current.size !== 0 - ) { + if (pk !== userPublicKey && !prevMembersRef.current.has(pk) && prevMembersRef.current.size !== 0) { const short = pk.slice(0, 6) + "…" + pk.slice(-4) - setMessagesMap((prevMap) => { - const next = new Map(prevMap) - next.set( - `sys-joined-${pk}-${Date.now()}`, - makeSystemText(`${short} joined the group`), - ) + setMessagesMap((prev) => { + const next = new Map(prev) + next.set(`sys-joined-${pk}-${Date.now()}`, makeSystemMessage(`${short} joined the group`)) return next }) } }) - // Update prevMembersRef to exactly the current members prevMembersRef.current = currentSet - - // Update knownMembers state setKnownMembers(currentSet) }, () => {}, @@ -213,22 +183,90 @@ export const NostrGroupChatProvider: React.FC = ({ ) }, [relayUrls.join("|"), groupId, userPublicKey, adminPubkeys?.join("|")]) + // ----- Sub: admin list (kind 39001) ----- + useEffect(() => { + nostrRuntime.ensureSubscription( + `nip29:admins`, + { "kinds": [39001], "#d": [groupId] }, + (event: Event) => { + const adminPubkeyList = event.tags + .filter((t: string[]) => t[0] === "p") + .map((t: string[]) => t[1]) + setAdminList(adminPubkeyList) + if (userPublicKey) setIsAdmin(adminPubkeyList.includes(userPublicKey)) + }, + () => {}, + relayUrls, + ) + }, [groupId, relayUrls.join("|"), userPublicKey]) + + // ----- Sub: deleted messages (kind 9005) ----- + useEffect(() => { + nostrRuntime.ensureSubscription( + `nip29:deletions`, + { "#h": [groupId], "kinds": [9005] }, + (event: Event) => { + const deletedId = event.tags.find((t: string[]) => t[0] === "e")?.[1] + if (deletedId) { + setDeletedIds((prev) => { + if (prev.has(deletedId)) return prev + return new Set([...prev, deletedId]) + }) + } + }, + () => {}, + relayUrls, + ) + }, [groupId, relayUrls.join("|")]) + // ----- Actions ----- const sendMessage = useCallback( - async (text: string) => { + async (text: string, replyToId?: string) => { if (!userPublicKey) throw Error("No user pubkey present") - const signer = await getSigner() - - const nostrEvent = { + const tags: string[][] = [["h", groupId, relayUrls[0]]] + if (replyToId) tags.push(["e", replyToId, relayUrls[0], "reply"]) + const signedEvent = await signer.signEvent({ kind: 9, created_at: Math.floor(Date.now() / 1000), - tags: [["h", groupId, relayUrls[0]]], // include relay hint + tags, content: text, pubkey: userPublicKey, - } + } as any) + pool.publish(relayUrls, signedEvent) + }, + [userPublicKey, groupId, relayUrls], + ) - const signedEvent = await signer.signEvent(nostrEvent as any) + const removeMessage = useCallback( + async (messageId: string) => { + if (!userPublicKey) return + const signer = await getSigner() + const signedEvent = await signer.signEvent({ + kind: 9005, + created_at: Math.floor(Date.now() / 1000), + tags: [["h", groupId], ["e", messageId]], + content: "", + pubkey: userPublicKey, + } as any) + pool.publish(relayUrls, signedEvent) + // Optimistically remove locally + setDeletedIds((prev) => new Set([...prev, messageId])) + }, + [userPublicKey, groupId, relayUrls], + ) + + const removeMember = useCallback( + async (pubkey: string) => { + if (!userPublicKey) return + const signer = await getSigner() + const signedEvent = await signer.signEvent({ + kind: 9001, + created_at: Math.floor(Date.now() / 1000), + tags: [["h", groupId], ["p", pubkey]], + content: "", + pubkey: userPublicKey, + } as any) pool.publish(relayUrls, signedEvent) }, [userPublicKey, groupId, relayUrls], @@ -236,24 +274,19 @@ export const NostrGroupChatProvider: React.FC = ({ const requestJoin = useCallback(async () => { if (!userPublicKey) throw Error("No user pubkey present") - const signer = await getSigner() - - const joinEvent = { + const signedEvent = await signer.signEvent({ kind: 9021, created_at: Math.floor(Date.now() / 1000), tags: [["h", groupId]], content: "I'd like to join this group.", pubkey: userPublicKey, - } - - const signedJoinEvent = await signer.signEvent(joinEvent as any) - pool.publish(relayUrls, signedJoinEvent) + } as any) + pool.publish(relayUrls, signedEvent) - // Optimistic system note setMessagesMap((prev) => { const next = new Map(prev) - next.set(`sys-join-req-${Date.now()}`, makeSystemText("Join request sent")) + next.set(`sys-join-req-${Date.now()}`, makeSystemMessage("Join request sent")) return next }) }, [userPublicKey, groupId, relayUrls]) @@ -262,24 +295,25 @@ export const NostrGroupChatProvider: React.FC = ({ () => ({ messages, isMember, + isAdmin, + adminList, knownMembers, sendMessage, requestJoin, + removeMessage, + removeMember, groupMetadata: metadata, }), - [messages, isMember, knownMembers, sendMessage, requestJoin], + [messages, isMember, isAdmin, adminList, knownMembers, sendMessage, requestJoin, removeMessage, removeMember, metadata], ) return ( - - {children} - + {children} ) } export const useNostrGroupChat = () => { const ctx = useContext(NostrGroupChatContext) - if (!ctx) - throw new Error("useNostrGroupChat must be used inside NostrGroupChatProvider") + if (!ctx) throw new Error("useNostrGroupChat must be used inside NostrGroupChatProvider") return ctx } diff --git a/app/screens/chat/GroupChat/GroupInfoModal.tsx b/app/screens/chat/GroupChat/GroupInfoModal.tsx new file mode 100644 index 000000000..e6ea25211 --- /dev/null +++ b/app/screens/chat/GroupChat/GroupInfoModal.tsx @@ -0,0 +1,255 @@ +import React from "react" +import { + Modal, + View, + Image, + ScrollView, + TouchableOpacity, + TouchableWithoutFeedback, +} from "react-native" +import { Text, makeStyles, useTheme } from "@rneui/themed" +import Icon from "react-native-vector-icons/Ionicons" +import { nip19 } from "nostr-tools" +import { useSafeAreaInsets } from "react-native-safe-area-context" + +const DEFAULT_AVATAR = + "https://pfp.nostr.build/520649f789e06c2a3912765c0081584951e91e3b5f3366d2ae08501162a5083b.jpg" + +type Props = { + visible: boolean + onClose: () => void + groupMetadata: { name?: string; about?: string; picture?: string } + adminList: string[] + memberCount: number + profileMap: Map + isAdmin: boolean +} + +export const GroupInfoModal: React.FC = ({ + visible, + onClose, + groupMetadata, + adminList, + memberCount, + profileMap, + isAdmin, +}) => { + const styles = useStyles() + const { theme: { colors } } = useTheme() + const insets = useSafeAreaInsets() + + const getDisplayName = (pubkey: string) => { + const p = profileMap.get(pubkey) + return p?.name || p?.username || nip19.npubEncode(pubkey).slice(0, 10) + "…" + } + + return ( + + + + + + + {/* Handle bar */} + + + {/* Close button */} + + + + + + {/* Group avatar + name */} + + + {groupMetadata.name || "Support Group"} + {groupMetadata.about ? ( + {groupMetadata.about} + ) : null} + + + + {memberCount} {memberCount === 1 ? "member" : "members"} + + + {isAdmin && ( + + + You are an admin + + )} + + + {/* Admins section */} + {adminList.length > 0 && ( + + Admins + {adminList.map((pubkey) => { + const profile = profileMap.get(pubkey) + return ( + + + + {getDisplayName(pubkey)} + {profile?.nip05 && ( + {profile.nip05} + )} + + + + Admin + + + ) + })} + + )} + + + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + backdrop: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.4)", + }, + sheet: { + backgroundColor: colors.grey5, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingTop: 12, + paddingHorizontal: 20, + maxHeight: "75%", + }, + handle: { + width: 40, + height: 4, + borderRadius: 2, + backgroundColor: colors.grey4, + alignSelf: "center", + marginBottom: 12, + }, + closeBtn: { + position: "absolute", + top: 16, + right: 20, + zIndex: 1, + }, + hero: { + alignItems: "center", + paddingTop: 8, + paddingBottom: 20, + borderBottomWidth: 0.5, + borderBottomColor: colors.grey4, + marginBottom: 16, + }, + avatar: { + width: 80, + height: 80, + borderRadius: 40, + borderWidth: 2, + marginBottom: 12, + }, + groupName: { + fontSize: 20, + fontWeight: "700", + color: colors.primary3, + textAlign: "center", + }, + groupAbout: { + fontSize: 14, + color: colors.grey2, + textAlign: "center", + marginTop: 6, + lineHeight: 20, + }, + memberBadge: { + flexDirection: "row", + alignItems: "center", + gap: 4, + marginTop: 10, + }, + memberCount: { + fontSize: 13, + fontWeight: "500", + }, + adminBadge: { + flexDirection: "row", + alignItems: "center", + gap: 4, + marginTop: 8, + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + }, + adminBadgeText: { + fontSize: 12, + fontWeight: "600", + }, + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 12, + fontWeight: "600", + color: colors.grey2, + textTransform: "uppercase", + letterSpacing: 0.8, + marginBottom: 10, + }, + memberRow: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 8, + borderBottomWidth: 0.5, + borderBottomColor: colors.grey4, + }, + memberAvatar: { + width: 40, + height: 40, + borderRadius: 20, + marginRight: 12, + }, + memberInfo: { + flex: 1, + }, + memberName: { + fontSize: 15, + fontWeight: "500", + color: colors.primary3, + }, + memberNip05: { + fontSize: 12, + color: colors.grey2, + marginTop: 1, + }, + adminTag: { + flexDirection: "row", + alignItems: "center", + gap: 3, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 10, + }, + adminTagText: { + fontSize: 11, + fontWeight: "600", + }, +})) diff --git a/app/screens/chat/GroupChat/SupportGroupChat.tsx b/app/screens/chat/GroupChat/SupportGroupChat.tsx index cab847784..f81c59830 100644 --- a/app/screens/chat/GroupChat/SupportGroupChat.tsx +++ b/app/screens/chat/GroupChat/SupportGroupChat.tsx @@ -1,126 +1,304 @@ -import React from "react" -import { View, Platform } from "react-native" -import { useTheme, makeStyles, Button, Text } from "@rneui/themed" +import React, { useEffect, useRef, useState } from "react" +import { Alert, View, Image, TouchableOpacity, Platform } from "react-native" +import { makeStyles, useTheme, Text, Button } from "@rneui/themed" import { Screen } from "../../../components/screen" -import { Chat, MessageType, defaultTheme } from "@flyerhq/react-native-chat-ui" -import { SafeAreaProvider } from "react-native-safe-area-context" -import { ChatMessage } from "../chatMessage" +import { FlatList } from "react-native-gesture-handler" +import { useSafeAreaInsets } from "react-native-safe-area-context" +import { useNavigation } from "@react-navigation/native" +import { StackNavigationProp } from "@react-navigation/stack" +import Icon from "react-native-vector-icons/Ionicons" import type { StackScreenProps } from "@react-navigation/stack" import type { RootStackParamList } from "../../../navigation/stack-param-lists" -import { useNostrGroupChat } from "./GroupChatProvider" +import { useNostrGroupChat, GroupMessage } from "./GroupChatProvider" import { useChatContext } from "../chatContext" +import { nostrRuntime } from "@app/nostr/runtime/NostrRuntime" +import { MessageInput } from "../components/MessageInput" +import { MessageBubble } from "../components/MessageBubble" +import { GroupInfoModal } from "./GroupInfoModal" +import { Rumor } from "@app/utils/nostr" type SupportGroupChatScreenProps = StackScreenProps +const DEFAULT_AVATAR = + "https://pfp.nostr.build/520649f789e06c2a3912765c0081584951e91e3b5f3366d2ae08501162a5083b.jpg" + +function readProfilesFromStore(pubkeys: string[]): Map { + const map = new Map() + for (const pubkey of pubkeys) { + const event = nostrRuntime.getEventStore().getLatest(`0:${pubkey}`) + if (event) { + try { + map.set(pubkey, JSON.parse(event.content)) + } catch {} + } + } + return map +} + +// Convert a GroupMessage to a Rumor-compatible shape for MessageBubble +function toRumor(msg: GroupMessage): Rumor { + const tags: string[][] = [] + if (msg.replyToId) tags.push(["e", msg.replyToId, "", "reply"]) + return { + id: msg.id, + pubkey: msg.authorId, + content: msg.text, + created_at: Math.floor(msg.createdAt / 1000), + kind: 9, + tags, + } +} + const InnerGroupChat: React.FC = () => { const styles = useStyles() - const { - theme: { colors }, - } = useTheme() - - const { messages, isMember, sendMessage, requestJoin } = useNostrGroupChat() + const { theme: { colors, mode } } = useTheme() + const insets = useSafeAreaInsets() + const navigation = useNavigation>() + const { messages, isMember, isAdmin, adminList, knownMembers, sendMessage, requestJoin, removeMessage, removeMember, groupMetadata } = useNostrGroupChat() const { userPublicKey } = useChatContext() + const [replyTo, setReplyTo] = useState(null) + const [infoVisible, setInfoVisible] = useState(false) - const renderTextMessage = ( - message: MessageType.Text, - showName: number, - nextMessage: boolean, - ) => { - if (message.author.id === "system") { - return ( - - - - {message.text} - - - - ) - } + // Profile loading via EventStore (same pattern as messages.tsx) + const subscribedPubkeys = useRef>(new Set()) + const [profileMap, setProfileMap] = useState>(new Map()) - return ( - + useEffect(() => { + const unsubStore = nostrRuntime.getEventStore().subscribe(() => { + const pubkeys = Array.from(subscribedPubkeys.current) + setProfileMap(readProfilesFromStore(pubkeys)) + }) + return unsubStore + }, []) + + // Subscribe to profiles for any new authors we haven't seen yet + useEffect(() => { + const newPubkeys = messages + .filter((m) => !m.isSystem && !subscribedPubkeys.current.has(m.authorId)) + .map((m) => m.authorId) + .filter((id, i, arr) => arr.indexOf(id) === i) + + if (newPubkeys.length === 0) return + + newPubkeys.forEach((pk) => subscribedPubkeys.current.add(pk)) + + nostrRuntime.ensureSubscription( + `groupProfiles:${[...subscribedPubkeys.current].sort().join(",")}`, + { kinds: [0], authors: [...subscribedPubkeys.current] }, ) + + setProfileMap(readProfilesFromStore([...subscribedPubkeys.current])) + }, [messages]) + + const chatBg = mode === "dark" ? "#0e1a16" : "#eef5f2" + + const handleAdminPress = (msg: GroupMessage) => { + const isOwnMessage = msg.authorId === userPublicKey + const options: { text: string; onPress: () => void; style?: "destructive" | "cancel" }[] = [] + + options.push({ + text: "Delete Message", + style: "destructive", + onPress: () => removeMessage(msg.id), + }) + + if (!isOwnMessage) { + options.push({ + text: "Remove User", + style: "destructive", + onPress: () => { + Alert.alert("Remove User", "Remove this user from the group?", [ + { text: "Cancel", style: "cancel" }, + { text: "Remove", style: "destructive", onPress: () => removeMember(msg.authorId) }, + ]) + }, + }) + } + + options.push({ text: "Cancel", style: "cancel", onPress: () => {} }) + + Alert.alert("Admin Actions", undefined, options) + } + + // Build a map of rumor-shaped messages for parent lookup + const rumorMap = new Map(messages.filter((m) => !m.isSystem).map((m) => [m.id, toRumor(m)])) + + const getParentRumor = (rumor: Rumor): Rumor | null => { + const replyTag = rumor.tags.find((t) => t[0] === "e" && t[3] === "reply") + if (!replyTag) return null + return rumorMap.get(replyTag[1]) || null } return ( - - - sendMessage(partial.text)} - user={{ id: userPublicKey || "me" }} - renderTextMessage={(message, showName, nextMessage) => - renderTextMessage(message, showName, nextMessage) - } - customBottomComponent={ - !isMember - ? () => ( - -