Add account info into Account statuses page

This commit is contained in:
Lim Chee Aun 2023-03-11 14:05:56 +08:00
parent b4f8f92431
commit 6fd9c106c6
8 changed files with 401 additions and 194 deletions

View file

@ -16,7 +16,7 @@ import {
} from 'react-router-dom'; } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import Account from './components/account'; import AccountSheet from './components/account-sheet';
import Compose from './components/compose'; import Compose from './components/compose';
import Drafts from './components/drafts'; import Drafts from './components/drafts';
import Loader from './components/loader'; import Loader from './components/loader';
@ -409,7 +409,7 @@ function App() {
} }
}} }}
> >
<Account <AccountSheet
account={snapStates.showAccount?.account || snapStates.showAccount} account={snapStates.showAccount?.account || snapStates.showAccount}
instance={snapStates.showAccount?.instance} instance={snapStates.showAccount?.instance}
onClose={() => { onClose={() => {

View file

@ -1,5 +1,7 @@
import './account-block.css'; import './account-block.css';
import { useNavigate } from 'react-router-dom';
import emojifyText from '../utils/emojify-text'; import emojifyText from '../utils/emojify-text';
import niceDateTime from '../utils/nice-date-time'; import niceDateTime from '../utils/nice-date-time';
import states from '../utils/states'; import states from '../utils/states';
@ -12,6 +14,7 @@ function AccountBlock({
avatarSize = 'xl', avatarSize = 'xl',
instance, instance,
external, external,
internal,
onClick, onClick,
showActivity = false, showActivity = false,
}) { }) {
@ -22,13 +25,16 @@ function AccountBlock({
<span> <span>
<b></b> <b></b>
<br /> <br />
@ <span class="account-block-acct">@</span>
</span> </span>
</div> </div>
); );
} }
const navigate = useNavigate();
const { const {
id,
acct, acct,
avatar, avatar,
avatarStatic, avatarStatic,
@ -40,6 +46,7 @@ function AccountBlock({
lastStatusAt, lastStatusAt,
} = account; } = account;
const displayNameWithEmoji = emojifyText(displayName, emojis); const displayNameWithEmoji = emojifyText(displayName, emojis);
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
return ( return (
<a <a
@ -51,10 +58,14 @@ function AccountBlock({
if (external) return; if (external) return;
e.preventDefault(); e.preventDefault();
if (onClick) return onClick(e); if (onClick) return onClick(e);
if (internal) {
navigate(`/${instance}/a/${id}`);
} else {
states.showAccount = { states.showAccount = {
account, account,
instance, instance,
}; };
}
}} }}
> >
<Avatar url={avatar} size={avatarSize} /> <Avatar url={avatar} size={avatarSize} />
@ -68,7 +79,12 @@ function AccountBlock({
) : ( ) : (
<b>{username}</b> <b>{username}</b>
)} )}
<br />@{acct} <br />
<span class="account-block-acct">
@{acct1}
<wbr />
{acct2}
</span>
{showActivity && ( {showActivity && (
<> <>
<br /> <br />

View file

@ -0,0 +1,225 @@
.account-container {
display: flex;
flex-direction: column;
overflow: hidden;
max-width: 100%;
}
.account-container.skeleton {
color: var(--outline-color);
}
.account-container .header-banner {
/* pointer-events: none; */
aspect-ratio: 6 / 1;
width: 100%;
height: auto;
object-fit: cover;
/* mask fade out bottom of banner */
mask-image: linear-gradient(
to bottom,
hsl(0, 0%, 0%) 0%,
hsla(0, 0%, 0%, 0.987) 14%,
hsla(0, 0%, 0%, 0.951) 26.2%,
hsla(0, 0%, 0%, 0.896) 36.8%,
hsla(0, 0%, 0%, 0.825) 45.9%,
hsla(0, 0%, 0%, 0.741) 53.7%,
hsla(0, 0%, 0%, 0.648) 60.4%,
hsla(0, 0%, 0%, 0.55) 66.2%,
hsla(0, 0%, 0%, 0.45) 71.2%,
hsla(0, 0%, 0%, 0.352) 75.6%,
hsla(0, 0%, 0%, 0.259) 79.6%,
hsla(0, 0%, 0%, 0.175) 83.4%,
hsla(0, 0%, 0%, 0.104) 87.2%,
hsla(0, 0%, 0%, 0.049) 91.1%,
hsla(0, 0%, 0%, 0.013) 95.3%,
hsla(0, 0%, 0%, 0) 100%
);
margin-bottom: -44px;
}
.account-container .header-banner:hover {
animation: position-object 5s ease-in-out 1s 5;
}
@media (min-height: 480px) {
.account-container .header-banner {
aspect-ratio: 3 / 1;
}
}
.account-container header {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
text-shadow: -8px 0 12px -6px var(--bg-color), 8px 0 12px -6px var(--bg-color),
-8px 0 24px var(--header-color-3, --bg-color),
8px 0 24px var(--header-color-4, --bg-color);
}
.account-container header .avatar {
box-shadow: -8px 0 24px var(--header-color-3, --bg-color),
8px 0 24px var(--header-color-4, --bg-color);
}
.account-container .note {
font-size: 95%;
line-height: 1.4;
}
.account-container .note:not(:has(p)):not(:empty) {
/* Some notes don't have <p> tags, so we need to add some padding */
padding: 1em 0;
}
.account-container .stats {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
gap: 16px;
opacity: 0.75;
font-size: 90%;
background-color: var(--bg-faded-color);
padding: 12px;
border-radius: 8px;
line-height: 1.25;
}
.account-container .stats > * {
text-align: center;
}
.account-container .stats a {
color: inherit;
}
.account-container .actions {
display: flex;
gap: 8px;
justify-content: space-between;
min-height: 2.5em;
}
.account-container .actions button {
align-self: flex-end;
}
.account-container .profile-metadata {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.account-container .profile-field {
min-width: 0;
flex-grow: 1;
font-size: 90%;
background-color: var(--bg-faded-color);
padding: 12px;
border-radius: 8px;
filter: saturate(0.75);
line-height: 1.25;
}
.account-container :is(.note, .profile-field) .invisible {
display: none;
}
.account-container :is(.note, .profile-field) .ellipsis::after {
content: '…';
}
.account-container .profile-field b {
font-size: 90%;
color: var(--text-insignificant-color);
text-transform: uppercase;
}
.account-container .profile-field b .icon {
color: var(--green-color);
}
.account-container .profile-field p {
margin: 0;
}
.account-container .common-followers {
border-top: 1px solid var(--outline-color);
border-bottom: 1px solid var(--outline-color);
padding: 8px 0;
font-size: 90%;
line-height: 1.5;
color: var(--text-insignificant-color);
}
.timeline-start .account-container {
border-bottom: 1px solid var(--outline-color);
}
.timeline-start .account-container :is(header, main) {
padding: 16px 16px 4px;
}
.timeline-start .account-container .account-block .account-block-acct {
opacity: 0.5;
}
.timeline-start .account-container .actions {
min-height: 0;
}
@keyframes shine {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
.timeline-start .account-container {
position: relative;
overflow: hidden;
}
.timeline-start .account-container:before {
content: '';
position: absolute;
z-index: 2;
width: 100%;
height: 100%;
background-image: linear-gradient(
100deg,
rgba(255, 255, 255, 0) 30%,
rgba(255, 255, 255, 0.25),
rgba(255, 255, 255, 0) 70%
);
top: 0;
left: -100%;
pointer-events: none;
}
.timeline-start .account-container:hover:before {
animation: shine 1s ease-in-out 1s;
}
@media (min-width: 40em) {
.timeline-start .account-container {
--item-radius: 16px;
border: 1px solid var(--divider-color);
margin: 16px 0;
background-color: var(--bg-color);
border-radius: var(--item-radius);
overflow: hidden;
/* box-shadow: 0px 1px var(--bg-blur-color), 0 0 64px var(--bg-color); */
--shadow-offset: 16px;
--shadow-blur: 32px;
--shadow-spread: calc(var(--shadow-blur) * -0.75);
box-shadow: calc(var(--shadow-offset) * -1) var(--shadow-offset)
var(--shadow-blur) var(--shadow-spread)
var(--header-color-1, var(--drop-shadow-color)),
var(--shadow-offset) var(--shadow-offset) var(--shadow-blur)
var(--shadow-spread) var(--header-color-2, var(--drop-shadow-color));
}
.timeline-start .account-container .header-banner {
margin-bottom: -77px;
}
.timeline-start .account-container header .account-block {
font-size: 175%;
margin-bottom: -8px;
line-height: 1.1;
letter-spacing: -1px;
mix-blend-mode: multiply;
gap: 12px;
}
.timeline-start .account-container header .account-block .avatar {
width: 112px !important;
height: 112px !important;
}
}

View file

@ -1,7 +1,6 @@
import './account.css'; import './account-info.css';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { api } from '../utils/api'; import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text'; import emojifyText from '../utils/emojify-text';
@ -17,49 +16,32 @@ import Avatar from './avatar';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
function Account({ account, instance: propInstance, onClose }) { function AccountInfo({
const { masto, instance, authenticated } = api({ instance: propInstance }); account,
fetchAccount = () => {},
standalone,
instance,
authenticated,
}) {
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const isString = typeof account === 'string'; const isString = typeof account === 'string';
const [info, setInfo] = useState(isString ? null : account); const [info, setInfo] = useState(isString ? null : account);
useEffect(() => { useEffect(() => {
if (isString) { if (!isString) return;
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
const info = await masto.v1.accounts.lookup({ const info = await fetchAccount();
acct: account,
skip_webfinger: false,
});
setInfo(info); setInfo(info);
setUIState('default'); setUIState('default');
} catch (e) { } catch (e) {
try { console.error(e);
const result = await masto.v2.search({
q: account,
type: 'accounts',
limit: 1,
resolve: authenticated,
});
if (result.accounts.length) {
setInfo(result.accounts[0]);
setUIState('default');
return;
}
setInfo(null); setInfo(null);
setUIState('error'); setUIState('error');
} catch (err) {
console.error(err);
setInfo(null);
setUIState('error');
}
} }
})(); })();
} else { }, [isString, fetchAccount]);
setInfo(account);
}
}, [account]);
const { const {
acct, acct,
@ -84,13 +66,17 @@ function Account({ account, instance: propInstance, onClose }) {
username, username,
} = info || {}; } = info || {};
const escRef = useHotkeys('esc', onClose, [onClose]); const [headerCornerColors, setHeaderCornerColors] = useState([]);
return ( return (
<div <div
ref={escRef} class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
id="account-container" style={{
class={`sheet ${uiState === 'loading' ? 'skeleton' : ''}`} '--header-color-1': headerCornerColors[0],
'--header-color-2': headerCornerColors[1],
'--header-color-3': headerCornerColors[2],
'--header-color-4': headerCornerColors[3],
}}
> >
{uiState === 'error' && ( {uiState === 'error' && (
<div class="ui-state"> <div class="ui-state">
@ -128,7 +114,47 @@ function Account({ account, instance: propInstance, onClose }) {
alt="" alt=""
class="header-banner" class="header-banner"
onError={(e) => { onError={(e) => {
if (e.target.crossOrigin) {
if (e.target.src !== headerStatic) {
e.target.src = headerStatic; e.target.src = headerStatic;
} else {
e.target.removeAttribute('crossorigin');
e.target.src = header;
}
} else if (e.target.src !== headerStatic) {
e.target.src = headerStatic;
} else {
e.target.remove();
}
}}
crossOrigin="anonymous"
onLoad={(e) => {
try {
// Get color from four corners of image
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = e.target.width;
canvas.height = e.target.height;
ctx.drawImage(e.target, 0, 0);
const colors = [
ctx.getImageData(0, 0, 1, 1).data,
ctx.getImageData(e.target.width - 1, 0, 1, 1).data,
ctx.getImageData(0, e.target.height - 1, 1, 1).data,
ctx.getImageData(
e.target.width - 1,
e.target.height - 1,
1,
1,
).data,
];
const rgbColors = colors.map((color) => {
return `rgb(${color[0]}, ${color[1]}, ${color[2]}, 0.3)`;
});
setHeaderCornerColors(rgbColors);
console.log({ colors, rgbColors });
} catch (e) {
// Silently fail
}
}} }}
/> />
)} )}
@ -137,7 +163,8 @@ function Account({ account, instance: propInstance, onClose }) {
account={info} account={info}
instance={instance} instance={instance}
avatarSize="xxxl" avatarSize="xxxl"
external external={standalone}
internal={!standalone}
/> />
</header> </header>
<main tabIndex="-1"> <main tabIndex="-1">
@ -429,4 +456,4 @@ function RelatedActions({ info, instance, authenticated }) {
); );
} }
export default Account; export default AccountInfo;

View file

@ -0,0 +1,56 @@
import { useHotkeys } from 'react-hotkeys-hook';
import { api } from '../utils/api';
import AccountInfo from './account-info';
function AccountSheet({ account, instance: propInstance, onClose }) {
const { masto, instance, authenticated } = api({ instance: propInstance });
const isString = typeof account === 'string';
const escRef = useHotkeys('esc', onClose, [onClose]);
return (
<div
ref={escRef}
class="sheet"
onClick={(e) => {
const accountBlock = e.target.closest('.account-block');
if (accountBlock) {
onClose();
}
}}
>
<AccountInfo
instance={instance}
authenticated={authenticated}
account={account}
fetchAccount={async () => {
if (isString) {
try {
const info = await masto.v1.accounts.lookup({
acct: account,
skip_webfinger: false,
});
return info;
} catch (e) {
const result = await masto.v2.search({
q: account,
type: 'accounts',
limit: 1,
resolve: authenticated,
});
if (result.accounts.length) {
return result.accounts[0];
}
}
} else {
return account;
}
}}
/>
</div>
);
}
export default AccountSheet;

View file

@ -1,134 +0,0 @@
#account-container.skeleton {
color: var(--outline-color);
}
#account-container .header-banner {
/* pointer-events: none; */
aspect-ratio: 6 / 1;
width: 100%;
height: auto;
object-fit: cover;
/* mask fade out bottom of banner */
mask-image: linear-gradient(
to bottom,
hsl(0, 0%, 0%) 0%,
hsla(0, 0%, 0%, 0.987) 14%,
hsla(0, 0%, 0%, 0.951) 26.2%,
hsla(0, 0%, 0%, 0.896) 36.8%,
hsla(0, 0%, 0%, 0.825) 45.9%,
hsla(0, 0%, 0%, 0.741) 53.7%,
hsla(0, 0%, 0%, 0.648) 60.4%,
hsla(0, 0%, 0%, 0.55) 66.2%,
hsla(0, 0%, 0%, 0.45) 71.2%,
hsla(0, 0%, 0%, 0.352) 75.6%,
hsla(0, 0%, 0%, 0.259) 79.6%,
hsla(0, 0%, 0%, 0.175) 83.4%,
hsla(0, 0%, 0%, 0.104) 87.2%,
hsla(0, 0%, 0%, 0.049) 91.1%,
hsla(0, 0%, 0%, 0.013) 95.3%,
hsla(0, 0%, 0%, 0) 100%
);
margin-bottom: -44px;
}
#account-container .header-banner:hover {
animation: position-object 5s ease-in-out 1s 5;
}
@media (min-height: 480px) {
#account-container .header-banner {
aspect-ratio: 3 / 1;
}
}
#account-container header {
position: relative;
display: flex;
align-items: center;
gap: 8px;
text-shadow: 0 0 24px var(--bg-color);
}
#account-container header .avatar {
box-shadow: 0 0 24px var(--bg-color);
}
#account-container .note {
font-size: 95%;
line-height: 1.4;
}
#account-container .note:not(:has(p)):not(:empty) {
/* Some notes don't have <p> tags, so we need to add some padding */
padding: 1em 0;
}
#account-container .stats {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
gap: 16px;
opacity: 0.75;
font-size: 90%;
background-color: var(--bg-faded-color);
padding: 12px;
border-radius: 8px;
line-height: 1.25;
}
#account-container .stats > * {
text-align: center;
}
#account-container .stats a {
color: inherit;
}
#account-container .actions {
display: flex;
gap: 8px;
justify-content: space-between;
min-height: 2.5em;
}
#account-container .actions button {
align-self: flex-end;
}
#account-container .profile-metadata {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
#account-container .profile-field {
min-width: 0;
flex-grow: 1;
font-size: 90%;
background-color: var(--bg-faded-color);
padding: 12px;
border-radius: 8px;
filter: saturate(0.75);
line-height: 1.25;
}
#account-container :is(.note, .profile-field) .invisible {
display: none;
}
#account-container :is(.note, .profile-field) .ellipsis::after {
content: '…';
}
#account-container .profile-field b {
font-size: 90%;
color: var(--text-insignificant-color);
text-transform: uppercase;
}
#account-container .profile-field b .icon {
color: var(--green-color);
}
#account-container .profile-field p {
margin: 0;
}
#account-container .common-followers {
border-top: 1px solid var(--outline-color);
border-bottom: 1px solid var(--outline-color);
padding: 8px 0;
font-size: 90%;
line-height: 1.5;
color: var(--text-insignificant-color);
}

View file

@ -27,6 +27,7 @@ function Timeline({
checkForUpdatesInterval = 60_000, // 1 minute checkForUpdatesInterval = 60_000, // 1 minute
headerStart, headerStart,
headerEnd, headerEnd,
timelineStart,
}) { }) {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
@ -292,6 +293,7 @@ function Timeline({
</button> </button>
)} )}
</header> </header>
{!!timelineStart && <div class="timeline-start">{timelineStart}</div>}
{!!items.length ? ( {!!items.length ? (
<> <>
<ul class="timeline"> <ul class="timeline">

View file

@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import AccountInfo from '../components/account-info';
import Timeline from '../components/timeline'; import Timeline from '../components/timeline';
import { api } from '../utils/api'; import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text'; import emojifyText from '../utils/emojify-text';
@ -13,7 +14,7 @@ const LIMIT = 20;
function AccountStatuses() { function AccountStatuses() {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { id, ...params } = useParams(); const { id, ...params } = useParams();
const { masto, instance } = api({ instance: params.instance }); const { masto, instance, authenticated } = api({ instance: params.instance });
const accountStatusesIterator = useRef(); const accountStatusesIterator = useRef();
async function fetchAccountStatuses(firstLoad) { async function fetchAccountStatuses(firstLoad) {
const results = []; const results = [];
@ -27,7 +28,7 @@ function AccountStatuses() {
pinnedStatuses.forEach((status) => { pinnedStatuses.forEach((status) => {
status._pinned = true; status._pinned = true;
}); });
if (pinnedStatuses.length > 1) { if (pinnedStatuses.length >= 3) {
const pinnedStatusesIds = pinnedStatuses.map((status) => status.id); const pinnedStatusesIds = pinnedStatuses.map((status) => status.id);
results.push({ results.push({
id: pinnedStatusesIds, id: pinnedStatusesIds,
@ -54,7 +55,7 @@ function AccountStatuses() {
}; };
} }
const [account, setAccount] = useState({}); const [account, setAccount] = useState();
useTitle( useTitle(
`${account?.acct ? '@' + account.acct : 'Posts'}`, `${account?.acct ? '@' + account.acct : 'Posts'}`,
'/:instance?/a/:id', '/:instance?/a/:id',
@ -71,7 +72,20 @@ function AccountStatuses() {
})(); })();
}, [id]); }, [id]);
const { displayName, acct, emojis } = account; const { displayName, acct, emojis } = account || {};
const TimelineStart = useMemo(
() => (
<AccountInfo
instance={instance}
account={id}
fetchAccount={() => masto.v1.accounts.fetch(id)}
authenticated={authenticated}
standalone
/>
),
[id, instance, authenticated],
);
return ( return (
<Timeline <Timeline
@ -103,6 +117,7 @@ function AccountStatuses() {
errorText="Unable to load statuses" errorText="Unable to load statuses"
fetchItems={fetchAccountStatuses} fetchItems={fetchAccountStatuses}
boostsCarousel={snapStates.settings.boostsCarousel} boostsCarousel={snapStates.settings.boostsCarousel}
timelineStart={TimelineStart}
/> />
); );
} }