Sovran builds a unified contact system from multiple Nostr sources:
- Direct Message Contacts: Recent DM conversations (NIP-04 and NIP-17)
- Mint Contacts: Mints with Nostr pubkeys in their info
- Follow Lists: NIP-02 contact lists (following/followers)
This creates a seamless experience where users can send payments to anyone they’ve messaged or any mint they trust.
The primary contact source is recent DM activity:
// app/(drawer)/(tabs)/payments/index.tsx
const recentActivityContacts = useMemo(() => {
if (!nostrKeys?.pubkey) return [];
const contactMap = new Map();
// NIP-04 DMs
dmEvents?.forEach((event) => {
const otherPubkey =
event.pubkey === nostrKeys.pubkey
? event.tags.find((tag) => tag[0] === 'p')?.[1]
: event.pubkey;
if (!otherPubkey) return;
const existing = contactMap.get(otherPubkey);
const ts = event.created_at || 0;
if (!existing || ts > existing.timestamp) {
contactMap.set(otherPubkey, { type: 'nip04', event, timestamp: ts });
}
});
// NIP-17 DMs
unwrappedDMs.forEach((dm) => {
const otherPubkey =
dm.senderPubkey === nostrKeys.pubkey ? dm.recipientPubkeys[0] : dm.senderPubkey;
if (!otherPubkey) return;
const existing = contactMap.get(otherPubkey);
if (!existing || dm.created_at > existing.timestamp) {
contactMap.set(otherPubkey, { type: 'nip17', dm, timestamp: dm.created_at });
}
});
return Array.from(contactMap.entries())
.map(([pubkey, entry]) => ({
type: 'contact',
pubkey,
dmEvent: entry.type === 'nip04' ? entry.event : null,
nip17Content: entry.type === 'nip17' ? entry.dm?.content : undefined,
timestamp: entry.timestamp,
}))
.sort((a, b) => b.timestamp - a.timestamp);
}, [dmEvents, unwrappedDMs, nostrKeys?.pubkey]);
Features:
- Unified NIP-04 and NIP-17: Combines both DM protocols
- Most recent message: Shows latest message from each contact
- Automatic decryption: Messages decrypted for preview
- Sorted by recency: Most recent conversations first
Mints that have a Nostr pubkey in their /v1/info endpoint:
const mintsWithMetadata = useMemo(() => {
const dmMap = new Map();
dmEvents?.forEach((event) => {
const otherPubkey =
event.pubkey === nostrKeys?.pubkey
? event.tags.find((tag) => tag[0] === 'p')?.[1]
: event.pubkey;
if (!otherPubkey) return;
const existing = dmMap.get(otherPubkey);
if (!existing || (event.created_at && event.created_at > existing.created_at)) {
dmMap.set(otherPubkey, event);
}
});
return mintsWithInfo.map(({ mint, mintInfo }) => {
let mintPubkey = null;
const nostrContact = mintInfo.contact?.find((c: any) => c.method === 'nostr');
if (nostrContact?.info) {
try {
mintPubkey = npubToPubkey(nostrContact.info);
} catch {
// Ignore decode failure
}
}
return {
type: 'mint',
pubkey: mintPubkey,
mint,
mintInfo,
dmEvent: mintPubkey ? dmMap.get(mintPubkey) : undefined,
timestamp: mintPubkey ? dmMap.get(mintPubkey)?.created_at || 0 : 0,
};
});
}, [mintsWithInfo, dmEvents, nostrKeys?.pubkey]);
Use Cases:
- Support requests: Message mint operators about issues
- Payment confirmations: Get receipts from mint DMs
- Mint discovery: Find mints via Nostr contacts
Sovran includes default contacts for new users:
const DEFAULT_CONTACTS = [
{
npub: 'npub1ref7jqxrh0z74554y900ufajer2lh52lk0wczrdrqcm8fjmjzweqll64x3',
label: 'Sovran',
},
{
npub: 'npub1ceel7z6ly287kz4mzqqcsgtc6nzc30zw2ru9w9e4gj64gw69f7qscyf0p8',
label: 'kelbie',
},
];
const contactsWithDefaults = useMemo(() => {
const existingPubkeys = new Set(recentActivityContacts.map((c) => c.pubkey));
const defaultsToAdd = defaultContactPubkeys
.filter((dc) => !existingPubkeys.has(dc.pubkey))
.map((dc) => ({
type: 'contact' as const,
pubkey: dc.pubkey,
dmEvent: null,
nip17Content: undefined,
timestamp: 0,
isDefault: true,
}));
return [...recentActivityContacts, ...defaultsToAdd];
}, [recentActivityContacts, defaultContactPubkeys]);
The main contact list UI:
// components/blocks/payments/DraggableContactsList.tsx
interface DraggableContactsListProps {
profilesMap: Map<string, any>;
data: any[];
isDecrypting: boolean;
isLoadingProfiles?: boolean;
emptyMessage: string;
}
export const DraggableContactsList: FC<DraggableContactsListProps> = ({
profilesMap,
data,
isDecrypting,
isLoadingProfiles = false,
emptyMessage,
}) => {
if (isDecrypting) {
return <Text>Decrypting messages...</Text>;
}
if (data.length === 0) {
return <Text>{emptyMessage}</Text>;
}
return (
<ScrollView>
{data.map((item) => {
const profile = item.pubkey ? profilesMap.get(item.pubkey) : undefined;
return (
<ContactItem
key={item.pubkey || item.mint?.mintUrl}
item={item}
profile={profile}
isLoadingProfile={isLoadingProfiles && !profile}
/>
);
})}
</ScrollView>
);
};
Each contact displays:
- Avatar: Profile picture or gradient based on pubkey
- Name: Display name, NIP-05, or truncated npub
- Last Message: Decrypted preview of most recent DM
- Timestamp: Relative time (“5m ago”, “Yesterday”, etc.)
// components/blocks/contacts/ContactItem.tsx
function ContactItem({ item, profile, isLoadingProfile }) {
const displayName = profile?.displayName || profile?.name || truncateMiddle(item.pubkey, 8);
const lastMessage = item.nip17Content || item.dmEvent?.content || '';
return (
<Pressable onPress={() => router.navigate('/userMessages', { pubkey: item.pubkey })}>
<HStack>
<Avatar picture={profile?.picture} seed={item.pubkey} loading={isLoadingProfile} />
<VStack>
<Text bold>{displayName}</Text>
<Text size={14} numberOfLines={1}>{lastMessage}</Text>
</VStack>
<Spacer />
<Text size={12}>{formatTimestamp(item.timestamp)}</Text>
</HStack>
</Pressable>
);
}
Contacts are enriched with profile metadata (kind 0 events):
const profileFilters = useMemo(() => {
const allPubkeys = [
...defaultContactPubkeys.map((dc) => dc.pubkey),
...decryptedContacts.map((item: any) => item.pubkey),
...decryptedMints.map((item: any) => item.pubkey),
].filter((pubkey): pubkey is string => !!pubkey);
const uniquePubkeys = [...new Set(allPubkeys)];
if (uniquePubkeys.length === 0) return null;
return [{ kinds: [0], authors: uniquePubkeys }];
}, [decryptedContacts, decryptedMints, defaultContactPubkeys]);
const { events: profileEvents, eose: profilesEose } = useSubscribe({ filters: profileFilters });
const isLoadingProfiles = !profilesEose;
const profilesMap = useMemo(() => {
const map = new Map();
profileEvents?.forEach((event) => {
try {
map.set(event.pubkey, JSON.parse(event.content));
} catch {
// Skip invalid profile JSON
}
});
return map;
}, [profileEvents]);
Profile fields used:
name: Short handle
displayName: Full name
picture: Avatar image URL
about: Bio/description
nip05: Verified identifier
lud16: Lightning address
Message Decryption
Contacts show a preview of the last message, which requires decryption:
async function decryptNip04Events<T extends { pubkey: string | null; dmEvent?: any }>(
items: T[],
privateKey: Uint8Array
): Promise<T[]> {
const signer = new NDKPrivateKeySigner(privateKey);
const results: T[] = [];
for (const item of items) {
try {
if (item.nip17Content !== undefined) {
// Already decrypted during unwrapping
results.push({ ...item, dmEvent: { content: item.nip17Content } });
continue;
}
if (!item.dmEvent || !item.pubkey) {
results.push(item);
continue;
}
if (item.dmEvent instanceof NDKEvent) {
const counterparty = new NDKUser({ pubkey: item.pubkey });
await item.dmEvent.decrypt(counterparty, signer);
results.push({ ...item, dmEvent: { ...item.dmEvent, content: item.dmEvent.content } });
} else {
results.push(item);
}
} catch {
// Decryption failed - show placeholder
results.push({ ...item, dmEvent: { ...item.dmEvent, content: '[Encrypted message]' } });
}
}
return results;
}
Decryption states:
- NIP-17: Already decrypted during
unwrapGiftWrap
- NIP-04: Decrypt using
NDKEvent.decrypt()
- Failed: Show “[Encrypted message]” placeholder
The payments screen includes search for finding new contacts:
const searchUsers = useCallback(async (query: string) => {
if (!query.trim()) return;
setSearchLoading(true);
setHasSearched(true);
try {
const result = await apiSearchUsers({ query, limit: 10 });
if (result.isOk()) {
const data = result.value;
if (data.results && Array.isArray(data.results)) {
const formatted: SearchResultData[] = data.results.map((res) => ({
pubkey: res.pubkey,
profile: { ...res },
}));
setSearchResults(formatted);
if (formatted.length > 0) addSearchToHistory(query, 'payments');
} else {
setSearchResults([]);
}
} else {
setSearchResults([]);
}
} catch {
setSearchResults([]);
} finally {
setSearchLoading(false);
}
}, [addSearchToHistory]);
Search features:
- Debounced input: 500ms delay to avoid excessive API calls
- Skeleton states: Show placeholders while searching
- Search history: Track recent searches
- API-based: Uses Sovran API for user search
SearchResult Component
function SearchResult({ result, loading, onPress }) {
if (loading) {
return (
<HStack>
<Skeleton style={{ width: 48, height: 48, borderRadius: 24 }} />
<VStack>
<Skeleton style={{ width: 120, height: 16 }} />
<Skeleton style={{ width: 180, height: 14 }} />
</VStack>
</HStack>
);
}
const displayName = result.profile?.displayName || result.profile?.name || truncateMiddle(result.pubkey, 8);
return (
<Pressable onPress={() => onPress(result)}>
<HStack>
<Avatar picture={result.profile?.picture} seed={result.pubkey} />
<VStack>
<Text bold>{displayName}</Text>
{result.profile?.nip05 && (
<HStack>
<Icon name="mdi:check-decagram" size={14} />
<Text size={14}>{result.profile.nip05}</Text>
</HStack>
)}
</VStack>
</HStack>
</Pressable>
);
}
Tabs: Recent Activity vs Mints
The payments screen has two tabs:
const TABS = ['Recent activity', 'Mints'];
<Tabs
tabs={TABS}
selectedTab={selectedTab}
handleTabPress={handleTabPress}
amounts={[String(decryptedContacts.length), String(decryptedMints.length)]}
/>
<PagerView onPageSelected={onPageSelected}>
<View key="1">
<DraggableContactsList
data={decryptedContacts}
profilesMap={profilesMap}
isDecrypting={isDecrypting}
isLoadingProfiles={isLoadingProfiles}
emptyMessage="No recent conversations found"
/>
</View>
<View key="2">
<DraggableContactsList
data={decryptedMints}
profilesMap={profilesMap}
isDecrypting={mintInfoLoading || isDecryptingMints}
isLoadingProfiles={isLoadingProfiles}
emptyMessage="No mints with nostr contacts found"
/>
</View>
</PagerView>
Contact lists (kind 3 events) track who you follow:
const latestContactListEvent = useMemo(() => {
if (!nostrKeys?.pubkey) return null;
const candidates = (contactListEvents || []).filter(
(event) => event.kind === Contacts && event.pubkey === nostrKeys.pubkey
);
if (candidates.length === 0) return null;
return [...candidates].sort((a, b) => {
const byCreatedAt = (b.created_at || 0) - (a.created_at || 0);
if (byCreatedAt !== 0) return byCreatedAt;
return (b.id || '').localeCompare(a.id || '');
})[0];
}, [contactListEvents, nostrKeys?.pubkey]);
useEffect(() => {
if (!latestContactListEvent) return;
const tags = Array.isArray(latestContactListEvent.tags)
? (latestContactListEvent.tags as string[][])
: [];
const content =
typeof latestContactListEvent.content === 'string' ? latestContactListEvent.content : '';
setContactsFromRelay({ tags, content, createdAt: latestContactListEvent.created_at || 0 });
}, [latestContactListEvent, setContactsFromRelay]);
Contact list structure:
{
kind: 3,
content: "", // Optional JSON relay list
tags: [
["p", "<pubkey1>", "<relay1>", "<petname1>"],
["p", "<pubkey2>", "<relay2>", "<petname2>"],
// ...
],
created_at: 1234567890,
}
Follow/Unfollow
See User Profiles for implementation.
Blocking
Blocking is not yet implemented in Sovran but can use NIP-51 mute lists.
Pet Names
The third parameter in p tags can store custom nicknames:
["p", "<pubkey>", "wss://relay.example.com", "Alice"]
These are displayed instead of display names when set.
Best Practices
Combine multiple contact sources
import { prefetchImages } from '@/helper/imageCache';
useEffect(() => {
prefetchImages(Array.from(profilesMap.values()).map((p: any) => p?.picture));
}, [profilesMap]);
Handle missing profiles gracefully
const displayName = profile?.displayName || profile?.name || truncateMiddle(npub, 8);
const picture = profile?.picture || undefined; // Triggers gradient avatar
Decrypt messages in batches
const decryptedContacts = await decryptNip04Events(contactsWithDefaults, privateKey);
Direct Messages
NIP-17 encrypted messaging for contact DMs
User Profiles
Profile metadata and follow lists
Identity & Keys
Key management for contact encryption
Mint Discovery
Finding mints with Nostr contacts