Skip to content

Commit

Permalink
feat: support NFTs and Uniques (#1564)
Browse files Browse the repository at this point in the history

Co-authored-by: Nick <[email protected]>
  • Loading branch information
AMIRKHANEF and Nick-1979 authored Nov 9, 2024
1 parent 2206c12 commit 7c38aac
Show file tree
Hide file tree
Showing 39 changed files with 2,301 additions and 54 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"Ekbatanifard",
"iadd",
"Infotip",
"IPFS",
"judgements",
"Kusama",
"Polkadot",
Expand Down
1 change: 1 addition & 0 deletions packages/extension-base/src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const START_WITH_PATH = [
'/send/',
'/stake/',
'/socialRecovery/',
'/nft/',
'/derivefs/'
] as const;

Expand Down
205 changes: 205 additions & 0 deletions packages/extension-polkagate/src/class/nftManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { ItemInformation, ItemMetadata, ItemOnChainInfo } from '../fullscreen/nft/utils/types';
import type { NftItemsType } from '../util/types';

// Define types for listener functions
type Listener = (address: string, nftItemsInformation: ItemInformation[]) => void;
type InitializationListener = () => void;

// Error class for NFT-specific errors
class NftManagerError extends Error {
constructor (message: string) {
super(message);
this.name = 'NftManagerError';
}
}

export default class NftManager {
// Store nft items and listeners
private nfts: NftItemsType = {};
private listeners = new Set<Listener>();
private initializationListeners = new Set<InitializationListener>();
private readonly STORAGE_KEY = 'nftItems';
private isInitialized = false;
private initializationPromise: Promise<void>;

constructor () {
// Load nft items from storage and set up storage change listener
this.initializationPromise = this.loadFromStorage();
chrome.storage.onChanged.addListener(this.handleStorageChange);
}

// Wait for initialization to complete
public async waitForInitialization (): Promise<void> {
return this.initializationPromise;
}

// Notify all listeners about initialization
private notifyInitializationListeners (): void {
this.initializationListeners.forEach((listener) => {
try {
listener();
} catch (error) {
console.error('Error in initialization listener:', error);
}
});
this.initializationListeners.clear();
}

// Load nft items from chrome storage
private async loadFromStorage (): Promise<void> {
try {
const result = await chrome.storage.local.get(this.STORAGE_KEY);

this.nfts = result[this.STORAGE_KEY] as NftItemsType || {};
this.isInitialized = true;

this.notifyInitializationListeners();
this.notifyListeners();
} catch (error) {
console.error('Failed to load NFT items from storage:', error);
throw new NftManagerError('Failed to load NFT items from storage');
}
}

// Save nft items to chrome storage with debouncing
private saveToStorage = (() => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;

return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}

// eslint-disable-next-line @typescript-eslint/no-misused-promises
timeoutId = setTimeout(async () => {
try {
await chrome.storage.local.set({ [this.STORAGE_KEY]: this.nfts });
} catch (error) {
console.error('Failed to save NFT items to storage:', error);
throw new NftManagerError('Failed to save NFT items to storage');
}
}, 1000); // Debounce for 1 second
};
})();

// Handle changes in chrome storage
private handleStorageChange = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
if (areaName === 'local' && changes[this.STORAGE_KEY]) {
this.nfts = changes[this.STORAGE_KEY].newValue as NftItemsType;
this.notifyListeners();
}
};

// Notify all listeners about nfts items changes
private notifyListeners (): void {
if (!this.isInitialized) {
return;
}

Object.entries(this.nfts).forEach(([address, nftItemsInformation]) => {
this.listeners.forEach((listener) => {
try {
listener(address, nftItemsInformation);
} catch (error) {
console.error('Error in listener:', error);
}
});
});
}

// Get nft items for a specific
get (address: string): ItemInformation[] | null | undefined {
if (!address) {
throw new NftManagerError('Address is required');
}

return address in this.nfts && this.nfts[address].length === 0
? null
: this.nfts?.[address];
}

// Get all nft items
getAll (): NftItemsType | null | undefined {
return this.nfts;
}

// Set on-chain nft item for a specific address
setOnChainItemsInfo (data: NftItemsType) {
if (!data) {
throw new NftManagerError('NFT items information are required to set on-chain information');
}

for (const address in data) {
if (!this.nfts[address]) {
this.nfts[address] = [];
}

const nftItemsInfo = data[address];

const existingItems = new Set(
this.nfts[address].map((item) => this.getItemKey(item))
);

const newItems = nftItemsInfo.filter(
(item) => !existingItems.has(this.getItemKey(item))
);

if (newItems.length > 0) {
this.nfts[address].push(...newItems);
this.saveToStorage();
this.notifyListeners();
}
}
}

private getItemKey (item: ItemOnChainInfo): string {
return `${item.chainName}-${item.collectionId}-${item.itemId}-${item.isNft}`;
}

// Set nft item detail for a specific address and item
setItemDetail (address: string, nftItemInfo: ItemInformation, nftItemDetail: ItemMetadata | null) {
if (!address || !nftItemInfo || nftItemDetail === undefined) {
throw new NftManagerError('Address, NFT item info, and detail are required');
}

if (!this.nfts[address]) {
return;
}

const itemIndex = this.nfts[address].findIndex(
(item) => this.getItemKey(item) === this.getItemKey(nftItemInfo)
);

if (itemIndex === -1) {
return;
}

this.nfts[address][itemIndex] = {
...this.nfts[address][itemIndex],
...(nftItemDetail ?? { noData: true })
};

this.saveToStorage();
this.notifyListeners();
}

// Subscribe a listener to endpoint changes
subscribe (listener: Listener) {
this.listeners.add(listener);
}

// Unsubscribe a listener from endpoint changes
unsubscribe (listener: Listener) {
this.listeners.delete(listener);
}

// Cleanup method to remove listeners and clear data
public destroy (): void {
chrome.storage.onChanged.removeListener(this.handleStorageChange);
this.listeners.clear();
this.nfts = {};
}
}
4 changes: 3 additions & 1 deletion packages/extension-polkagate/src/components/InputFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ interface Props {
placeholder: string;
value?: string;
withReset?: boolean;
disabled?: boolean;
theme: Theme;
}

