diff --git a/app/components/common/Address.tsx b/app/components/common/Address.tsx index e9caadc76..67598e20e 100644 --- a/app/components/common/Address.tsx +++ b/app/components/common/Address.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { useState } from 'react'; import useAsyncEffect from 'use-async-effect'; +import { EditIcon, NicknameEditor, useNickname } from '@/app/features/nicknames'; import { getTokenInfoWithoutOnChainFallback } from '@/app/utils/token-info'; import { Copyable } from './Copyable'; @@ -44,6 +45,8 @@ export function Address({ const address = pubkey.toBase58(); const { cluster } = useCluster(); const addressPath = useClusterPath({ pathname: `/address/${address}` }); + const [showNicknameEditor, setShowNicknameEditor] = useState(false); + const nickname = useNickname(address); const display = displayAddress(address, cluster, tokenLabelInfo); if (truncateUnknown && address === display) { @@ -70,6 +73,9 @@ export function Address({ addressLabel = overrideText; } + // Prepend nickname if exists + const displayText = nickname ? `"${nickname}" (${addressLabel})` : addressLabel; + const handleMouseEnter = (text: string) => { const elements = document.querySelectorAll(`[data-address="${text}"]`); elements.forEach(el => { @@ -85,22 +91,35 @@ export function Address({ }; const content = ( - - handleMouseEnter(address)} - onMouseLeave={() => handleMouseLeave(address)} +
+ + handleMouseEnter(address)} + onMouseLeave={() => handleMouseLeave(address)} + > + {link ? ( + + {displayText} + + ) : ( + {displayText} + )} + + + + {showNicknameEditor && ( + setShowNicknameEditor(false)} /> + )} +
); return ( diff --git a/app/features/nicknames/index.ts b/app/features/nicknames/index.ts new file mode 100644 index 000000000..a5202211e --- /dev/null +++ b/app/features/nicknames/index.ts @@ -0,0 +1,5 @@ +export { getNickname, removeNickname, setNickname } from './lib/nicknames'; +export { useNickname } from './model/use-nickname'; +export { EditIcon } from './ui/EditIcon'; +export { NicknameEditor } from './ui/NicknameEditor'; + diff --git a/app/features/nicknames/lib/__tests__/nicknames.test.ts b/app/features/nicknames/lib/__tests__/nicknames.test.ts new file mode 100644 index 000000000..92dabba59 --- /dev/null +++ b/app/features/nicknames/lib/__tests__/nicknames.test.ts @@ -0,0 +1,135 @@ +/** + * Tests for nickname localStorage utility functions + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getNickname, removeNickname, setNickname } from '../nicknames'; + +describe('nicknames', () => { + // Clear localStorage before each test for isolation + beforeEach(() => { + localStorage.clear(); + }); + + describe('getNickname', () => { + it('returns null when no nickname exists', () => { + const result = getNickname('TestAddress123'); + expect(result).toBeNull(); + }); + + it('returns the nickname when it exists', () => { + // Setup: add nickname to localStorage + localStorage.setItem( + 'solana-explorer-nicknames', + JSON.stringify({ + TestAddress123: 'My Wallet', + }) + ); + + const result = getNickname('TestAddress123'); + expect(result).toBe('My Wallet'); + }); + + it('handles invalid JSON gracefully', () => { + // Setup: corrupt localStorage data + localStorage.setItem('solana-explorer-nicknames', 'invalid-json'); + + const result = getNickname('TestAddress123'); + expect(result).toBeNull(); + }); + }); + + describe('setNickname', () => { + it('saves a nickname to localStorage', () => { + setNickname('TestAddress123', 'My Wallet'); + + // Verify it was saved correctly + const stored = JSON.parse(localStorage.getItem('solana-explorer-nicknames') || '{}'); + expect(stored['TestAddress123']).toBe('My Wallet'); + }); + + it('trims whitespace from nicknames', () => { + setNickname('TestAddress123', ' My Wallet '); + + const stored = JSON.parse(localStorage.getItem('solana-explorer-nicknames') || '{}'); + expect(stored['TestAddress123']).toBe('My Wallet'); + }); + + it('removes nickname when empty string provided', () => { + // Setup: save a nickname + setNickname('TestAddress123', 'My Wallet'); + + // Update with empty/whitespace-only string + setNickname('TestAddress123', ' '); + + const stored = JSON.parse(localStorage.getItem('solana-explorer-nicknames') || '{}'); + expect(stored['TestAddress123']).toBeUndefined(); + }); + + it('preserves other nicknames', () => { + setNickname('Address1', 'Wallet 1'); + setNickname('Address2', 'Wallet 2'); + + const stored = JSON.parse(localStorage.getItem('solana-explorer-nicknames') || '{}'); + expect(stored['Address1']).toBe('Wallet 1'); + expect(stored['Address2']).toBe('Wallet 2'); + }); + + it('dispatches nicknameUpdated event', () => { + const listener = vi.fn(); + window.addEventListener('nicknameUpdated', listener); + + setNickname('TestAddress123', 'My Wallet'); + + // Verify event was dispatched with correct address + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { address: 'TestAddress123' }, + }) + ); + + window.removeEventListener('nicknameUpdated', listener); + }); + }); + + describe('removeNickname', () => { + it('removes a nickname from localStorage', () => { + // Setup: save a nickname first + setNickname('TestAddress123', 'My Wallet'); + + removeNickname('TestAddress123'); + + const stored = JSON.parse(localStorage.getItem('solana-explorer-nicknames') || '{}'); + expect(stored['TestAddress123']).toBeUndefined(); + }); + + it('preserves other nicknames when removing one', () => { + // Setup: save multiple nicknames + setNickname('Address1', 'Wallet 1'); + setNickname('Address2', 'Wallet 2'); + + removeNickname('Address1'); + + const stored = JSON.parse(localStorage.getItem('solana-explorer-nicknames') || '{}'); + expect(stored['Address1']).toBeUndefined(); + expect(stored['Address2']).toBe('Wallet 2'); + }); + + it('dispatches nicknameUpdated event', () => { + const listener = vi.fn(); + window.addEventListener('nicknameUpdated', listener); + + removeNickname('TestAddress123'); + + // Verify event was dispatched with correct address + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { address: 'TestAddress123' }, + }) + ); + + window.removeEventListener('nicknameUpdated', listener); + }); + }); +}); + diff --git a/app/features/nicknames/lib/nicknames.ts b/app/features/nicknames/lib/nicknames.ts new file mode 100644 index 000000000..506261861 --- /dev/null +++ b/app/features/nicknames/lib/nicknames.ts @@ -0,0 +1,54 @@ +'use client'; + +/** + * Utility functions for managing wallet address nicknames in localStorage + */ + +const STORAGE_KEY = 'solana-explorer-nicknames'; + +export const getNickname = (address: string): string | null => { + if (typeof window === 'undefined') return null; + + try { + const nicknames = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); + return nicknames[address] || null; + } catch (error) { + console.error('Error reading nicknames from localStorage:', error); + return null; + } +}; + +export const setNickname = (address: string, nickname: string): void => { + if (typeof window === 'undefined') return; + + try { + const nicknames = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); + if (nickname.trim()) { + nicknames[address] = nickname.trim(); + } else { + delete nicknames[address]; + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(nicknames)); + + // Dispatch custom event to notify other components + window.dispatchEvent(new CustomEvent('nicknameUpdated', { detail: { address } })); + } catch (error) { + console.error('Error saving nickname to localStorage:', error); + } +}; + +export const removeNickname = (address: string): void => { + if (typeof window === 'undefined') return; + + try { + const nicknames = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); + delete nicknames[address]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(nicknames)); + + // Dispatch custom event to notify other components + window.dispatchEvent(new CustomEvent('nicknameUpdated', { detail: { address } })); + } catch (error) { + console.error('Error removing nickname from localStorage:', error); + } +}; + diff --git a/app/features/nicknames/model/use-nickname.ts b/app/features/nicknames/model/use-nickname.ts new file mode 100644 index 000000000..89bcd2559 --- /dev/null +++ b/app/features/nicknames/model/use-nickname.ts @@ -0,0 +1,32 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { getNickname } from '../lib/nicknames'; + +/** + * Hook to manage nickname for a given address + * Listens for nickname updates across all instances + */ +export function useNickname(address: string) { + const [nickname, setNickname] = useState(null); + + useEffect(() => { + setNickname(getNickname(address)); + + // Listen for nickname updates + const handleNicknameUpdate = (event: CustomEvent) => { + if (event.detail.address === address) { + setNickname(getNickname(address)); + } + }; + + window.addEventListener('nicknameUpdated', handleNicknameUpdate as EventListener); + return () => { + window.removeEventListener('nicknameUpdated', handleNicknameUpdate as EventListener); + }; + }, [address]); + + return nickname; +} + diff --git a/app/features/nicknames/ui/EditIcon.tsx b/app/features/nicknames/ui/EditIcon.tsx new file mode 100644 index 000000000..de56dc484 --- /dev/null +++ b/app/features/nicknames/ui/EditIcon.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Edit } from 'react-feather'; + +type Props = { + width?: number; + className?: string; +}; + +export function EditIcon({ width = 14, className }: Props) { + return ; +} + diff --git a/app/features/nicknames/ui/NicknameEditor.tsx b/app/features/nicknames/ui/NicknameEditor.tsx new file mode 100644 index 000000000..9c42a1656 --- /dev/null +++ b/app/features/nicknames/ui/NicknameEditor.tsx @@ -0,0 +1,124 @@ +'use client'; + +/** + * Simple modal for editing wallet address nicknames. + * Nicknames are stored in browser localStorage. + */ + +import React, { useEffect, useRef, useState } from 'react'; + +import { getNickname, removeNickname, setNickname } from '../lib/nicknames'; + +type Props = { + address: string; + onClose: () => void; +}; + +export function NicknameEditor({ address, onClose }: Props) { + const [nickname, setNicknameLocal] = useState(''); + const inputRef = useRef(null); + const saveButtonRef = useRef(null); + + // Load existing nickname on mount + useEffect(() => { + const existing = getNickname(address); + if (existing) { + setNicknameLocal(existing); + } + }, [address]); + + const handleSave = () => { + setNickname(address, nickname); + onClose(); + }; + + const handleRemove = () => { + removeNickname(address); + onClose(); + }; + + // Support Enter to save, Escape to cancel + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSave(); + } else if (e.key === 'Escape') { + onClose(); + } + }; + + // Handle Tab key to cycle between input and save button + const handleSaveButtonKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab' && !e.shiftKey) { + e.preventDefault(); + inputRef.current?.focus(); + } + }; + + return ( +
+
e.stopPropagation()} + > +
+
Edit Nickname
+
+
+
+ +
{address}
+
+
+ + setNicknameLocal(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Enter a memorable name..." + autoFocus + /> + This nickname is stored locally on your device. +
+
+
+ {getNickname(address) && ( + + )} +
+
+ + +
+
+
+
+
+ ); +} + diff --git a/app/features/nicknames/ui/stories/NicknameEditor.stories.tsx b/app/features/nicknames/ui/stories/NicknameEditor.stories.tsx new file mode 100644 index 000000000..2b7206784 --- /dev/null +++ b/app/features/nicknames/ui/stories/NicknameEditor.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { NicknameEditor } from '../NicknameEditor'; + +const meta = { + component: NicknameEditor, + decorators: [ + Story => ( +
+ +
+ ), + ], + parameters: { + docs: { + description: { + story: 'Modal for editing wallet address nicknames stored in localStorage', + }, + }, + nextjs: { + appDirectory: true, + }, + }, + tags: ['autodocs'], + title: 'Features/Nicknames/NicknameEditor', +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const Primary: Story = { + args: { + address: 'DXhYDXhYDXhYDXhYDXhYDXhYDXhYDXhYDXhYDXhYDXhY', + onClose: () => console.log('Close clicked'), + }, +}; + +export const WithExistingNickname: Story = { + args: { + address: 'So11111111111111111111111111111111111111112', + onClose: () => console.log('Close clicked'), + }, + beforeEach: () => { + // Set up a nickname in localStorage for this story + if (typeof window !== 'undefined') { + const nicknames = { So11111111111111111111111111111111111111112: 'SOL Token' }; + localStorage.setItem('solana-explorer-nicknames', JSON.stringify(nicknames)); + } + }, +}; + +export const LongAddress: Story = { + args: { + address: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + onClose: () => console.log('Close clicked'), + }, +}; +