diff --git a/src/app.jsx b/src/app.jsx index 350c3f12..d2a19ca4 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -16,7 +16,7 @@ import { } from 'react-router-dom'; import { useSnapshot } from 'valtio'; -import Account from './components/account'; +import AccountSheet from './components/account-sheet'; import Compose from './components/compose'; import Drafts from './components/drafts'; import Loader from './components/loader'; @@ -409,7 +409,7 @@ function App() { } }} > - { diff --git a/src/components/account-block.jsx b/src/components/account-block.jsx index 84d3792b..b4c40a0a 100644 --- a/src/components/account-block.jsx +++ b/src/components/account-block.jsx @@ -1,5 +1,7 @@ import './account-block.css'; +import { useNavigate } from 'react-router-dom'; + import emojifyText from '../utils/emojify-text'; import niceDateTime from '../utils/nice-date-time'; import states from '../utils/states'; @@ -12,6 +14,7 @@ function AccountBlock({ avatarSize = 'xl', instance, external, + internal, onClick, showActivity = false, }) { @@ -22,13 +25,16 @@ function AccountBlock({ ████████
- @██████ +
); } + const navigate = useNavigate(); + const { + id, acct, avatar, avatarStatic, @@ -40,6 +46,7 @@ function AccountBlock({ lastStatusAt, } = account; const displayNameWithEmoji = emojifyText(displayName, emojis); + const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct]; return ( @@ -68,7 +79,12 @@ function AccountBlock({ ) : ( {username} )} -
@{acct} +
+ {showActivity && ( <>
diff --git a/src/components/account-info.css b/src/components/account-info.css new file mode 100644 index 00000000..1e8ddd58 --- /dev/null +++ b/src/components/account-info.css @@ -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

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; + } +} diff --git a/src/components/account.jsx b/src/components/account-info.jsx similarity index 80% rename from src/components/account.jsx rename to src/components/account-info.jsx index 1f787c82..8c84ce4e 100644 --- a/src/components/account.jsx +++ b/src/components/account-info.jsx @@ -1,7 +1,6 @@ -import './account.css'; +import './account-info.css'; import { useEffect, useRef, useState } from 'preact/hooks'; -import { useHotkeys } from 'react-hotkeys-hook'; import { api } from '../utils/api'; import emojifyText from '../utils/emojify-text'; @@ -17,49 +16,32 @@ import Avatar from './avatar'; import Icon from './icon'; import Link from './link'; -function Account({ account, instance: propInstance, onClose }) { - const { masto, instance, authenticated } = api({ instance: propInstance }); +function AccountInfo({ + account, + fetchAccount = () => {}, + standalone, + instance, + authenticated, +}) { const [uiState, setUIState] = useState('default'); const isString = typeof account === 'string'; const [info, setInfo] = useState(isString ? null : account); useEffect(() => { - if (isString) { - setUIState('loading'); - (async () => { - try { - const info = await masto.v1.accounts.lookup({ - acct: account, - skip_webfinger: false, - }); - setInfo(info); - setUIState('default'); - } catch (e) { - try { - 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); - setUIState('error'); - } catch (err) { - console.error(err); - setInfo(null); - setUIState('error'); - } - } - })(); - } else { - setInfo(account); - } - }, [account]); + if (!isString) return; + setUIState('loading'); + (async () => { + try { + const info = await fetchAccount(); + setInfo(info); + setUIState('default'); + } catch (e) { + console.error(e); + setInfo(null); + setUIState('error'); + } + })(); + }, [isString, fetchAccount]); const { acct, @@ -84,13 +66,17 @@ function Account({ account, instance: propInstance, onClose }) { username, } = info || {}; - const escRef = useHotkeys('esc', onClose, [onClose]); + const [headerCornerColors, setHeaderCornerColors] = useState([]); return (

{uiState === 'error' && (
@@ -128,7 +114,47 @@ function Account({ account, instance: propInstance, onClose }) { alt="" class="header-banner" onError={(e) => { - e.target.src = headerStatic; + if (e.target.crossOrigin) { + if (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} instance={instance} avatarSize="xxxl" - external + external={standalone} + internal={!standalone} />
@@ -429,4 +456,4 @@ function RelatedActions({ info, instance, authenticated }) { ); } -export default Account; +export default AccountInfo; diff --git a/src/components/account-sheet.jsx b/src/components/account-sheet.jsx new file mode 100644 index 00000000..d202866c --- /dev/null +++ b/src/components/account-sheet.jsx @@ -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 ( +
{ + const accountBlock = e.target.closest('.account-block'); + if (accountBlock) { + onClose(); + } + }} + > + { + 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; + } + }} + /> +
+ ); +} + +export default AccountSheet; diff --git a/src/components/account.css b/src/components/account.css deleted file mode 100644 index f16dc935..00000000 --- a/src/components/account.css +++ /dev/null @@ -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

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); -} diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx index 491b72be..52911397 100644 --- a/src/components/timeline.jsx +++ b/src/components/timeline.jsx @@ -27,6 +27,7 @@ function Timeline({ checkForUpdatesInterval = 60_000, // 1 minute headerStart, headerEnd, + timelineStart, }) { const [items, setItems] = useState([]); const [uiState, setUIState] = useState('default'); @@ -292,6 +293,7 @@ function Timeline({ )} + {!!timelineStart &&

{timelineStart}
} {!!items.length ? ( <>
    diff --git a/src/pages/account-statuses.jsx b/src/pages/account-statuses.jsx index 5561849f..02b33a66 100644 --- a/src/pages/account-statuses.jsx +++ b/src/pages/account-statuses.jsx @@ -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 { useSnapshot } from 'valtio'; +import AccountInfo from '../components/account-info'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; import emojifyText from '../utils/emojify-text'; @@ -13,7 +14,7 @@ const LIMIT = 20; function AccountStatuses() { const snapStates = useSnapshot(states); const { id, ...params } = useParams(); - const { masto, instance } = api({ instance: params.instance }); + const { masto, instance, authenticated } = api({ instance: params.instance }); const accountStatusesIterator = useRef(); async function fetchAccountStatuses(firstLoad) { const results = []; @@ -27,7 +28,7 @@ function AccountStatuses() { pinnedStatuses.forEach((status) => { status._pinned = true; }); - if (pinnedStatuses.length > 1) { + if (pinnedStatuses.length >= 3) { const pinnedStatusesIds = pinnedStatuses.map((status) => status.id); results.push({ id: pinnedStatusesIds, @@ -54,7 +55,7 @@ function AccountStatuses() { }; } - const [account, setAccount] = useState({}); + const [account, setAccount] = useState(); useTitle( `${account?.acct ? '@' + account.acct : 'Posts'}`, '/:instance?/a/:id', @@ -71,7 +72,20 @@ function AccountStatuses() { })(); }, [id]); - const { displayName, acct, emojis } = account; + const { displayName, acct, emojis } = account || {}; + + const TimelineStart = useMemo( + () => ( + masto.v1.accounts.fetch(id)} + authenticated={authenticated} + standalone + /> + ), + [id, instance, authenticated], + ); return ( ); }