export default function InputFilter ({ autoFocus = true, label, onChange, placeholder, theme, value, withReset = false }: Props) {
export default function InputFilter ({ autoFocus = true, disabled, label, onChange, placeholder, theme, value, withReset = false }: Props) {
const inputRef: React.RefObject<HTMLInputElement> | null = useRef(null);

const onChangeFilter = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
Expand All @@ -41,6 +42,7 @@ export default function InputFilter ({ autoFocus = true, label, onChange, placeh
autoCapitalize='off'
autoCorrect='off'
autoFocus={autoFocus}
disabled={disabled}
onChange={onChangeFilter}
placeholder={placeholder}
ref={inputRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ function AOC ({ accountAssets, address, hideNumbers, mode = 'Detail', onclick, s
}
}, [accountAssets]);

const shouldShowCursor = useMemo(() => (mode === 'Detail' && accountAssets && accountAssets.length > 5) || (mode !== 'Detail' && accountAssets && accountAssets.length > 6), [accountAssets, mode]);

return (
<Grid container item>
<Typography fontSize='18px' fontWeight={400} mt='13px' px='10px' width='fit-content'>
Expand All @@ -159,7 +161,7 @@ function AOC ({ accountAssets, address, hideNumbers, mode = 'Detail', onclick, s
</Collapse>
</Grid>
{!!accountAssets?.length &&
<Grid alignItems='center' container item justifyContent='center' onClick={toggleAssets} sx={{ cursor: 'pointer', width: '65px' }}>
<Grid alignItems='center' container item justifyContent='center' onClick={toggleAssets} sx={{ cursor: shouldShowCursor ? 'pointer' : 'default', width: '65px' }}>
{mode === 'Detail'
? accountAssets.length > 5 &&
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export default function AccountSetting ({ address, setDisplayPopup }: Props): Re
/>
<TaskButton
disabled={proxyDisable}
icon={<VaadinIcon icon='vaadin:sitemap' style={{ height: '30px', color: `${proxyDisable ? theme.palette.text.disabled : theme.palette.text.primary}` }} />}
icon={<VaadinIcon icon='vaadin:sitemap' style={{ color: `${proxyDisable ? theme.palette.text.disabled : theme.palette.text.primary}`, height: '30px' }} />}
onClick={onManageProxies}
secondaryIconType='page'
text={t('Manage proxies')}
Expand All @@ -116,26 +116,26 @@ export default function AccountSetting ({ address, setDisplayPopup }: Props): Re
/>
<TaskButton
disabled={hardwareOrExternalAccount}
icon={<VaadinIcon icon='vaadin:download-alt' style={{ height: '30px', color: `${hardwareOrExternalAccount ? theme.palette.text.disabled : theme.palette.text.primary}` }} />}
icon={<VaadinIcon icon='vaadin:download-alt' style={{ color: `${hardwareOrExternalAccount ? theme.palette.text.disabled : theme.palette.text.primary}`, height: '30px' }} />}
onClick={onExportAccount}
secondaryIconType='popup'
text={t('Export account')}
/>
<TaskButton
disabled={hardwareOrExternalAccount}
icon={<VaadinIcon icon='vaadin:road-branch' style={{ height: '30px', color: `${hardwareOrExternalAccount ? theme.palette.text.disabled : theme.palette.text.primary}` }} />}
icon={<VaadinIcon icon='vaadin:road-branch' style={{ color: `${hardwareOrExternalAccount ? theme.palette.text.disabled : theme.palette.text.primary}`, height: '30px' }} />}
onClick={goToDeriveAcc}
secondaryIconType='popup'
text={t('Derive new account')}
/>
<TaskButton
icon={<VaadinIcon icon='vaadin:edit' style={{ height: '30px', color: `${theme.palette.text.primary}` }} />}
icon={<VaadinIcon icon='vaadin:edit' style={{ color: `${theme.palette.text.primary}`, height: '30px' }} />}
onClick={onRenameAccount}
secondaryIconType='popup'
text={t('Rename')}
/>
<TaskButton
icon={<VaadinIcon icon='vaadin:file-remove' style={{ height: '30px', color: `${theme.palette.text.primary}` }} />}
icon={<VaadinIcon icon='vaadin:file-remove' style={{ color: `${theme.palette.text.primary}`, height: '30px' }} />}
noBorderButton
onClick={onForgetAccount}
secondaryIconType='popup'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import type { BalancesInfo } from 'extension-polkagate/src/util/types';
import type { FetchedBalance } from '../../../hooks/useAssetsBalances';

import { faCoins, faHistory, faPaperPlane, faVoteYea } from '@fortawesome/free-solid-svg-icons';
import { faCoins, faGem, faHistory, faPaperPlane, faVoteYea } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { ArrowForwardIosRounded as ArrowForwardIosRoundedIcon, Boy as BoyIcon, QrCode2 as QrCodeIcon } from '@mui/icons-material';
import { Divider, Grid, Typography, useTheme } from '@mui/material';
Expand Down Expand Up @@ -55,7 +55,6 @@ export const openOrFocusTab = (relativeUrl: string, closeCurrentTab?: boolean):
return tab.url === tabUrl;
});


if (existingTab?.id) {
chrome.tabs.update(existingTab.id, { active: true }).catch(console.error);
} else {
Expand Down Expand Up @@ -161,6 +160,10 @@ export default function CommonTasks ({ address, assetId, balance, genesisHash, s
address && !stakingDisabled && openOrFocusTab(`/poolfs/${address}/`);
}, [address, stakingDisabled]);

const onNFTAlbum = useCallback(() => {
address && openOrFocusTab(`/nft/${address}`);
}, [address]);

const goToHistory = useCallback(() => {
address && genesisHash && setDisplayPopup(popupNumbers.HISTORY);
}, [address, genesisHash, setDisplayPopup]);
Expand Down Expand Up @@ -252,6 +255,19 @@ export default function CommonTasks ({ address, assetId, balance, genesisHash, s
show={(hasSoloStake || hasPoolStake) && !stakingDisabled}
text={t('Stake in Pool')}
/>
<TaskButton
disabled={false} // We check NFTs across all supported chains, so this feature is not specific to the current chain and should not be disabled.
icon={
<FontAwesomeIcon
color={theme.palette.text.primary}
fontSize='28px'
icon={faGem}
/>
}
onClick={onNFTAlbum}
secondaryIconType='page'
text={t('NFT album')}
/>
<TaskButton
disabled={!genesisHash}
icon={
Expand Down
Loading

0 comments on commit 7c38aac

Please sign in to comment.