Commit a8412d24 authored by tom goriunov's avatar tom goriunov Committed by GitHub

configurable address icon style (#1199)

* base implementation

* make use of react-identicons

* add NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY to envs schema

* add NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY to .env.example

* fix tests
parent 1d870916
...@@ -4,4 +4,5 @@ NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx ...@@ -4,4 +4,5 @@ NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=xxx NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=xxx
NEXT_PUBLIC_AUTH0_CLIENT_ID=xxx NEXT_PUBLIC_AUTH0_CLIENT_ID=xxx
\ No newline at end of file NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY=xxx
\ No newline at end of file
import type { IdenticonType } from 'types/views/address';
import { IDENTICON_TYPES } from 'types/views/address';
import { getEnvValue } from 'configs/app/utils';
const identiconType: IdenticonType = (() => {
const value = getEnvValue(process.env.NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE);
return IDENTICON_TYPES.find((type) => value === type) || 'jazzicon';
})();
const config = Object.freeze({
identiconType: identiconType,
});
export default config;
export { default as block } from './block'; export { default as block } from './block';
export { default as address } from './address';
...@@ -31,6 +31,8 @@ NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-c ...@@ -31,6 +31,8 @@ NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-c
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/network-icons/base.svg NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/network-icons/base.svg
## footer ## footer
## misc ## misc
## views
NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE=gradient_avatar
# app features # app features
NEXT_PUBLIC_APP_INSTANCE=local NEXT_PUBLIC_APP_INSTANCE=local
......
...@@ -10,6 +10,7 @@ import { SUPPORTED_WALLETS } from '../../../types/client/wallets'; ...@@ -10,6 +10,7 @@ import { SUPPORTED_WALLETS } from '../../../types/client/wallets';
import type { CustomLink, CustomLinksGroup } from '../../../types/footerLinks'; import type { CustomLink, CustomLinksGroup } from '../../../types/footerLinks';
import type { ChainIndicatorId } from '../../../types/homepage'; import type { ChainIndicatorId } from '../../../types/homepage';
import { type NetworkVerificationType, type NetworkExplorer, type FeaturedNetwork, NETWORK_GROUPS } from '../../../types/networks'; import { type NetworkVerificationType, type NetworkExplorer, type FeaturedNetwork, NETWORK_GROUPS } from '../../../types/networks';
import { IDENTICON_TYPES } from '../../../types/views/address';
import { BLOCK_FIELDS_IDS } from '../../../types/views/block'; import { BLOCK_FIELDS_IDS } from '../../../types/views/block';
import type { BlockFieldId } from '../../../types/views/block'; import type { BlockFieldId } from '../../../types/views/block';
...@@ -294,6 +295,7 @@ const schema = yup ...@@ -294,6 +295,7 @@ const schema = yup
.transform(getEnvValue) .transform(getEnvValue)
.json() .json()
.of(yup.string<BlockFieldId>().oneOf(BLOCK_FIELDS_IDS)), .of(yup.string<BlockFieldId>().oneOf(BLOCK_FIELDS_IDS)),
NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE: yup.string().oneOf(IDENTICON_TYPES),
// e. misc // e. misc
NEXT_PUBLIC_NETWORK_EXPLORERS: yup NEXT_PUBLIC_NETWORK_EXPLORERS: yup
......
...@@ -130,3 +130,5 @@ frontend: ...@@ -130,3 +130,5 @@ frontend:
_default: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY _default: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY
NEXT_PUBLIC_WEB3_WALLETS: NEXT_PUBLIC_WEB3_WALLETS:
_default: "['token_pocket','coinbase','metamask']" _default: "['token_pocket','coinbase','metamask']"
NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE:
_default: gradient_avatar
...@@ -163,6 +163,14 @@ By default, the app has generic favicon. You can override this behavior by provi ...@@ -163,6 +163,14 @@ By default, the app has generic favicon. You can override this behavior by provi
&nbsp; &nbsp;
#### Address views
| Variable | Type | Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE | `"github" \| "jazzicon" \| "gradient_avatar" \| "blockie"` | Style of address identicon appearance. Choose between [GitHub](https://github.blog/2013-08-14-identicons/), [Metamask Jazzicon](https://metamask.github.io/jazzicon/), [Gradient Avatar](https://github.com/varld/gradient-avatar) and [Ethereum Blocky](https://mycryptohq.github.io/ethereum-blockies-base64/) | - | `jazzicon` | `gradient_avatar` |
&nbsp;
### Misc ### Misc
| Variable | Type| Description | Compulsoriness | Default value | Example value | | Variable | Type| Description | Compulsoriness | Default value | Example value |
......
import type { ArrayElement } from 'types/utils';
export const IDENTICON_TYPES = [
'github',
'jazzicon',
'gradient_avatar',
'blockie',
] as const;
export type IdenticonType = ArrayElement<typeof IDENTICON_TYPES>;
...@@ -27,7 +27,13 @@ base.describe('base view', () => { ...@@ -27,7 +27,13 @@ base.describe('base view', () => {
base.beforeEach(async({ page, mount }) => { base.beforeEach(async({ page, mount }) => {
await page.route(API_URL, (route) => route.fulfill({ await page.route(API_URL, (route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify({ items: [ txMock.base, txMock.base ], next_page_params: { block: 1 } }), body: JSON.stringify({ items: [
txMock.base,
{
...txMock.base,
hash: '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3194',
},
], next_page_params: { block: 1 } }),
})); }));
component = await mount( component = await mount(
......
import { useColorModeValue, useToken, Box, chakra, Skeleton } from '@chakra-ui/react';
import dynamic from 'next/dynamic';
import React from 'react';
const Identicon = dynamic<{ bg: string; string: string; size: number }>(
async() => {
const lib = await import('react-identicons');
return typeof lib === 'object' && 'default' in lib ? lib.default : lib;
},
{
loading: () => <Skeleton w="100%" h="100%"/>,
ssr: false,
},
);
interface Props {
className?: string;
size: number;
seed: string;
}
const IdenticonGithub = ({ size, seed }: Props) => {
const bgColor = useToken('colors', useColorModeValue('gray.100', 'white'));
return (
<Box
boxSize={ `${ size * 2 }px` }
transformOrigin="left top"
transform="scale(0.5)"
borderRadius="full"
overflow="hidden"
>
<Identicon
bg={ bgColor }
string={ seed }
// the displayed size is doubled for retina displays and then scaled down
size={ size * 2 }
/>
</Box>
);
};
export default React.memo(chakra(IdenticonGithub));
import { useColorModeValue, useToken, SkeletonCircle, Image, Box } from '@chakra-ui/react'; import { SkeletonCircle, Image } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import Identicon from 'react-identicons';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import * as cookies from 'lib/cookies'; import * as cookies from 'lib/cookies';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo'; import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import IdenticonGithub from 'ui/shared/IdenticonGithub';
const IdenticonComponent = typeof Identicon === 'object' && 'default' in Identicon ? Identicon.default : Identicon;
// for those who haven't got profile
// or if we cannot download the profile picture for some reasons
const FallbackImage = ({ size, id }: { size: number; id: string }) => {
const bgColor = useToken('colors', useColorModeValue('gray.100', 'white'));
return (
<Box
flexShrink={ 0 }
maxWidth={ `${ size }px` }
maxHeight={ `${ size }px` }
>
<Box boxSize={ `${ size * 2 }px` } transformOrigin="left top" transform="scale(0.5)" borderRadius="full" overflow="hidden">
<IdenticonComponent
bg={ bgColor }
string={ id }
// the displayed size is doubled for retina displays and then scaled down
size={ size * 2 }
/>
</Box>
</Box>
);
};
interface Props { interface Props {
size: number; size: number;
...@@ -56,13 +31,10 @@ const UserAvatar = ({ size }: Props) => { ...@@ -56,13 +31,10 @@ const UserAvatar = ({ size }: Props) => {
flexShrink={ 0 } flexShrink={ 0 }
src={ data?.avatar } src={ data?.avatar }
alt={ `Profile picture of ${ data?.name || data?.nickname || '' }` } alt={ `Profile picture of ${ data?.name || data?.nickname || '' }` }
w={ sizeString } boxSize={ `${ size }px` }
minW={ sizeString }
h={ sizeString }
minH={ sizeString }
borderRadius="full" borderRadius="full"
overflow="hidden" overflow="hidden"
fallback={ isImageLoadError || !data?.avatar ? <FallbackImage size={ size } id={ data?.email || 'randomness' }/> : undefined } fallback={ isImageLoadError || !data?.avatar ? <IdenticonGithub size={ size } seed={ data?.email || 'randomness' } flexShrink={ 0 }/> : undefined }
onError={ handleImageLoadError } onError={ handleImageLoadError }
/> />
); );
......
...@@ -2,7 +2,6 @@ import type { As } from '@chakra-ui/react'; ...@@ -2,7 +2,6 @@ import type { As } from '@chakra-ui/react';
import { Flex, Skeleton, Tooltip, chakra } from '@chakra-ui/react'; import { Flex, Skeleton, Tooltip, chakra } from '@chakra-ui/react';
import _omit from 'lodash/omit'; import _omit from 'lodash/omit';
import React from 'react'; import React from 'react';
import Jazzicon, { jsNumberForAddress } from 'react-jazzicon';
import type { AddressParam } from 'types/api/addressParams'; import type { AddressParam } from 'types/api/addressParams';
...@@ -14,6 +13,7 @@ import iconContract from 'icons/contract.svg'; ...@@ -14,6 +13,7 @@ import iconContract from 'icons/contract.svg';
import * as EntityBase from 'ui/shared/entities/base/components'; import * as EntityBase from 'ui/shared/entities/base/components';
import { getIconProps } from '../base/utils'; import { getIconProps } from '../base/utils';
import AddressIdenticon from './AddressIdenticon';
type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'address'>; type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'address'>;
...@@ -88,8 +88,11 @@ const Icon = (props: IconProps) => { ...@@ -88,8 +88,11 @@ const Icon = (props: IconProps) => {
return ( return (
<Tooltip label={ props.address.implementation_name }> <Tooltip label={ props.address.implementation_name }>
<Flex { ...styles }> <Flex marginRight={ styles.marginRight }>
<Jazzicon diameter={ props.iconSize === 'lg' ? 30 : 20 } seed={ jsNumberForAddress(props.address.hash) }/> <AddressIdenticon
size={ props.iconSize === 'lg' ? 30 : 20 }
hash={ props.address.hash }
/>
</Flex> </Flex>
</Tooltip> </Tooltip>
); );
......
import { Box, Image } from '@chakra-ui/react';
import dynamic from 'next/dynamic';
import React from 'react';
import config from 'configs/app';
import IdenticonGithub from 'ui/shared/IdenticonGithub';
interface IconProps {
hash: string;
size: number;
}
const Icon = dynamic(
async() => {
switch (config.UI.views.address.identiconType) {
case 'github': {
// eslint-disable-next-line react/display-name
return (props: IconProps) => <IdenticonGithub size={ props.size } seed={ props.hash }/>;
}
case 'blockie': {
const makeBlockie = (await import('ethereum-blockies-base64')).default;
// eslint-disable-next-line react/display-name
return (props: IconProps) => {
const data = makeBlockie(props.hash);
return (
<Image
src={ data }
alt={ `Identicon for ${ props.hash }}` }
/>
);
};
}
case 'jazzicon': {
const Jazzicon = await import('react-jazzicon');
// eslint-disable-next-line react/display-name
return (props: IconProps) => {
return (
<Jazzicon.default
diameter={ props.size }
seed={ Jazzicon.jsNumberForAddress(props.hash) }
/>
);
};
}
case 'gradient_avatar': {
const GradientAvatar = (await import('gradient-avatar')).default;
// eslint-disable-next-line react/display-name
return (props: IconProps) => {
const svg = GradientAvatar(props.hash, props.size);
return <div dangerouslySetInnerHTML={{ __html: svg }}/>;
};
}
default: {
return () => null;
}
}
}, {
ssr: false,
});
type Props = IconProps;
const AddressIdenticon = ({ size, hash }: Props) => {
return (
<Box boxSize={ `${ size }px` } borderRadius="full" overflow="hidden">
<Icon size={ size } hash={ hash }/>
</Box>
);
};
export default React.memo(AddressIdenticon);
...@@ -44,8 +44,8 @@ const ProfileMenuDesktop = () => { ...@@ -44,8 +44,8 @@ const ProfileMenuDesktop = () => {
<PopoverTrigger> <PopoverTrigger>
<Button <Button
variant="unstyled" variant="unstyled"
display="inline-flex" display="block"
height="auto" boxSize="50px"
flexShrink={ 0 } flexShrink={ 0 }
{ ...buttonProps } { ...buttonProps }
> >
......
...@@ -46,7 +46,9 @@ const ProfileMenuMobile = () => { ...@@ -46,7 +46,9 @@ const ProfileMenuMobile = () => {
<Box padding={ 2 } onClick={ hasMenu ? onOpen : undefined }> <Box padding={ 2 } onClick={ hasMenu ? onOpen : undefined }>
<Button <Button
variant="unstyled" variant="unstyled"
height="auto" display="block"
boxSize="24px"
flexShrink={ 0 }
{ ...buttonProps } { ...buttonProps }
> >
<UserAvatar size={ 24 }/> <UserAvatar size={ 24 }/>
......
...@@ -7762,6 +7762,13 @@ eth-rpc-errors@^4.0.2: ...@@ -7762,6 +7762,13 @@ eth-rpc-errors@^4.0.2:
dependencies: dependencies:
fast-safe-stringify "^2.0.6" fast-safe-stringify "^2.0.6"
ethereum-blockies-base64@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/ethereum-blockies-base64/-/ethereum-blockies-base64-1.0.2.tgz#4aebca52142bf4d16a3144e6e2b59303e39ed2b3"
integrity sha512-Vg2HTm7slcWNKaRhCUl/L3b4KrB8ohQXdd5Pu3OI897EcR6tVRvUqdTwAyx+dnmoDzj8e2bwBLDQ50ByFmcz6w==
dependencies:
pnglib "0.0.1"
ethereum-cryptography@^2.0.0: ethereum-cryptography@^2.0.0:
version "2.1.2" version "2.1.2"
resolved "https://registry.yarnpkg.com/ethereum-cryptography/-/ethereum-cryptography-2.1.2.tgz#18fa7108622e56481157a5cb7c01c0c6a672eb67" resolved "https://registry.yarnpkg.com/ethereum-cryptography/-/ethereum-cryptography-2.1.2.tgz#18fa7108622e56481157a5cb7c01c0c6a672eb67"
...@@ -8353,6 +8360,15 @@ graceful-fs@^4.2.4, graceful-fs@^4.2.9: ...@@ -8353,6 +8360,15 @@ graceful-fs@^4.2.4, graceful-fs@^4.2.9:
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
gradient-avatar@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/gradient-avatar/-/gradient-avatar-1.0.2.tgz#42bb408e402b1f21aafba3878858721055515224"
integrity sha512-Od9KI2YImV60wnsvU/u6GEyBm2fiHUUHgiLySE243GYl/T/tiJMJ5QYey8o7tepugmlnUGQRaCItHv19UnUjUg==
dependencies:
hsl-rgb "^1.0.0"
hsl-triad "^1.0.0"
string-hash "^1.1.3"
grapheme-splitter@^1.0.4: grapheme-splitter@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
...@@ -8487,6 +8503,16 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react- ...@@ -8487,6 +8503,16 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-
dependencies: dependencies:
react-is "^16.7.0" react-is "^16.7.0"
hsl-rgb@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/hsl-rgb/-/hsl-rgb-1.0.0.tgz#05ca49f6d960c9d8e237f27d7bdf284b656de5ab"
integrity sha512-cNq+7sfwzSDoiG/jiu8wZpOmjScUZrMKiI33tH3aQ1MZsXWQd0yJjMpPwu2OZFYa4D/bOT1aCbB5gS1kOqFx1A==
hsl-triad@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/hsl-triad/-/hsl-triad-1.0.0.tgz#0d27f397f75e8beb6cf9c361970ffbe104652220"
integrity sha512-PKnjrMugS6sHC5dVh4VQZYOHEKG2QILjVwbpEtNjEV19RyswuIxrIiGhumVJjya/FjK/p9gX6+zRMXFGTvaQAA==
html-encoding-sniffer@^3.0.0: html-encoding-sniffer@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9"
...@@ -10665,6 +10691,11 @@ pngjs@^5.0.0: ...@@ -10665,6 +10691,11 @@ pngjs@^5.0.0:
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
pnglib@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/pnglib/-/pnglib-0.0.1.tgz#f9ab6f9c688f4a9d579ad8be28878a716e30c096"
integrity sha512-95ChzOoYLOPIyVmL+Y6X+abKGXUJlvOVLkB1QQkyXl7Uczc6FElUy/x01NS7r2GX6GRezloO/ecCX9h4U9KadA==
popmotion@11.0.3: popmotion@11.0.3:
version "11.0.3" version "11.0.3"
resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.3.tgz#565c5f6590bbcddab7a33a074bb2ba97e24b0cc9" resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.3.tgz#565c5f6590bbcddab7a33a074bb2ba97e24b0cc9"
...@@ -11912,6 +11943,11 @@ string-argv@^0.3.1: ...@@ -11912,6 +11943,11 @@ string-argv@^0.3.1:
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==
string-hash@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b"
integrity sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==
string-length@^4.0.1: string-length@^4.0.1:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment