Implement basic message search

Define gray color palette
pull/1/head
J M Rossy 2 years ago
parent f064843bbb
commit b999be0fcd
  1. 2
      .vscode/settings.json
  2. 2
      src/components/icons/ChainIcon.tsx
  3. 2
      src/components/input/SelectField.tsx
  4. 100
      src/features/search/MessageSearch.tsx
  5. 2
      src/features/search/MessageSummary.tsx
  6. 9
      src/styles/globals.css
  7. 69
      src/test/mockMessages.ts
  8. 4
      src/utils/time.ts
  9. 11
      tailwind.config.js

@ -1,6 +1,6 @@
{ {
"search.exclude": { "search.exclude": {
"**/node_modules": false "**/node_modules/**": true
}, },
"files.exclude": { "files.exclude": {
"**/*.js.map": true, "**/*.js.map": true,

@ -28,7 +28,7 @@ const CHAIN_TO_ICON = {
function _ChainIcon({ function _ChainIcon({
chainId, chainId,
size = 46, size = 44,
}: { }: {
chainId: number; chainId: number;
size?: number; size?: number;

@ -19,7 +19,7 @@ export function SelectField(props: Props) {
return ( return (
<select <select
className={`px-2 py-1.5 text-sm border border-gray-500 rounded invalid:text-gray-400 focus:outline-none ${ className={`px-2 py-1 text-sm border border-gray-500 rounded invalid:text-gray-400 focus:outline-none ${
classes || '' classes || ''
}`} }`}
{...passThruProps} {...passThruProps}

@ -1,13 +1,15 @@
import Image from 'next/future/image'; import Image from 'next/future/image';
import { ChangeEvent, useMemo, useState } from 'react'; import { ChangeEvent, useMemo, useState, useTransition } from 'react';
import { IconButton } from '../../components/buttons/IconButton';
import { SelectField } from '../../components/input/SelectField'; import { SelectField } from '../../components/input/SelectField';
import { Card } from '../../components/layout/Card';
import { prodChains } from '../../consts/networksConfig'; import { prodChains } from '../../consts/networksConfig';
import ArrowRightIcon from '../../images/icons/arrow-right-short.svg'; import ArrowRightIcon from '../../images/icons/arrow-right-short.svg';
import FunnelIcon from '../../images/icons/funnel.svg'; import FunnelIcon from '../../images/icons/funnel.svg';
import SearchIcon from '../../images/icons/search.svg'; import SearchIcon from '../../images/icons/search.svg';
import XIcon from '../../images/icons/x.svg';
import { MOCK_MESSAGES } from '../../test/mockMessages'; import { MOCK_MESSAGES } from '../../test/mockMessages';
import { Message } from '../../types';
import { MessageSummary } from './MessageSummary'; import { MessageSummary } from './MessageSummary';
@ -15,16 +17,30 @@ import { MessageSummary } from './MessageSummary';
// TODO loading and error states // TODO loading and error states
// TODO text grays with ting of green // TODO text grays with ting of green
export function MessageSearch() { export function MessageSearch() {
// Search state and handlers
const [isPending, startTransition] = useTransition();
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const onChangeSearch = (event: ChangeEvent<HTMLInputElement> | null) => {
const value = event?.target?.value || '';
setSearchInput(value);
startTransition(() => {
setSearchQuery(value);
});
};
const searchResults = useMemo(
() => findMessageWithValue(searchQuery, MOCK_MESSAGES),
[searchQuery],
);
// Filter state and handlers
const [originChainFilter, setOriginChainFilter] = useState(''); const [originChainFilter, setOriginChainFilter] = useState('');
const [destinationChainFilter, setDestinationChainFilter] = useState(''); const [destinationChainFilter, setDestinationChainFilter] = useState('');
const chainOptions = useMemo(getChainOptionList, []); const chainOptions = useMemo(getChainOptionList, []);
const onChangeSearch = (event: ChangeEvent<HTMLInputElement>) => {
setSearchInput(event.target.value);
};
const onChangeOriginFilter = (value: string) => { const onChangeOriginFilter = (value: string) => {
setOriginChainFilter(value); setOriginChainFilter(value);
}; };
@ -41,18 +57,43 @@ export function MessageSearch() {
onChange={onChangeSearch} onChange={onChangeSearch}
type="text" type="text"
placeholder="Search for messages by address or transaction hash" placeholder="Search for messages by address or transaction hash"
className="p-2 sm:p-4 flex-1 h-10 sm:h-12 rounded focus:outline-none" className="p-2 sm:px-4 md:px-5 flex-1 h-10 sm:h-12 rounded focus:outline-none"
/> />
<div className="bg-beige-500 h-10 sm:h-12 w-10 sm:w-12 flex items-center justify-center rounded"> <div className="bg-beige-500 h-10 sm:h-12 w-10 sm:w-12 flex items-center justify-center rounded">
<Image src={SearchIcon} alt="Search" width={20} height={20} /> {isPending && (
<Image src={SearchIcon} alt="Search" width={20} height={20} />
)}
{!isPending && !searchQuery && (
<Image src={SearchIcon} alt="Search" width={20} height={20} />
)}
{!isPending && searchQuery && (
<IconButton
imgSrc={XIcon}
title="Clear search"
width={28}
height={28}
onClick={() => onChangeSearch(null)}
/>
)}
</div> </div>
</div> </div>
<Card width="w-full" classes="mt-6 p-0"> <div
<div className="px-2 py-3 md:px-4 md:py-3 flex items-center justify-between border-b border-gray-300"> style={{ height: '38.05rem' }}
<h2 className="text-gray-800">Latest Messages</h2> className="w-full mt-5 bg-white shadow-md rounded overflow-auto"
>
<div className="px-2 py-3 sm:px-4 md:px-5 md:py-3 flex items-center justify-between border-b border-gray-100">
<h2 className="text-gray-800 black-shadow">
{!searchQuery ? 'Latest Messages' : 'Search Results'}
</h2>
<div className="flex items-center space-x-2 md:space-x-3"> <div className="flex items-center space-x-2 md:space-x-3">
<div className="w-px h-8 bg-gray-300"></div> <div className="w-px h-8 bg-gray-100"></div>
<Image src={FunnelIcon} alt="Filter" width={22} height={22} /> <Image
src={FunnelIcon}
alt="Filter"
width={22}
height={22}
className="opacity-50"
/>
<SelectField <SelectField
classes="w-24 md:w-32" classes="w-24 md:w-32"
options={chainOptions} options={chainOptions}
@ -64,6 +105,7 @@ export function MessageSearch() {
alt="Arrow-right" alt="Arrow-right"
width={30} width={30}
height={30} height={30}
className="opacity-50"
/> />
<SelectField <SelectField
classes="w-24 md:w-32" classes="w-24 md:w-32"
@ -73,15 +115,15 @@ export function MessageSearch() {
/> />
</div> </div>
</div> </div>
{MOCK_MESSAGES.map((m) => ( {searchResults.map((m) => (
<div <div
key={`message-${m.id}`} key={`message-${m.id}`}
className="px-2 py-2 md:px-4 md:py-3 border-b" className="px-2 py-2 sm:px-4 md:px-5 md:py-3 border-b border-gray-100"
> >
<MessageSummary message={m} /> <MessageSummary message={m} />
</div> </div>
))} ))}
</Card> </div>
</> </>
); );
} }
@ -92,3 +134,29 @@ function getChainOptionList(): Array<{ value: string; display: string }> {
...prodChains.map((c) => ({ value: c.id.toString(), display: c.name })), ...prodChains.map((c) => ({ value: c.id.toString(), display: c.name })),
]; ];
} }
// TODO move this to backend
function findMessageWithValue(query: string, messages: Message[]) {
if (!query) {
return messages
.sort((a, b) => b.originTimeSent - a.originTimeSent)
.slice(0, 8);
}
const normalizedQuery = query.trim().toLowerCase();
return messages
.filter((m) => {
return (
m.sender.toLowerCase().includes(normalizedQuery) ||
m.recipient.toLowerCase().includes(normalizedQuery) ||
m.originTransaction.transactionHash
.toLowerCase()
.includes(normalizedQuery) ||
(m.destinationTransaction &&
m.destinationTransaction.transactionHash
.toLowerCase()
.includes(normalizedQuery))
);
})
.sort((a, b) => b.originTimeSent - a.originTimeSent);
}

@ -42,7 +42,7 @@ export function MessageSummary({ message }: { message: Message }) {
{shortenAddress(recipient) || 'Invalid Address'} {shortenAddress(recipient) || 'Invalid Address'}
</div> </div>
</div> </div>
<div className={styles.valueContainer}> <div className={styles.valueContainer + ' w-28'}>
<div className={styles.label}>Time sent</div> <div className={styles.label}>Time sent</div>
<div className={styles.value}> <div className={styles.value}>
{getHumanReadableTimeString(originTimeSent)} {getHumanReadableTimeString(originTimeSent)}

@ -18,6 +18,15 @@ a {
text-decoration: none; text-decoration: none;
} }
/*
Text and shadows
================
*/
.black-shadow {
text-shadow: 0 0 #010101;
}
/* /*
Scrollbar Overrides Scrollbar Overrides
=================== ===================

@ -8,6 +8,15 @@ import {
} from '../consts/networksConfig'; } from '../consts/networksConfig';
import { Message, MessageStatus, PartialTransactionReceipt } from '../types'; import { Message, MessageStatus, PartialTransactionReceipt } from '../types';
function randomAddress() {
return (
'0x' +
[...Array(40)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join('')
);
}
export const MOCK_TX_HASH = export const MOCK_TX_HASH =
'0x0948a5377b757038b3f1a9948b8b5b2e5370c4d0801e68e005eb598048393d68'; '0x0948a5377b757038b3f1a9948b8b5b2e5370c4d0801e68e005eb598048393d68';
@ -23,8 +32,8 @@ export const MOCK_MESSAGES: Message[] = [
{ {
id: '1', id: '1',
status: MessageStatus.Delivered, status: MessageStatus.Delivered,
sender: constants.AddressZero, sender: randomAddress(),
recipient: constants.AddressZero, recipient: randomAddress(),
body: constants.AddressZero + constants.AddressZero + constants.AddressZero, body: constants.AddressZero + constants.AddressZero + constants.AddressZero,
originChainId: chain.mainnet.id, originChainId: chain.mainnet.id,
destinationChainId: chain.arbitrum.id, destinationChainId: chain.arbitrum.id,
@ -36,8 +45,8 @@ export const MOCK_MESSAGES: Message[] = [
{ {
id: '2', id: '2',
status: MessageStatus.Delivered, status: MessageStatus.Delivered,
sender: constants.AddressZero, sender: randomAddress(),
recipient: constants.AddressZero, recipient: randomAddress(),
body: constants.AddressZero + constants.AddressZero + constants.AddressZero, body: constants.AddressZero + constants.AddressZero + constants.AddressZero,
originChainId: chain.polygon.id, originChainId: chain.polygon.id,
destinationChainId: chain.optimism.id, destinationChainId: chain.optimism.id,
@ -49,8 +58,8 @@ export const MOCK_MESSAGES: Message[] = [
{ {
id: '3', id: '3',
status: MessageStatus.Delivered, status: MessageStatus.Delivered,
sender: constants.AddressZero, sender: randomAddress(),
recipient: constants.AddressZero, recipient: randomAddress(),
body: constants.AddressZero + constants.AddressZero + constants.AddressZero, body: constants.AddressZero + constants.AddressZero + constants.AddressZero,
originChainId: avalancheChain.id, originChainId: avalancheChain.id,
destinationChainId: celoMainnetChain.id, destinationChainId: celoMainnetChain.id,
@ -62,8 +71,8 @@ export const MOCK_MESSAGES: Message[] = [
{ {
id: '4', id: '4',
status: MessageStatus.Delivered, status: MessageStatus.Delivered,
sender: constants.AddressZero, sender: randomAddress(),
recipient: constants.AddressZero, recipient: randomAddress(),
body: constants.AddressZero + constants.AddressZero + constants.AddressZero, body: constants.AddressZero + constants.AddressZero + constants.AddressZero,
originChainId: bscChain.id, originChainId: bscChain.id,
destinationChainId: chain.mainnet.id, destinationChainId: chain.mainnet.id,
@ -75,8 +84,8 @@ export const MOCK_MESSAGES: Message[] = [
{ {
id: '5', id: '5',
status: MessageStatus.Failing, status: MessageStatus.Failing,
sender: constants.AddressZero, sender: randomAddress(),
recipient: constants.AddressZero, recipient: randomAddress(),
body: constants.AddressZero + constants.AddressZero + constants.AddressZero, body: constants.AddressZero + constants.AddressZero + constants.AddressZero,
originChainId: chain.mainnet.id, originChainId: chain.mainnet.id,
destinationChainId: chain.goerli.id, destinationChainId: chain.goerli.id,
@ -87,13 +96,49 @@ export const MOCK_MESSAGES: Message[] = [
{ {
id: '6', id: '6',
status: MessageStatus.Pending, status: MessageStatus.Pending,
sender: constants.AddressZero, sender: randomAddress(),
recipient: constants.AddressZero, recipient: randomAddress(),
body: constants.AddressZero + constants.AddressZero + constants.AddressZero, body: constants.AddressZero + constants.AddressZero + constants.AddressZero,
originChainId: chain.mainnet.id, originChainId: chain.mainnet.id,
destinationChainId: celoMainnetChain.id, destinationChainId: celoMainnetChain.id,
originTransaction: MOCK_TRANSACTION, originTransaction: MOCK_TRANSACTION,
destinationTransaction: undefined, destinationTransaction: undefined,
originTimeSent: Date.now() - 90_000,
},
{
id: '7',
status: MessageStatus.Pending,
sender: randomAddress(),
recipient: randomAddress(),
body: constants.AddressZero + constants.AddressZero + constants.AddressZero,
originChainId: chain.optimism.id,
destinationChainId: avalancheChain.id,
originTransaction: MOCK_TRANSACTION,
destinationTransaction: undefined,
originTimeSent: Date.now() - 80_000,
},
{
id: '8',
status: MessageStatus.Pending,
sender: randomAddress(),
recipient: randomAddress(),
body: constants.AddressZero + constants.AddressZero + constants.AddressZero,
originChainId: bscChain.id,
destinationChainId: avalancheChain.id,
originTransaction: MOCK_TRANSACTION,
destinationTransaction: undefined,
originTimeSent: Date.now(),
},
{
id: '9',
status: MessageStatus.Pending,
sender: randomAddress(),
recipient: randomAddress(),
body: constants.AddressZero + constants.AddressZero + constants.AddressZero,
originChainId: chain.arbitrum.id,
destinationChainId: chain.polygon.id,
originTransaction: MOCK_TRANSACTION,
destinationTransaction: undefined,
originTimeSent: Date.now(), originTimeSent: Date.now(),
}, },
]; ];

@ -1,6 +1,6 @@
// Inspired by https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site // Inspired by https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
export function getHumanReadableTimeString(timestamp: number) { export function getHumanReadableTimeString(timestamp: number) {
const seconds = Math.floor((new Date().getTime() - timestamp) / 1000); const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds <= 2) { if (seconds <= 2) {
return 'Just now'; return 'Just now';
@ -8,7 +8,7 @@ export function getHumanReadableTimeString(timestamp: number) {
if (seconds <= 60) { if (seconds <= 60) {
return `${seconds} seconds ago`; return `${seconds} seconds ago`;
} }
const minutes = Math.floor(seconds / 3600); const minutes = Math.floor(seconds / 60);
if (minutes <= 1) { if (minutes <= 1) {
return '1 minute ago'; return '1 minute ago';
} }

@ -48,6 +48,17 @@ module.exports = {
700: '#667260', 700: '#667260',
800: '#495245', 800: '#495245',
900: '#2B3129', 900: '#2B3129',
},
gray: {
100: '#E7E9E8',
200: '#CDD0CE',
300: '#B3B7B4',
400: '#999E9A',
500: '#7E8680',
600: '#666C67',
700: '#4D514E',
800: '#343734',
900: '#1B1D1C',
} }
}, },
spacing: { spacing: {

Loading…
Cancel
Save