Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 34 additions & 15 deletions app/components/common/Address.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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 => {
Expand All @@ -85,22 +91,35 @@ export function Address({
};

const content = (
<Copyable text={address} replaceText={!alignRight}>
<span
data-address={address}
className="font-monospace"
onMouseEnter={() => handleMouseEnter(address)}
onMouseLeave={() => handleMouseLeave(address)}
<div className="d-flex align-items-center gap-2">
<Copyable text={address} replaceText={!alignRight}>
<span
data-address={address}
className="font-monospace"
onMouseEnter={() => handleMouseEnter(address)}
onMouseLeave={() => handleMouseLeave(address)}
>
{link ? (
<Link className={truncate ? 'text-truncate address-truncate' : ''} href={addressPath}>
{displayText}
</Link>
) : (
<span className={truncate ? 'text-truncate address-truncate' : ''}>{displayText}</span>
)}
</span>
</Copyable>
<button
className="btn btn-sm btn-link p-0 text-muted"
onClick={() => setShowNicknameEditor(true)}
title="Edit nickname"
style={{ fontSize: '0.875rem', lineHeight: 1 }}
>
{link ? (
<Link className={truncate ? 'text-truncate address-truncate' : ''} href={addressPath}>
{addressLabel}
</Link>
) : (
<span className={truncate ? 'text-truncate address-truncate' : ''}>{addressLabel}</span>
)}
</span>
</Copyable>
<EditIcon />
</button>
{showNicknameEditor && (
<NicknameEditor address={address} onClose={() => setShowNicknameEditor(false)} />
)}
</div>
);

return (
Expand Down
5 changes: 5 additions & 0 deletions app/features/nicknames/index.ts
Original file line number Diff line number Diff line change
@@ -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';

135 changes: 135 additions & 0 deletions app/features/nicknames/lib/__tests__/nicknames.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});

54 changes: 54 additions & 0 deletions app/features/nicknames/lib/nicknames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use client';

/**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We started to split the logic inside the Explorer according to the FSD. I'd suggest moving it to the features/ directory.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restructured to the following FSD pattern:

  • app/features/nicknames/lib/ for utility functions
  • app/features/nicknames/model/ for hooks
  • app/features/nicknames/ui/ for components & stories
  • app/features/nicknames/index.ts for feature exports

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the 'use client' directive should be added here.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 'use client' directive to lib/nicknames.ts and model/use-nickname.ts.

* 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);
}
};

32 changes: 32 additions & 0 deletions app/features/nicknames/model/use-nickname.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(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;
}

27 changes: 27 additions & 0 deletions app/features/nicknames/ui/EditIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';

type Props = {
width?: number;
height?: number;
className?: string;
};

export function EditIcon({ width = 14, height = 14, className }: Props) {
return (
<svg
width={width}
height={height}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explorer uses react-feathers icons. So it is better to use this https://feathericons.com/?query=edit

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rogaldh updated!

Loading