From 927e1fe7dcf2a315e24a6020a3260258fe99fa85 Mon Sep 17 00:00:00 2001 From: Abhay Date: Thu, 26 Feb 2026 18:54:48 +0530 Subject: [PATCH 1/8] NIP17 Chat is prettified --- app/screens/chat/NIP17Chat.tsx | 2 +- app/screens/chat/chatContext.tsx | 42 +- app/screens/chat/components/MessageBubble.tsx | 330 +++++++++++++ app/screens/chat/components/MessageInput.tsx | 148 ++++++ .../chat/components/ReactionPicker.tsx | 73 +++ app/screens/chat/historyListItem.tsx | 84 ++-- app/screens/chat/messages.tsx | 434 +++++++++--------- app/screens/chat/style.ts | 36 +- app/utils/nostr.ts | 37 ++ 9 files changed, 927 insertions(+), 259 deletions(-) create mode 100644 app/screens/chat/components/MessageBubble.tsx create mode 100644 app/screens/chat/components/MessageInput.tsx create mode 100644 app/screens/chat/components/ReactionPicker.tsx diff --git a/app/screens/chat/NIP17Chat.tsx b/app/screens/chat/NIP17Chat.tsx index 81612997b..276ac0f61 100644 --- a/app/screens/chat/NIP17Chat.tsx +++ b/app/screens/chat/NIP17Chat.tsx @@ -240,7 +240,7 @@ export const NIP17Chat: React.FC = () => { groupId: "support-group-id", }) } - style={{ marginRight: 20, marginLeft: 20, marginBottom: 4 }} + style={styles.item} > diff --git a/app/screens/chat/chatContext.tsx b/app/screens/chat/chatContext.tsx index e2f4d1e29..ac01adbdf 100644 --- a/app/screens/chat/chatContext.tsx +++ b/app/screens/chat/chatContext.tsx @@ -17,11 +17,14 @@ import { loadJson, saveJson } from "@app/utils/storage" const contactsEventCacheKey = (pubkey: string) => `contacts_event:${pubkey}` const profileEventCacheKey = (pubkey: string) => `user_profile_event:${pubkey}` +export type ReactionEntry = { emoji: string; reactor: string } + type ChatContextType = { giftwraps: Event[] rumors: Rumor[] setGiftWraps: React.Dispatch> setRumors: React.Dispatch> + reactions: Map profileMap: Map addEventToProfiles: (event: Event) => void resetChat: () => Promise @@ -39,6 +42,7 @@ const ChatContext = createContext({ setGiftWraps: () => {}, rumors: [], setRumors: () => {}, + reactions: new Map(), profileMap: new Map(), addEventToProfiles: () => {}, resetChat: async () => {}, @@ -56,6 +60,7 @@ export const useChatContext = () => useContext(ChatContext) export const ChatContextProvider: React.FC = ({ children }) => { const [giftwraps, setGiftWraps] = useState([]) const [rumors, setRumors] = useState([]) + const [reactions, setReactions] = useState>(new Map()) const [userProfileEvent, setUserProfileEvent] = useState(null) const [userPublicKey, setUserPublicKey] = useState(null) const [contactsEvent, setContactsEvent] = useState() @@ -84,12 +89,28 @@ export const ChatContextProvider: React.FC = ({ children }) = try { const signer = await getSigner() const rumor = await getRumorFromWrap(event, signer) - setRumors((prev) => { - if (!prev.map((r) => r.id).includes(rumor.id)) { - return [...prev, rumor] + if (rumor.kind === 7) { + // NIP-25 reaction + const originalId = rumor.tags.find((t) => t[0] === "e")?.[1] + if (originalId) { + setReactions((prev) => { + const existing = prev.get(originalId) || [] + if (existing.some((r) => r.reactor === rumor.pubkey && r.emoji === rumor.content)) { + return prev + } + const next = new Map(prev) + next.set(originalId, [...existing, { emoji: rumor.content, reactor: rumor.pubkey }]) + return next + }) } - return prev - }) + } else { + setRumors((prev) => { + if (!prev.map((r) => r.id).includes(rumor.id)) { + return [...prev, rumor] + } + return prev + }) + } } catch (e) { console.log("Failed to decrypt giftwrap", e) } @@ -132,7 +153,14 @@ export const ChatContextProvider: React.FC = ({ children }) = }), ) ).filter((r): r is Rumor => r !== null) - setRumors(decryptedRumors) + // Deduplicate by ID โ€” multiple gift wraps can decrypt to the same rumor (sender + recipient copies) + const seenIds = new Set() + const uniqueRumors = decryptedRumors.filter((r) => { + if (seenIds.has(r.id)) return false + seenIds.add(r.id) + return true + }) + setRumors(uniqueRumors) // ------------------------ // Subscribe to giftwraps via NostrRuntime @@ -233,6 +261,7 @@ export const ChatContextProvider: React.FC = ({ children }) = setGiftWraps([]) setRumors([]) setContactsEvent(undefined) + setReactions(new Map()) setUserProfileEvent(null) setUserPublicKey(null) processedEventIds.current.clear() @@ -252,6 +281,7 @@ export const ChatContextProvider: React.FC = ({ children }) = setGiftWraps, rumors, setRumors, + reactions, profileMap: profileMap.current, addEventToProfiles: (event: Event) => { try { diff --git a/app/screens/chat/components/MessageBubble.tsx b/app/screens/chat/components/MessageBubble.tsx new file mode 100644 index 000000000..25d0e865d --- /dev/null +++ b/app/screens/chat/components/MessageBubble.tsx @@ -0,0 +1,330 @@ +import React, { useRef, useState } from "react" +import { + View, + Text, + Image, + TouchableOpacity, + GestureResponderEvent, +} from "react-native" +import { Swipeable } from "react-native-gesture-handler" +import Icon from "react-native-vector-icons/Ionicons" +import { nip19 } from "nostr-tools" +import { Rumor } from "@app/utils/nostr" +import { ReactionPicker } from "./ReactionPicker" +import type { ReactionEntry } from "../chatContext" +import { makeStyles, useTheme } from "@rneui/themed" +import { GaloyIcon } from "@app/components/atomic/galoy-icon" + +type Props = { + rumor: Rumor + isMe: boolean + profileMap: Map + reactions: ReactionEntry[] + parentRumor: Rumor | null + onReply: (rumor: Rumor) => void + onReact: (emoji: string) => void + isGroupChat?: boolean +} + +const formatTime = (created_at: number) => + new Date(created_at * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + +const MAX_AVATARS = 3 + +function groupReactions( + reactions: ReactionEntry[], +): { emoji: string; count: number; reactors: string[] }[] { + const map = new Map() + for (const r of reactions) { + const existing = map.get(r.emoji) || [] + if (!existing.includes(r.reactor)) existing.push(r.reactor) + map.set(r.emoji, existing) + } + return Array.from(map.entries()).map(([emoji, reactors]) => ({ + emoji, + count: reactors.length, + reactors, + })) +} + +export const MessageBubble: React.FC = ({ + rumor, + isMe, + profileMap, + reactions, + parentRumor, + onReply, + onReact, + isGroupChat = false, +}) => { + const { theme: { colors } } = useTheme() + const styles = useStyles() + const [pickerVisible, setPickerVisible] = useState(false) + const [pickerPosition, setPickerPosition] = useState({ x: 0, y: 0 }) + const swipeableRef = useRef(null) + + const senderProfile = profileMap.get(rumor.pubkey) + const senderName = + senderProfile?.name || + senderProfile?.username || + senderProfile?.nip05 || + nip19.npubEncode(rumor.pubkey).slice(0, 10) + + const replyTag = rumor.tags.find((t) => t[0] === "e" && t[3] === "reply") + const groupedReactions = groupReactions(reactions) + + const handleLongPress = (e: GestureResponderEvent) => { + setPickerPosition({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }) + setPickerVisible(true) + } + + const renderRightActions = () => ( + + + + ) + + const renderLeftActions = () => ( + + + + ) + + return ( + <> + { + onReply(rumor) + swipeableRef.current?.close() + }} + friction={2} + rightThreshold={40} + leftThreshold={40} + > + + {/* Avatar for received messages in group chats */} + {!isMe && isGroupChat && ( + + )} + + + {/* Sender name in group chats */} + {!isMe && isGroupChat && ( + {senderName} + )} + + {/* Quoted reply preview */} + {replyTag && parentRumor && ( + + + {profileMap.get(parentRumor.pubkey)?.name || + nip19.npubEncode(parentRumor.pubkey).slice(0, 10)} + + + {parentRumor.content} + + + )} + + + + + {(rumor as any).metadata?.errors && ( + + )} + + {rumor.content} + + + + {formatTime(rumor.created_at)} + + + + + {/* Reaction pills */} + {groupedReactions.length > 0 && ( + + {groupedReactions.map(({ emoji, count, reactors }) => ( + + {emoji} + {/* Reactor avatars (up to MAX_AVATARS) */} + + {reactors.slice(0, MAX_AVATARS).map((pubkey, i) => ( + + ))} + + {count > MAX_AVATARS && ( + +{count - MAX_AVATARS} + )} + + ))} + + )} + + + + + setPickerVisible(false)} + position={pickerPosition} + /> + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + row: { + flexDirection: "row", + marginVertical: 2, + paddingHorizontal: 12, + }, + rowSent: { + justifyContent: "flex-end", + }, + rowReceived: { + justifyContent: "flex-start", + }, + avatar: { + width: 32, + height: 32, + borderRadius: 16, + marginRight: 8, + alignSelf: "flex-end", + marginBottom: 4, + }, + bubbleWrapper: { + maxWidth: "75%", + }, + senderName: { + fontSize: 11, + fontWeight: "600", + color: colors.grey2, + marginBottom: 2, + marginLeft: 4, + }, + replyPreview: { + backgroundColor: colors.grey4, + borderLeftWidth: 3, + borderLeftColor: colors.grey2, + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 4, + marginBottom: 4, + }, + replyPreviewAuthor: { + fontSize: 11, + fontWeight: "600", + color: colors.primary3, + }, + replyPreviewText: { + fontSize: 12, + color: colors.grey1, + }, + bubble: { + borderRadius: 16, + paddingHorizontal: 12, + paddingVertical: 8, + }, + bubbleRow: { + flexDirection: "row", + alignItems: "flex-start", + }, + messageText: { + fontSize: 15, + color: colors.primary3, + flexShrink: 1, + }, + messageTextSent: { + color: "#FFFFFF", + }, + timestamp: { + fontSize: 10, + color: colors.grey2, + alignSelf: "flex-end", + marginTop: 2, + }, + timestampSent: { + color: "rgba(255,255,255,0.7)", + }, + errorIcon: { + marginRight: 6, + marginTop: 2, + }, + reactionsRow: { + flexDirection: "row", + flexWrap: "wrap", + marginTop: 3, + gap: 4, + }, + reactionsRight: { + justifyContent: "flex-end", + }, + reactionsLeft: { + justifyContent: "flex-start", + }, + reactionPill: { + flexDirection: "row", + alignItems: "center", + backgroundColor: colors.grey4, + borderRadius: 12, + paddingHorizontal: 6, + paddingVertical: 2, + }, + reactionEmoji: { + fontSize: 14, + }, + reactorAvatars: { + flexDirection: "row", + alignItems: "center", + }, + reactorAvatar: { + width: 16, + height: 16, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.grey5, + }, + reactionCount: { + fontSize: 11, + color: colors.grey1, + marginLeft: 4, + }, + swipeAction: { + justifyContent: "center", + alignItems: "center", + width: 60, + paddingHorizontal: 12, + }, +})) diff --git a/app/screens/chat/components/MessageInput.tsx b/app/screens/chat/components/MessageInput.tsx new file mode 100644 index 000000000..c8b9c66dd --- /dev/null +++ b/app/screens/chat/components/MessageInput.tsx @@ -0,0 +1,148 @@ +import React, { useState } from "react" +import { + View, + TextInput, + TouchableOpacity, + Text, +} from "react-native" +import Icon from "react-native-vector-icons/Ionicons" +import ReactNativeHapticFeedback from "react-native-haptic-feedback" +import { nip19 } from "nostr-tools" +import { Rumor } from "@app/utils/nostr" +import { makeStyles, useTheme } from "@rneui/themed" + +type Props = { + onSend: (text: string, replyToId?: string) => void + replyTo: Rumor | null + onCancelReply: () => void + profileMap: Map +} + +const MIN_HEIGHT = 40 +const MAX_HEIGHT = 120 + +export const MessageInput: React.FC = ({ + onSend, + replyTo, + onCancelReply, + profileMap, +}) => { + const [text, setText] = useState("") + const [inputHeight, setInputHeight] = useState(MIN_HEIGHT) + const { theme: { colors } } = useTheme() + const styles = useStyles() + + const handleSend = () => { + const trimmed = text.trim() + if (!trimmed) return + ReactNativeHapticFeedback.trigger("impactMedium", { enableVibrateFallback: true }) + onSend(trimmed, replyTo?.id) + setText("") + setInputHeight(MIN_HEIGHT) + onCancelReply() + } + + const replyAuthorProfile = replyTo ? profileMap.get(replyTo.pubkey) : null + const replyAuthorName = replyTo + ? replyAuthorProfile?.name || + replyAuthorProfile?.username || + nip19.npubEncode(replyTo.pubkey).slice(0, 10) + : null + + return ( + + {replyTo && ( + + + Replying to {replyAuthorName} + + {replyTo.content} + + + + + + + )} + + + { + const h = e.nativeEvent.contentSize.height + setInputHeight(Math.min(Math.max(h, MIN_HEIGHT), MAX_HEIGHT)) + }} + placeholder="Message" + placeholderTextColor={colors.grey3} + multiline + scrollEnabled={inputHeight >= MAX_HEIGHT} + maxLength={2000} + /> + + + + + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + container: { + borderTopWidth: 0.5, + borderTopColor: colors.grey4, + }, + replyBar: { + flexDirection: "row", + alignItems: "center", + backgroundColor: colors.grey4, + borderLeftWidth: 3, + borderLeftColor: colors.grey2, + paddingHorizontal: 12, + paddingVertical: 6, + }, + replyContent: { + flex: 1, + }, + replyLabel: { + fontSize: 12, + fontWeight: "600", + color: colors.primary3, + }, + replyText: { + fontSize: 12, + color: colors.grey1, + }, + replyClose: { + padding: 4, + }, + inputRow: { + flexDirection: "row", + alignItems: "flex-end", + backgroundColor: colors.grey5, + paddingHorizontal: 12, + paddingVertical: 8, + }, + textInput: { + flex: 1, + fontSize: 15, + color: colors.primary3, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: colors.grey4, + marginRight: 8, + }, + sendButton: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: "center", + alignItems: "center", + }, +})) diff --git a/app/screens/chat/components/ReactionPicker.tsx b/app/screens/chat/components/ReactionPicker.tsx new file mode 100644 index 000000000..c23ebff88 --- /dev/null +++ b/app/screens/chat/components/ReactionPicker.tsx @@ -0,0 +1,73 @@ +import React from "react" +import { Modal, View, TouchableOpacity, Text, TouchableWithoutFeedback } from "react-native" +import { makeStyles } from "@rneui/themed" + +const EMOJIS = ["โค๏ธ", "๐Ÿ‘", "๐Ÿ˜‚", "๐Ÿ˜ฎ", "๐Ÿ˜ข", "๐Ÿ”ฅ", "โž•", "๐Ÿ’ฏ"] + +type Props = { + visible: boolean + onSelect: (emoji: string) => void + onClose: () => void + position: { x: number; y: number } +} + +export const ReactionPicker: React.FC = ({ visible, onSelect, onClose, position }) => { + const styles = useStyles() + + return ( + + + + + {EMOJIS.map((emoji) => ( + { + onSelect(emoji) + onClose() + }} + > + {emoji} + + ))} + + + + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + overlay: { + flex: 1, + }, + picker: { + position: "absolute", + flexDirection: "row", + backgroundColor: colors.grey5, + borderRadius: 24, + paddingHorizontal: 8, + paddingVertical: 6, + shadowColor: colors.black, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 6, + elevation: 8, + }, + emojiButton: { + paddingHorizontal: 6, + paddingVertical: 2, + }, + emoji: { + fontSize: 24, + }, +})) diff --git a/app/screens/chat/historyListItem.tsx b/app/screens/chat/historyListItem.tsx index edecb1b68..c4fded51b 100644 --- a/app/screens/chat/historyListItem.tsx +++ b/app/screens/chat/historyListItem.tsx @@ -17,6 +17,17 @@ interface HistoryListItemProps { groups: Map } +function formatRelativeTime(created_at: number): string { + const now = Math.floor(Date.now() / 1000) + const diff = now - created_at + if (diff < 60) return "now" + if (diff < 3600) return `${Math.floor(diff / 60)}m ago` + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` + if (diff < 172800) return "Yesterday" + if (diff < 604800) return `${Math.floor(diff / 86400)}d ago` + return new Date(created_at * 1000).toLocaleDateString([], { month: "short", day: "numeric" }) +} + export const HistoryListItem: React.FC = ({ item, groups, @@ -31,12 +42,10 @@ export const HistoryListItem: React.FC = ({ 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(() => { const pubkeys = item .split(",") @@ -46,7 +55,7 @@ export const HistoryListItem: React.FC = ({ pubkeys.forEach((pubkey) => subscribedPubkeys.add(pubkey)) setSubscribedPubkeys(new Set(subscribedPubkeys)) - const unsub = nostrRuntime.ensureSubscription( + nostrRuntime.ensureSubscription( `historyProfile:${pubkeys.join(",")}`, { kinds: [0], authors: pubkeys }, (event: Event) => { @@ -55,7 +64,6 @@ export const HistoryListItem: React.FC = ({ ) }, [profileMap, subscribedPubkeys, item]) - // Check unread messages useFocusEffect(() => { const checkUnreadStatus = async () => { const lastSeen = await getLastSeen(item) @@ -95,7 +103,6 @@ export const HistoryListItem: React.FC = ({ /> ))} - {/* Self note indicator */} {selfNote && ( = ({ )} {/* Names and last message */} - - - - {item - .split(",") - .filter((p) => p !== userPublicKeyVal) - .map((pubkey) => { - const profile = profileMap?.get(pubkey) - return ( - profile?.nip05 || - profile?.name || - profile?.username || - nip19.npubEncode(pubkey).slice(0, 9) + ".." - ) - }) - .join(", ")} - {selfNote && ( - - - Note to Self - - - - )} - - + + + + + {item + .split(",") + .filter((p) => p !== userPublicKeyVal) + .map((pubkey) => { + const profile = profileMap?.get(pubkey) + return ( + profile?.nip05 || + profile?.name || + profile?.username || + nip19.npubEncode(pubkey).slice(0, 9) + ".." + ) + }) + .join(", ")} + {selfNote && ( + + + Note to Self + + + + )} + + + {lastRumor && ( + {formatRelativeTime(lastRumor.created_at)} + )} + {lastRumor && ( - + {(profileMap?.get(lastRumor.pubkey)?.name || profileMap?.get(lastRumor.pubkey)?.nip05 || profileMap?.get(lastRumor.pubkey)?.username || diff --git a/app/screens/chat/messages.tsx b/app/screens/chat/messages.tsx index 77a1a0be7..b9dd72838 100644 --- a/app/screens/chat/messages.tsx +++ b/app/screens/chat/messages.tsx @@ -1,7 +1,6 @@ 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 { ActivityIndicator, Image, View, TouchableOpacity } from "react-native" import { RouteProp, useNavigation } from "@react-navigation/native" import { StackNavigationProp } from "@react-navigation/stack" import { Screen } from "../../components/screen" @@ -9,59 +8,30 @@ import type { ChatStackParamList, RootStackParamList, } from "../../navigation/stack-param-lists" -import { makeStyles, Text, useTheme } from "@rneui/themed" -import { GaloyIconButton } from "@app/components/atomic/galoy-icon-button" -import { isIos } from "@app/utils/helper" -import { Chat, MessageType, defaultTheme } from "@flyerhq/react-native-chat-ui" -import { ChatMessage } from "./chatMessage" +import { Text, makeStyles, useTheme } from "@rneui/themed" import Icon from "react-native-vector-icons/Ionicons" -import { nip19, Event } from "nostr-tools" -import { Rumor, convertRumorsToGroups, sendNip17Message } from "@app/utils/nostr" +import { nip19 } from "nostr-tools" +import { Rumor, convertRumorsToGroups, sendNip17Message, sendReaction } from "@app/utils/nostr" import { useEffect, useState } from "react" import { useChatContext } from "./chatContext" -import { SafeAreaProvider } from "react-native-safe-area-context" +import { useSafeAreaInsets } from "react-native-safe-area-context" import { updateLastSeen } from "./utils" import { nostrRuntime } from "@app/nostr/runtime/NostrRuntime" import { getSigner } from "@app/nostr/signer" +import { FlatList } from "react-native-gesture-handler" +import { MessageBubble } from "./components/MessageBubble" +import { MessageInput } from "./components/MessageInput" type MessagesProps = { route: RouteProp } export const Messages: React.FC = ({ route }) => { - const groupId = route.params.groupId const { userPublicKey } = useChatContext() - const [profileMap, setProfileMap] = useState>(new Map()) - const [preferredRelaysMap, setPreferredRelaysMap] = useState>( - new Map(), - ) - - // 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(() => { - const pubkeys = groupId.split(",") - nostrRuntime.ensureSubscription( - `messagesProfiles:${pubkeys.join(",")}`, - { kinds: [0], authors: pubkeys }, - handleProfileEvent, - ) - }, [groupId]) - return ( ) } @@ -69,209 +39,263 @@ export const Messages: React.FC = ({ route }) => { type MessagesScreenProps = { groupId: string userPubkey: string - profileMap: Map - preferredRelaysMap: Map } -export const MessagesScreen: React.FC = ({ - userPubkey, - groupId, - profileMap, - preferredRelaysMap, -}) => { - const { - theme: { colors }, - } = useTheme() - const { rumors } = useChatContext() +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 +} + +export const MessagesScreen: React.FC = ({ userPubkey, groupId }) => { + const { theme: { colors, mode } } = useTheme() const styles = useStyles() + const { rumors, reactions } = useChatContext() const navigation = useNavigation>() - const { LL } = useI18nContext() + const insets = useSafeAreaInsets() + const [initialized, setInitialized] = useState(false) - const [messages, setMessages] = useState>(new Map()) - const user = { id: userPubkey } + const [chatRumors, setChatRumors] = useState([]) + const [replyTo, setReplyTo] = useState(null) - const convertRumorsToMessages = (rumors: Rumor[]) => { - const chatMap = new Map() - rumors.forEach((r) => { - chatMap.set(r.id, { - author: { id: r.pubkey }, - createdAt: r.created_at * 1000, - id: r.id, - type: "text", - text: r.content, - }) + const pubkeys = groupId.split(",") + const isGroupChat = pubkeys.length > 2 + const recipientPubkeys = pubkeys.filter((p) => p !== userPubkey) + + // Seed profiles immediately from EventStore, then subscribe for live updates + const [profileMap, setProfileMap] = useState>(() => + readProfilesFromStore(pubkeys), + ) + + useEffect(() => { + // Re-read from EventStore whenever it emits (covers profiles fetched by any screen) + const unsubStore = nostrRuntime.getEventStore().subscribe(() => { + setProfileMap(readProfilesFromStore(pubkeys)) }) - return chatMap - } - // Initialize messages map & last-seen tracking + // Ensure relay subscription for profiles not yet in store + nostrRuntime.ensureSubscription( + `messagesProfiles:${pubkeys.join(",")}`, + { kinds: [0], authors: pubkeys }, + ) + + return unsubStore + }, [groupId]) + + // Build deduplicated chat rumor list useEffect(() => { setInitialized(true) - const chatRumors = convertRumorsToGroups(rumors).get(groupId) || [] - const lastRumor = chatRumors.sort((a, b) => b.created_at - a.created_at)[0] + const groupRumors = convertRumorsToGroups(rumors).get(groupId) || [] + const seen = new Set() + const unique = groupRumors.filter((r) => { + if (seen.has(r.id)) return false + seen.add(r.id) + return true + }) + const sorted = unique.sort((a, b) => a.created_at - b.created_at) + const lastRumor = sorted[sorted.length - 1] if (lastRumor) updateLastSeen(groupId, lastRumor.created_at) - setMessages((prev) => new Map([...prev, ...convertRumorsToMessages(chatRumors)])) - }, [rumors]) - - const handleSendPress = async (message: MessageType.PartialText) => { - const textMessage: MessageType.Text = { - author: user, - createdAt: Date.now(), - text: message.text, - type: "text", - id: message.text, - } + setChatRumors(sorted) + }, [rumors, groupId]) - let sent = false - const onSent = (rumor: Rumor) => { - if (!sent) { - textMessage.id = rumor.id - setMessages((prev) => new Map(prev).set(textMessage.id, textMessage)) - sent = true - } - } + const getParentRumor = (rumor: Rumor): Rumor | null => { + const replyTag = rumor.tags.find((t) => t[0] === "e" && t[3] === "reply") + if (!replyTag) return null + return rumors.find((r) => r.id === replyTag[1]) || null + } + const handleSend = async (text: string, replyToId?: string) => { const signer = await getSigner() const result = await sendNip17Message( - groupId.split(","), - message.text, - preferredRelaysMap, + pubkeys, + text, + new Map(), signer, - onSent, + (rumor) => { + setChatRumors((prev) => { + if (prev.some((r) => r.id === rumor.id)) return prev + return [...prev, rumor].sort((a, b) => a.created_at - b.created_at) + }) + }, + replyToId, ) - // Mark failed messages if (result.outputs.filter((o) => o.acceptedRelays.length > 0).length === 0) { - textMessage.metadata = { errors: true } - textMessage.id = result.rumor.id - setMessages((prev) => new Map(prev).set(textMessage.id, textMessage)) + const failed = { ...result.rumor, metadata: { errors: true } } as any + setChatRumors((prev) => { + if (prev.some((r) => r.id === failed.id)) return prev + return [...prev, failed].sort((a: Rumor, b: Rumor) => a.created_at - b.created_at) + }) } } + const handleReaction = async (rumor: Rumor, emoji: string) => { + const signer = await getSigner() + await sendReaction(rumor.id, rumor.pubkey, emoji, pubkeys, new Map(), signer) + } + + const recipientProfile = profileMap.get(recipientPubkeys[0]) + const headerTitle = recipientPubkeys + .map( + (p) => + profileMap.get(p)?.name || + profileMap.get(p)?.username || + profileMap.get(p)?.lud16 || + nip19.npubEncode(p).slice(0, 9) + "..", + ) + .join(", ") + + const chatBg = mode === "dark" ? "#0e1a16" : "#eef5f2" + return ( - - - - {groupId - .split(",") - .filter((p) => p !== userPubkey) - .map( - (p) => - profileMap.get(p)?.name || - profileMap.get(p)?.username || - profileMap.get(p)?.lud16 || - nip19.npubEncode(p).slice(0, 9) + "..", - ) - .join(", ")} - - - { - const recipientId = groupId.split(",").filter((id) => id !== userPubkey)[0] - navigation.navigate("sendBitcoinDestination", { - username: profileMap.get(recipientId)?.lud16, - }) + {/* Header */} + + + + + + {/* Avatar + name */} + + - {groupId - .split(",") - .filter((p) => p !== userPubkey) - .map((pubkey) => ( - - ))} - + + + {headerTitle} + + {recipientProfile?.nip05 && ( + + {recipientProfile.nip05} + + )} + + + + {/* Lightning button */} + { + navigation.navigate("sendBitcoinDestination", { + username: recipientProfile?.lud16, + }) + }} + hitSlop={8} + > + + - {!initialized && } + {!initialized && } - - - - b.createdAt! - a.createdAt!, - )} - user={user} - onSendPress={handleSendPress} - renderTextMessage={(msg, next, prev) => ( - - )} - theme={{ - ...defaultTheme, - colors: { - ...defaultTheme.colors, - inputBackground: colors._black, - background: colors._lightGrey, - }, - fonts: { - ...defaultTheme.fonts, - sentMessageBodyTextStyle: { - ...defaultTheme.fonts.sentMessageBodyTextStyle, - fontSize: 12, - }, - }, - }} - l10nOverride={{ - emptyChatPlaceholder: initialized - ? isIos - ? "No messages here yet" - : "..." - : isIos - ? "Fetching Messages..." - : "...", - }} - flatListProps={{ - contentContainerStyle: { - paddingTop: messages.size ? (Platform.OS === "ios" ? 50 : 0) : 100, - }, - }} - /> - - + {/* Message list */} + r.id} + style={{ backgroundColor: chatBg }} + contentContainerStyle={styles.listContent} + renderItem={({ item }) => ( + setReplyTo(r)} + onReact={(emoji) => handleReaction(item, emoji)} + isGroupChat={isGroupChat} + /> + )} + ListEmptyComponent={ + initialized ? ( + + No messages yet + + ) : null + } + /> + + {/* Input */} + + setReplyTo(null)} + onSend={handleSend} + /> ) } const useStyles = makeStyles(({ colors }) => ({ - aliasView: { + header: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 12, + paddingBottom: 10, + borderBottomWidth: 0.5, + borderBottomColor: colors.grey4, + backgroundColor: colors.grey5, + }, + backBtn: { + padding: 4, + marginRight: 4, + }, + headerCenter: { + flex: 1, flexDirection: "row", alignItems: "center", - justifyContent: "space-between", - paddingRight: 10, - paddingLeft: 10, - paddingBottom: 6, - paddingTop: isIos ? 40 : 10, + marginRight: 8, + overflow: "hidden", + }, + headerAvatar: { + width: 38, + height: 38, + borderRadius: 19, + borderWidth: 1.5, + marginRight: 10, }, - chatBodyContainer: { flex: 1 }, - chatView: { flex: 1, marginHorizontal: 30, borderRadius: 24, overflow: "hidden" }, - userPic: { - borderRadius: 50, - height: 50, - width: 50, - borderWidth: 1, - borderColor: colors.green, + headerNameCol: { + flex: 1, + }, + headerName: { + fontSize: 16, + fontWeight: "600", + color: colors.primary3, + }, + headerSubtitle: { + fontSize: 12, + color: colors.grey2, + marginTop: 1, + }, + lightningBtn: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: "center", + alignItems: "center", + }, + listContent: { + paddingVertical: 8, + }, + emptyContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingTop: 60, }, - backButton: { fontSize: 26, color: colors.primary3 }, })) diff --git a/app/screens/chat/style.ts b/app/screens/chat/style.ts index fe8aa5546..742b5bb83 100644 --- a/app/screens/chat/style.ts +++ b/app/screens/chat/style.ts @@ -46,7 +46,21 @@ export const useStyles = makeStyles(({ colors }) => ({ item: { marginHorizontal: 20, - marginVertical: 4, + marginVertical: 3, + }, + + listItemHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 2, + }, + + timestamp: { + fontSize: 11, + color: "#888", + flexShrink: 0, + marginLeft: 8, }, unreadItem: { @@ -64,17 +78,17 @@ export const useStyles = makeStyles(({ colors }) => ({ }, itemContainer: { - borderRadius: 8, + borderRadius: 12, backgroundColor: colors.grey5, - // borderColor: colors.grey2, - // borderWidth: 0.5, - minHeight: 50, - margin: 5, + minHeight: 60, + paddingHorizontal: 12, + paddingVertical: 10, + marginVertical: 3, shadowColor: colors.black, - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.2, - shadowRadius: 3, - elevation: 4, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + elevation: 2, }, listContainer: { flexGrow: 1 }, @@ -83,7 +97,7 @@ export const useStyles = makeStyles(({ colors }) => ({ backgroundColor: colors.grey5, borderBottomColor: colors.grey5, borderTopColor: colors.grey5, - marginHorizontal: 12, + marginHorizontal: 20, marginVertical: 8, }, diff --git a/app/utils/nostr.ts b/app/utils/nostr.ts index 37b2b3377..80349dbc6 100644 --- a/app/utils/nostr.ts +++ b/app/utils/nostr.ts @@ -123,6 +123,8 @@ export const fetchGiftWrapsForPublicKey = ( export const convertRumorsToGroups = (rumors: Rumor[]) => { let groups: Map = new Map() rumors.forEach((rumor) => { + // Filter out kind 7 reactions โ€” only kind 14 messages belong in conversation groups + if (rumor.kind !== 14) return let participants = rumor.tags.filter((t) => t[0] === "p").map((p) => p[1]) let id = getGroupId([...participants, rumor.pubkey]) groups.set(id, [...(groups.get(id) || []), rumor]) @@ -286,8 +288,12 @@ export async function sendNip17Message( preferredRelaysMap: Map, signer: NostrSigner, onSent?: (rumor: Rumor) => void, + replyToId?: string, ) { let p_tags = recipients.map((recipientId: string) => ["p", recipientId]) + if (replyToId) { + p_tags = [...p_tags, ["e", replyToId, "", "reply"]] + } let rumor = await createRumor({ content: message, kind: 14, tags: p_tags }, signer) let outputs: { acceptedRelays: string[]; rejectedRelays: string[] }[] = [] console.log("total recipients", recipients) @@ -331,6 +337,37 @@ export async function sendNip17Message( return { outputs, rumor } } +export async function sendReaction( + originalRumorId: string, + originalAuthorPubkey: string, + emoji: string, + recipients: string[], + preferredRelaysMap: Map, + signer: NostrSigner, +): Promise { + const p_tags = recipients.map((r) => ["p", r]) + const rumor = await createRumor( + { + kind: 7, + content: emoji, + tags: [...p_tags, ["e", originalRumorId], ["p", originalAuthorPubkey]], + }, + signer, + ) + await Promise.allSettled( + recipients.map(async (recipientId) => { + const recipientRelays = preferredRelaysMap.get(recipientId) || [ + "wss://relay.flashapp.me", + "wss://relay.damus.io", + "wss://nostr.oxtr.dev", + ] + const seal = await createSeal(rumor, signer, recipientId) + const wrap = createWrap(seal, recipientId) + await Promise.allSettled(customPublish(recipientRelays, wrap)) + }), + ) +} + export const ensureRelay = async ( url: string, params?: { connectionTimeout?: number }, From 8018cd6df898eaf6eb71c9ecfaf35151d90c239e Mon Sep 17 00:00:00 2001 From: Abhay Date: Tue, 3 Mar 2026 19:24:05 +0530 Subject: [PATCH 2/8] Update group chat --- app/components/chat-message/chat-message.tsx | 3 +- .../chat/GroupChat/GroupChatProvider.tsx | 165 +++----- .../chat/GroupChat/SupportGroupChat.tsx | 366 +++++++++++++----- app/screens/chat/chatMessage.tsx | 3 +- package.json | 2 - yarn.lock | 153 +++----- 6 files changed, 390 insertions(+), 302 deletions(-) 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/screens/chat/GroupChat/GroupChatProvider.tsx b/app/screens/chat/GroupChat/GroupChatProvider.tsx index c80a4f30f..bcc6068ff 100644 --- a/app/screens/chat/GroupChat/GroupChatProvider.tsx +++ b/app/screens/chat/GroupChat/GroupChatProvider.tsx @@ -8,48 +8,46 @@ import React, { useState, } from "react" import { Event } from "nostr-tools" -import { MessageType } from "@flyerhq/react-native-chat-ui" import { getSigner } from "@app/nostr/signer" import { useChatContext } from "../../../screens/chat/chatContext" import { nostrRuntime } from "@app/nostr/runtime/NostrRuntime" import { pool } from "@app/utils/nostr/pool" // ===== Types ===== + +export type GroupMessage = { + id: string + authorId: string + createdAt: number // unix milliseconds + text: string + isSystem?: boolean +} + export type NostrGroupChatProviderProps = { groupId: string relayUrls?: string[] - /** - * Optional: limit membership/metadata events to known admin pubkeys. - * If omitted, provider listens to any author. - */ adminPubkeys?: string[] children: React.ReactNode } type ContextValue = { - messages: MessageType.Text[] - groupMetadata: { [key: string]: string } + messages: GroupMessage[] + groupMetadata: { name?: string; about?: string; picture?: string } isMember: boolean knownMembers: Set - /** - * Send a plain text message to the group - */ sendMessage: (text: string) => Promise - /** - * Request to join the group (NIP-29 join event) - */ requestJoin: () => 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,8 +58,7 @@ 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 [isMember, setIsMember] = useState(false) const [knownMembers, setKnownMembers] = useState>(new Set()) const [metadata, setMetadata] = useState<{ @@ -69,15 +66,12 @@ export const NostrGroupChatProvider: React.FC = ({ 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!) + return Array.from(messagesMap.values()).sort((a, b) => b.createdAt - a.createdAt) }, [messagesMap]) - // Sync isMember when userPublicKey becomes available after membership data was already received useEffect(() => { if (userPublicKey && knownMembers.size > 0) { setIsMember(knownMembers.has(userPublicKey)) @@ -88,17 +82,12 @@ 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 msg: GroupMessage = { id: event.id, - author: { id: event.pubkey }, + authorId: event.pubkey, createdAt: event.created_at * 1000, - type: "text", text: event.content, } setMessagesMap((prev) => { @@ -113,99 +102,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) }, () => {}, @@ -217,18 +173,14 @@ export const NostrGroupChatProvider: React.FC = ({ const sendMessage = useCallback( async (text: string) => { if (!userPublicKey) throw Error("No user pubkey present") - const signer = await getSigner() - - const nostrEvent = { + const signedEvent = await signer.signEvent({ kind: 9, created_at: Math.floor(Date.now() / 1000), - tags: [["h", groupId, relayUrls[0]]], // include relay hint + tags: [["h", groupId, relayUrls[0]]], content: text, pubkey: userPublicKey, - } - - const signedEvent = await signer.signEvent(nostrEvent as any) + } as any) pool.publish(relayUrls, signedEvent) }, [userPublicKey, groupId, relayUrls], @@ -236,50 +188,35 @@ 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]) const value = useMemo( - () => ({ - messages, - isMember, - knownMembers, - sendMessage, - requestJoin, - groupMetadata: metadata, - }), - [messages, isMember, knownMembers, sendMessage, requestJoin], + () => ({ messages, isMember, knownMembers, sendMessage, requestJoin, groupMetadata: metadata }), + [messages, isMember, knownMembers, sendMessage, requestJoin, 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/SupportGroupChat.tsx b/app/screens/chat/GroupChat/SupportGroupChat.tsx index cab847784..960aec20f 100644 --- a/app/screens/chat/GroupChat/SupportGroupChat.tsx +++ b/app/screens/chat/GroupChat/SupportGroupChat.tsx @@ -1,126 +1,316 @@ -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 { View, Image, TouchableOpacity, ListRenderItem } 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 { nip19 } from "nostr-tools" 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" 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 +} + 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, sendMessage, requestJoin, groupMetadata } = useNostrGroupChat() const { userPublicKey } = useChatContext() - const renderTextMessage = ( - message: MessageType.Text, - showName: number, - nextMessage: boolean, - ) => { - if (message.author.id === "system") { + // Profile loading via EventStore (same pattern as messages.tsx) + const subscribedPubkeys = useRef>(new Set()) + const [profileMap, setProfileMap] = useState>(new Map()) + + 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) // unique + + if (newPubkeys.length === 0) return + + newPubkeys.forEach((pk) => subscribedPubkeys.current.add(pk)) + + nostrRuntime.ensureSubscription( + `groupProfiles:${[...subscribedPubkeys.current].sort().join(",")}`, + { kinds: [0], authors: [...subscribedPubkeys.current] }, + ) + + // Seed immediately from whatever's already in the store + setProfileMap(readProfilesFromStore([...subscribedPubkeys.current])) + }, [messages]) + + const chatBg = mode === "dark" ? "#0e1a16" : "#eef5f2" + + const renderItem: ListRenderItem = ({ item: msg }) => { + if (msg.isSystem) { return ( - - - - {message.text} - + + + {msg.text} ) } + const isMe = msg.authorId === userPublicKey + const profile = profileMap.get(msg.authorId) + const senderName = + profile?.name || + profile?.username || + nip19.npubEncode(msg.authorId).slice(0, 10) + const time = new Date(msg.createdAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + return ( - + + {!isMe && ( + + )} + + {!isMe && {senderName}} + + + {msg.text} + + {time} + + + ) } return ( - - - sendMessage(partial.text)} - user={{ id: userPublicKey || "me" }} - renderTextMessage={(message, showName, nextMessage) => - renderTextMessage(message, showName, nextMessage) + {/* Header */} + + + + + + ( - -