mirror of
https://github.com/cheeaun/phanpy.git
synced 2024-12-19 22:32:09 +03:00
c2e6d732c4
Expecting bugs!
2262 lines
74 KiB
JavaScript
2262 lines
74 KiB
JavaScript
import './account-info.css';
|
|
|
|
import { msg, plural, t, Trans } from '@lingui/macro';
|
|
import { useLingui } from '@lingui/react';
|
|
import { MenuDivider, MenuItem } from '@szhsin/react-menu';
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useReducer,
|
|
useRef,
|
|
useState,
|
|
} from 'preact/hooks';
|
|
import punycode from 'punycode/';
|
|
|
|
import { api } from '../utils/api';
|
|
import enhanceContent from '../utils/enhance-content';
|
|
import getHTMLText from '../utils/getHTMLText';
|
|
import handleContentLinks from '../utils/handle-content-links';
|
|
import i18nDuration from '../utils/i18n-duration';
|
|
import { getLists } from '../utils/lists';
|
|
import niceDateTime from '../utils/nice-date-time';
|
|
import pmem from '../utils/pmem';
|
|
import shortenNumber from '../utils/shorten-number';
|
|
import showCompose from '../utils/show-compose';
|
|
import showToast from '../utils/show-toast';
|
|
import states, { hideAllModals } from '../utils/states';
|
|
import store from '../utils/store';
|
|
import { getCurrentAccountID, updateAccount } from '../utils/store-utils';
|
|
import supports from '../utils/supports';
|
|
|
|
import AccountBlock from './account-block';
|
|
import Avatar from './avatar';
|
|
import EmojiText from './emoji-text';
|
|
import Icon from './icon';
|
|
import Link from './link';
|
|
import ListAddEdit from './list-add-edit';
|
|
import Loader from './loader';
|
|
import MenuConfirm from './menu-confirm';
|
|
import MenuLink from './menu-link';
|
|
import Menu2 from './menu2';
|
|
import Modal from './modal';
|
|
import SubMenu2 from './submenu2';
|
|
import TranslationBlock from './translation-block';
|
|
|
|
const MUTE_DURATIONS = [
|
|
60 * 5, // 5 minutes
|
|
60 * 30, // 30 minutes
|
|
60 * 60, // 1 hour
|
|
60 * 60 * 6, // 6 hours
|
|
60 * 60 * 24, // 1 day
|
|
60 * 60 * 24 * 3, // 3 days
|
|
60 * 60 * 24 * 7, // 1 week
|
|
0, // forever
|
|
];
|
|
const MUTE_DURATIONS_LABELS = {
|
|
0: msg`Forever`,
|
|
300: i18nDuration(5, 'minute'),
|
|
1_800: i18nDuration(30, 'minute'),
|
|
3_600: i18nDuration(1, 'hour'),
|
|
21_600: i18nDuration(6, 'hour'),
|
|
86_400: i18nDuration(1, 'day'),
|
|
259_200: i18nDuration(3, 'day'),
|
|
604_800: i18nDuration(1, 'week'),
|
|
};
|
|
console.log({ MUTE_DURATIONS_LABELS });
|
|
|
|
const LIMIT = 80;
|
|
|
|
const ACCOUNT_INFO_MAX_AGE = 1000 * 60 * 10; // 10 mins
|
|
|
|
function fetchFamiliarFollowers(currentID, masto) {
|
|
return masto.v1.accounts.familiarFollowers.fetch({
|
|
id: [currentID],
|
|
});
|
|
}
|
|
const memFetchFamiliarFollowers = pmem(fetchFamiliarFollowers, {
|
|
maxAge: ACCOUNT_INFO_MAX_AGE,
|
|
});
|
|
|
|
async function fetchPostingStats(accountID, masto) {
|
|
const fetchStatuses = masto.v1.accounts
|
|
.$select(accountID)
|
|
.statuses.list({
|
|
limit: 20,
|
|
})
|
|
.next();
|
|
|
|
const { value: statuses } = await fetchStatuses;
|
|
console.log('fetched statuses', statuses);
|
|
const stats = {
|
|
total: statuses.length,
|
|
originals: 0,
|
|
replies: 0,
|
|
boosts: 0,
|
|
};
|
|
// Categories statuses by type
|
|
// - Original posts (not replies to others)
|
|
// - Threads (self-replies + 1st original post)
|
|
// - Boosts (reblogs)
|
|
// - Replies (not-self replies)
|
|
statuses.forEach((status) => {
|
|
if (status.reblog) {
|
|
stats.boosts++;
|
|
} else if (
|
|
!!status.inReplyToId &&
|
|
status.inReplyToAccountId !== status.account.id // Not self-reply
|
|
) {
|
|
stats.replies++;
|
|
} else {
|
|
stats.originals++;
|
|
}
|
|
});
|
|
|
|
// Count days since last post
|
|
if (statuses.length) {
|
|
stats.daysSinceLastPost = Math.ceil(
|
|
(Date.now() - new Date(statuses[statuses.length - 1].createdAt)) /
|
|
86400000,
|
|
);
|
|
}
|
|
|
|
console.log('posting stats', stats);
|
|
return stats;
|
|
}
|
|
const memFetchPostingStats = pmem(fetchPostingStats, {
|
|
maxAge: ACCOUNT_INFO_MAX_AGE,
|
|
});
|
|
|
|
function AccountInfo({
|
|
account,
|
|
fetchAccount = () => {},
|
|
standalone,
|
|
instance,
|
|
authenticated,
|
|
}) {
|
|
const { i18n } = useLingui();
|
|
const { masto } = api({
|
|
instance,
|
|
});
|
|
const { masto: currentMasto, instance: currentInstance } = api();
|
|
const [uiState, setUIState] = useState('default');
|
|
const isString = typeof account === 'string';
|
|
const [info, setInfo] = useState(isString ? null : account);
|
|
|
|
const sameCurrentInstance = useMemo(
|
|
() => instance === currentInstance,
|
|
[instance, currentInstance],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isString) {
|
|
setInfo(account);
|
|
return;
|
|
}
|
|
setUIState('loading');
|
|
(async () => {
|
|
try {
|
|
const info = await fetchAccount();
|
|
states.accounts[`${info.id}@${instance}`] = info;
|
|
setInfo(info);
|
|
setUIState('default');
|
|
} catch (e) {
|
|
console.error(e);
|
|
setInfo(null);
|
|
setUIState('error');
|
|
}
|
|
})();
|
|
}, [isString, account, fetchAccount]);
|
|
|
|
const {
|
|
acct,
|
|
avatar,
|
|
avatarStatic,
|
|
bot,
|
|
createdAt,
|
|
displayName,
|
|
emojis,
|
|
fields,
|
|
followersCount,
|
|
followingCount,
|
|
group,
|
|
// header,
|
|
// headerStatic,
|
|
id,
|
|
lastStatusAt,
|
|
locked,
|
|
note,
|
|
statusesCount,
|
|
url,
|
|
username,
|
|
memorial,
|
|
moved,
|
|
roles,
|
|
hideCollections,
|
|
} = info || {};
|
|
let headerIsAvatar = false;
|
|
let { header, headerStatic } = info || {};
|
|
if (!header || /missing\.png$/.test(header)) {
|
|
if (avatar && !/missing\.png$/.test(avatar)) {
|
|
header = avatar;
|
|
headerIsAvatar = true;
|
|
if (avatarStatic && !/missing\.png$/.test(avatarStatic)) {
|
|
headerStatic = avatarStatic;
|
|
}
|
|
}
|
|
}
|
|
|
|
const isSelf = useMemo(() => id === getCurrentAccountID(), [id]);
|
|
|
|
useEffect(() => {
|
|
const infoHasEssentials = !!(
|
|
info?.id &&
|
|
info?.username &&
|
|
info?.acct &&
|
|
info?.avatar &&
|
|
info?.avatarStatic &&
|
|
info?.displayName &&
|
|
info?.url
|
|
);
|
|
if (isSelf && instance && infoHasEssentials) {
|
|
const accounts = store.local.getJSON('accounts');
|
|
let updated = false;
|
|
accounts.forEach((account) => {
|
|
if (account.info.id === info.id && account.instanceURL === instance) {
|
|
account.info = info;
|
|
updated = true;
|
|
}
|
|
});
|
|
if (updated) {
|
|
console.log('Updated account info', info);
|
|
store.local.setJSON('accounts', accounts);
|
|
}
|
|
}
|
|
}, [isSelf, info, instance]);
|
|
|
|
const accountInstance = useMemo(() => {
|
|
if (!url) return null;
|
|
const domain = punycode.toUnicode(URL.parse(url).hostname);
|
|
return domain;
|
|
}, [url]);
|
|
|
|
const [headerCornerColors, setHeaderCornerColors] = useState([]);
|
|
|
|
const followersIterator = useRef();
|
|
const familiarFollowersCache = useRef([]);
|
|
async function fetchFollowers(firstLoad) {
|
|
if (firstLoad || !followersIterator.current) {
|
|
followersIterator.current = masto.v1.accounts.$select(id).followers.list({
|
|
limit: LIMIT,
|
|
});
|
|
}
|
|
const results = await followersIterator.current.next();
|
|
if (isSelf) return results;
|
|
if (!sameCurrentInstance) return results;
|
|
|
|
const { value } = results;
|
|
let newValue = [];
|
|
// On first load, fetch familiar followers, merge to top of results' `value`
|
|
// Remove dups on every fetch
|
|
if (firstLoad) {
|
|
let familiarFollowers = [];
|
|
try {
|
|
familiarFollowers = await masto.v1.accounts.familiarFollowers.fetch({
|
|
id: [id],
|
|
});
|
|
} catch (e) {}
|
|
familiarFollowersCache.current = familiarFollowers?.[0]?.accounts || [];
|
|
newValue = [
|
|
...familiarFollowersCache.current,
|
|
...value.filter(
|
|
(account) =>
|
|
!familiarFollowersCache.current.some(
|
|
(familiar) => familiar.id === account.id,
|
|
),
|
|
),
|
|
];
|
|
} else if (value?.length) {
|
|
newValue = value.filter(
|
|
(account) =>
|
|
!familiarFollowersCache.current.some(
|
|
(familiar) => familiar.id === account.id,
|
|
),
|
|
);
|
|
}
|
|
|
|
return {
|
|
...results,
|
|
value: newValue,
|
|
};
|
|
}
|
|
|
|
const followingIterator = useRef();
|
|
async function fetchFollowing(firstLoad) {
|
|
if (firstLoad || !followingIterator.current) {
|
|
followingIterator.current = masto.v1.accounts.$select(id).following.list({
|
|
limit: LIMIT,
|
|
});
|
|
}
|
|
const results = await followingIterator.current.next();
|
|
return results;
|
|
}
|
|
|
|
const LinkOrDiv = standalone ? 'div' : Link;
|
|
const accountLink = instance ? `/${instance}/a/${id}` : `/a/${id}`;
|
|
|
|
const [familiarFollowers, setFamiliarFollowers] = useState([]);
|
|
const [postingStats, setPostingStats] = useState();
|
|
const [postingStatsUIState, setPostingStatsUIState] = useState('default');
|
|
const hasPostingStats = !!postingStats?.total;
|
|
|
|
const renderFamiliarFollowers = async (currentID) => {
|
|
try {
|
|
const followers = await memFetchFamiliarFollowers(
|
|
currentID,
|
|
currentMasto,
|
|
);
|
|
console.log('fetched familiar followers', followers);
|
|
setFamiliarFollowers(
|
|
followers[0].accounts.slice(0, FAMILIAR_FOLLOWERS_LIMIT),
|
|
);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const renderPostingStats = async () => {
|
|
if (!id) return;
|
|
setPostingStatsUIState('loading');
|
|
try {
|
|
const stats = await memFetchPostingStats(id, masto);
|
|
setPostingStats(stats);
|
|
setPostingStatsUIState('default');
|
|
} catch (e) {
|
|
console.error(e);
|
|
setPostingStatsUIState('error');
|
|
}
|
|
};
|
|
|
|
const onRelationshipChange = useCallback(
|
|
({ relationship, currentID }) => {
|
|
if (!relationship.following) {
|
|
renderFamiliarFollowers(currentID);
|
|
if (!standalone && statusesCount > 0) {
|
|
// Only render posting stats if not standalone and has posts
|
|
renderPostingStats();
|
|
}
|
|
}
|
|
},
|
|
[standalone, id, statusesCount],
|
|
);
|
|
|
|
const onProfileUpdate = useCallback(
|
|
(newAccount) => {
|
|
if (newAccount.id === id) {
|
|
console.log('Updated account info', newAccount);
|
|
setInfo(newAccount);
|
|
states.accounts[`${newAccount.id}@${instance}`] = newAccount;
|
|
}
|
|
},
|
|
[id, instance],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
tabIndex="-1"
|
|
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
|
|
style={{
|
|
'--header-color-1': headerCornerColors[0],
|
|
'--header-color-2': headerCornerColors[1],
|
|
'--header-color-3': headerCornerColors[2],
|
|
'--header-color-4': headerCornerColors[3],
|
|
}}
|
|
>
|
|
{uiState === 'error' && (
|
|
<div class="ui-state">
|
|
<p>
|
|
<Trans>Unable to load account.</Trans>
|
|
</p>
|
|
<p>
|
|
<a
|
|
href={isString ? account : url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<Trans>Go to account page</Trans> <Icon icon="external" />
|
|
</a>
|
|
</p>
|
|
</div>
|
|
)}
|
|
{uiState === 'loading' ? (
|
|
<>
|
|
<header>
|
|
<AccountBlock avatarSize="xxxl" skeleton />
|
|
</header>
|
|
<main>
|
|
<div class="note">
|
|
<p>███████ ████ ████</p>
|
|
<p>████ ████████ ██████ █████████ ████ ██</p>
|
|
</div>
|
|
<div class="account-metadata-box">
|
|
<div class="profile-metadata">
|
|
<div class="profile-field">
|
|
<b class="more-insignificant">███</b>
|
|
<p>██████</p>
|
|
</div>
|
|
<div class="profile-field">
|
|
<b class="more-insignificant">████</b>
|
|
<p>███████████</p>
|
|
</div>
|
|
</div>
|
|
<div class="stats">
|
|
<div>
|
|
<span>██</span> <Trans>Followers</Trans>
|
|
</div>
|
|
<div>
|
|
<span>██</span> <Trans>Following</Trans>
|
|
</div>
|
|
<div>
|
|
<span>██</span> <Trans>Posts</Trans>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<span />
|
|
<span class="buttons">
|
|
<button type="button" class="plain" disabled>
|
|
<Icon icon="more" size="l" alt={t`More`} />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
</main>
|
|
</>
|
|
) : (
|
|
info && (
|
|
<>
|
|
{!!moved && (
|
|
<div class="account-moved">
|
|
<p>
|
|
<Trans>
|
|
<b>{displayName}</b> has indicated that their new account is
|
|
now:
|
|
</Trans>
|
|
</p>
|
|
<AccountBlock
|
|
account={moved}
|
|
instance={instance}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
states.showAccount = moved;
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{!!header && !/missing\.png$/.test(header) && (
|
|
<img
|
|
src={header}
|
|
alt=""
|
|
class={`header-banner ${
|
|
headerIsAvatar ? 'header-is-avatar' : ''
|
|
}`}
|
|
onError={(e) => {
|
|
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) => {
|
|
e.target.classList.add('loaded');
|
|
try {
|
|
// Get color from four corners of image
|
|
const canvas = window.OffscreenCanvas
|
|
? new OffscreenCanvas(1, 1)
|
|
: document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d', {
|
|
willReadFrequently: true,
|
|
});
|
|
canvas.width = e.target.width;
|
|
canvas.height = e.target.height;
|
|
ctx.imageSmoothingEnabled = false;
|
|
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,
|
|
// ];
|
|
// Get 10x10 pixels from corners, get average color from each
|
|
const pixelDimension = 10;
|
|
const colors = [
|
|
ctx.getImageData(0, 0, pixelDimension, pixelDimension)
|
|
.data,
|
|
ctx.getImageData(
|
|
e.target.width - pixelDimension,
|
|
0,
|
|
pixelDimension,
|
|
pixelDimension,
|
|
).data,
|
|
ctx.getImageData(
|
|
0,
|
|
e.target.height - pixelDimension,
|
|
pixelDimension,
|
|
pixelDimension,
|
|
).data,
|
|
ctx.getImageData(
|
|
e.target.width - pixelDimension,
|
|
e.target.height - pixelDimension,
|
|
pixelDimension,
|
|
pixelDimension,
|
|
).data,
|
|
].map((data) => {
|
|
let r = 0;
|
|
let g = 0;
|
|
let b = 0;
|
|
let a = 0;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
r += data[i];
|
|
g += data[i + 1];
|
|
b += data[i + 2];
|
|
a += data[i + 3];
|
|
}
|
|
const dataLength = data.length / 4;
|
|
return [
|
|
r / dataLength,
|
|
g / dataLength,
|
|
b / dataLength,
|
|
a / dataLength,
|
|
];
|
|
});
|
|
const rgbColors = colors.map((color) => {
|
|
const [r, g, b, a] = lightenRGB(color);
|
|
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
});
|
|
setHeaderCornerColors(rgbColors);
|
|
console.log({ colors, rgbColors });
|
|
} catch (e) {
|
|
// Silently fail
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
<header>
|
|
{standalone ? (
|
|
<Menu2
|
|
shift={
|
|
window.matchMedia('(min-width: calc(40em))').matches
|
|
? 114
|
|
: 64
|
|
}
|
|
menuButton={
|
|
<div>
|
|
<AccountBlock
|
|
account={info}
|
|
instance={instance}
|
|
avatarSize="xxxl"
|
|
onClick={() => {}}
|
|
/>
|
|
</div>
|
|
}
|
|
>
|
|
<div class="szh-menu__header">
|
|
<AccountHandleInfo acct={acct} instance={instance} />
|
|
</div>
|
|
<MenuItem
|
|
onClick={() => {
|
|
const handleWithInstance = acct.includes('@')
|
|
? `@${acct}`
|
|
: `@${acct}@${instance}`;
|
|
try {
|
|
navigator.clipboard.writeText(handleWithInstance);
|
|
showToast(t`Handle copied`);
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast(t`Unable to copy handle`);
|
|
}
|
|
}}
|
|
>
|
|
<Icon icon="link" />
|
|
<span>
|
|
<Trans>Copy handle</Trans>
|
|
</span>
|
|
</MenuItem>
|
|
<MenuItem href={url} target="_blank">
|
|
<Icon icon="external" />
|
|
<span>
|
|
<Trans>Go to original profile page</Trans>
|
|
</span>
|
|
</MenuItem>
|
|
<MenuDivider />
|
|
<MenuLink href={info.avatar} target="_blank">
|
|
<Icon icon="user" />
|
|
<span>
|
|
<Trans>View profile image</Trans>
|
|
</span>
|
|
</MenuLink>
|
|
<MenuLink href={info.header} target="_blank">
|
|
<Icon icon="media" />
|
|
<span>
|
|
<Trans>View profile header</Trans>
|
|
</span>
|
|
</MenuLink>
|
|
</Menu2>
|
|
) : (
|
|
<AccountBlock
|
|
account={info}
|
|
instance={instance}
|
|
avatarSize="xxxl"
|
|
internal
|
|
/>
|
|
)}
|
|
</header>
|
|
<div class="faux-header-bg" aria-hidden="true" />
|
|
<main>
|
|
{!!memorial && (
|
|
<span class="tag">
|
|
<Trans>In Memoriam</Trans>
|
|
</span>
|
|
)}
|
|
{!!bot && (
|
|
<span class="tag">
|
|
<Icon icon="bot" /> <Trans>Automated</Trans>
|
|
</span>
|
|
)}
|
|
{!!group && (
|
|
<span class="tag">
|
|
<Icon icon="group" /> <Trans>Group</Trans>
|
|
</span>
|
|
)}
|
|
{roles?.map((role) => (
|
|
<span class="tag">
|
|
{role.name}
|
|
{!!accountInstance && (
|
|
<>
|
|
{' '}
|
|
<span class="more-insignificant">{accountInstance}</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
))}
|
|
<div
|
|
class="note"
|
|
dir="auto"
|
|
onClick={handleContentLinks({
|
|
instance: currentInstance,
|
|
})}
|
|
dangerouslySetInnerHTML={{
|
|
__html: enhanceContent(note, { emojis }),
|
|
}}
|
|
/>
|
|
<div class="account-metadata-box">
|
|
{fields?.length > 0 && (
|
|
<div class="profile-metadata">
|
|
{fields.map(({ name, value, verifiedAt }, i) => (
|
|
<div
|
|
class={`profile-field ${
|
|
verifiedAt ? 'profile-verified' : ''
|
|
}`}
|
|
key={name + i}
|
|
dir="auto"
|
|
>
|
|
<b>
|
|
<EmojiText text={name} emojis={emojis} />{' '}
|
|
{!!verifiedAt && (
|
|
<Icon
|
|
icon="check-circle"
|
|
size="s"
|
|
alt={t`Verified`}
|
|
/>
|
|
)}
|
|
</b>
|
|
<p
|
|
dangerouslySetInnerHTML={{
|
|
__html: enhanceContent(value, { emojis }),
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div class="stats">
|
|
<LinkOrDiv
|
|
tabIndex={0}
|
|
to={accountLink}
|
|
onClick={() => {
|
|
// states.showAccount = false;
|
|
setTimeout(() => {
|
|
states.showGenericAccounts = {
|
|
id: 'followers',
|
|
heading: t`Followers`,
|
|
fetchAccounts: fetchFollowers,
|
|
instance,
|
|
excludeRelationshipAttrs: isSelf
|
|
? ['followedBy']
|
|
: [],
|
|
blankCopy: hideCollections
|
|
? t`This user has chosen to not make this information available.`
|
|
: undefined,
|
|
};
|
|
}, 0);
|
|
}}
|
|
>
|
|
{!!familiarFollowers.length && (
|
|
<span class="shazam-container-horizontal">
|
|
<span class="shazam-container-inner stats-avatars-bunch">
|
|
{familiarFollowers.map((follower) => (
|
|
<Avatar
|
|
url={follower.avatarStatic}
|
|
size="s"
|
|
alt={`${follower.displayName} @${follower.acct}`}
|
|
squircle={follower?.bot}
|
|
/>
|
|
))}
|
|
</span>
|
|
</span>
|
|
)}
|
|
<span title={followersCount}>
|
|
{shortenNumber(followersCount)}
|
|
</span>{' '}
|
|
<Trans>Followers</Trans>
|
|
</LinkOrDiv>
|
|
<LinkOrDiv
|
|
class="insignificant"
|
|
tabIndex={0}
|
|
to={accountLink}
|
|
onClick={() => {
|
|
// states.showAccount = false;
|
|
setTimeout(() => {
|
|
states.showGenericAccounts = {
|
|
heading: t`Following`,
|
|
fetchAccounts: fetchFollowing,
|
|
instance,
|
|
excludeRelationshipAttrs: isSelf ? ['following'] : [],
|
|
blankCopy: hideCollections
|
|
? t`This user has chosen to not make this information available.`
|
|
: undefined,
|
|
};
|
|
}, 0);
|
|
}}
|
|
>
|
|
<span title={followingCount}>
|
|
{shortenNumber(followingCount)}
|
|
</span>{' '}
|
|
<Trans>Following</Trans>
|
|
<br />
|
|
</LinkOrDiv>
|
|
<LinkOrDiv
|
|
class="insignificant"
|
|
to={accountLink}
|
|
// onClick={
|
|
// standalone
|
|
// ? undefined
|
|
// : () => {
|
|
// hideAllModals();
|
|
// }
|
|
// }
|
|
>
|
|
<span title={statusesCount}>
|
|
{shortenNumber(statusesCount)}
|
|
</span>{' '}
|
|
<Trans>Posts</Trans>
|
|
</LinkOrDiv>
|
|
{!!createdAt && (
|
|
<div class="insignificant">
|
|
<Trans>
|
|
Joined{' '}
|
|
<time datetime={createdAt}>
|
|
{niceDateTime(createdAt, {
|
|
hideTime: true,
|
|
})}
|
|
</time>
|
|
</Trans>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{!!postingStats && (
|
|
<LinkOrDiv
|
|
to={accountLink}
|
|
class="account-metadata-box"
|
|
// onClick={() => {
|
|
// states.showAccount = false;
|
|
// }}
|
|
>
|
|
<div class="shazam-container">
|
|
<div class="shazam-container-inner">
|
|
{hasPostingStats ? (
|
|
<div
|
|
class="posting-stats"
|
|
title={t`${(
|
|
postingStats.originals / postingStats.total
|
|
).toLocaleString(i18n.locale || undefined, {
|
|
style: 'percent',
|
|
})} original posts, ${(
|
|
postingStats.replies / postingStats.total
|
|
).toLocaleString(i18n.locale || undefined, {
|
|
style: 'percent',
|
|
})} replies, ${(
|
|
postingStats.boosts / postingStats.total
|
|
).toLocaleString(i18n.locale || undefined, {
|
|
style: 'percent',
|
|
})} boosts`}
|
|
>
|
|
<div>
|
|
{postingStats.daysSinceLastPost < 365
|
|
? plural(postingStats.total, {
|
|
one: plural(postingStats.daysSinceLastPost, {
|
|
one: `Last 1 post in the past 1 day`,
|
|
other: `Last 1 post in the past ${postingStats.daysSinceLastPost} days`,
|
|
}),
|
|
other: plural(
|
|
postingStats.daysSinceLastPost,
|
|
{
|
|
one: `Last ${postingStats.total} posts in the past 1 day`,
|
|
other: `Last ${postingStats.total} posts in the past ${postingStats.daysSinceLastPost} days`,
|
|
},
|
|
),
|
|
})
|
|
: plural(postingStats.total, {
|
|
one: 'Last 1 post in the past year(s)',
|
|
other: `Last ${postingStats.total} posts in the past year(s)`,
|
|
})}
|
|
</div>
|
|
<div
|
|
class="posting-stats-bar"
|
|
style={{
|
|
// [originals | replies | boosts]
|
|
'--originals-percentage': `${
|
|
(postingStats.originals / postingStats.total) *
|
|
100
|
|
}%`,
|
|
'--replies-percentage': `${
|
|
((postingStats.originals +
|
|
postingStats.replies) /
|
|
postingStats.total) *
|
|
100
|
|
}%`,
|
|
}}
|
|
/>
|
|
<div class="posting-stats-legends">
|
|
<span class="ib">
|
|
<span class="posting-stats-legend-item posting-stats-legend-item-originals" />{' '}
|
|
<Trans>Original</Trans>
|
|
</span>{' '}
|
|
<span class="ib">
|
|
<span class="posting-stats-legend-item posting-stats-legend-item-replies" />{' '}
|
|
<Trans>Replies</Trans>
|
|
</span>{' '}
|
|
<span class="ib">
|
|
<span class="posting-stats-legend-item posting-stats-legend-item-boosts" />{' '}
|
|
<Trans>Boosts</Trans>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div class="posting-stats">
|
|
<Trans>Post stats unavailable.</Trans>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</LinkOrDiv>
|
|
)}
|
|
{!moved && (
|
|
<div class="account-metadata-box">
|
|
<div
|
|
class="shazam-container no-animation"
|
|
hidden={!!postingStats}
|
|
>
|
|
<div class="shazam-container-inner">
|
|
<button
|
|
type="button"
|
|
class="posting-stats-button"
|
|
disabled={postingStatsUIState === 'loading'}
|
|
onClick={() => {
|
|
renderPostingStats();
|
|
}}
|
|
>
|
|
<div
|
|
class={`posting-stats-bar posting-stats-icon ${
|
|
postingStatsUIState === 'loading' ? 'loading' : ''
|
|
}`}
|
|
style={{
|
|
'--originals-percentage': '33%',
|
|
'--replies-percentage': '66%',
|
|
}}
|
|
/>
|
|
<Trans>View post stats</Trans>{' '}
|
|
{/* <Loader
|
|
abrupt
|
|
hidden={postingStatsUIState !== 'loading'}
|
|
/> */}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
<footer>
|
|
<RelatedActions
|
|
info={info}
|
|
instance={instance}
|
|
standalone={standalone}
|
|
authenticated={authenticated}
|
|
onRelationshipChange={onRelationshipChange}
|
|
onProfileUpdate={onProfileUpdate}
|
|
/>
|
|
</footer>
|
|
</>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const FAMILIAR_FOLLOWERS_LIMIT = 3;
|
|
|
|
function RelatedActions({
|
|
info,
|
|
instance,
|
|
standalone,
|
|
authenticated,
|
|
onRelationshipChange = () => {},
|
|
onProfileUpdate = () => {},
|
|
}) {
|
|
if (!info) return null;
|
|
const { _ } = useLingui();
|
|
const {
|
|
masto: currentMasto,
|
|
instance: currentInstance,
|
|
authenticated: currentAuthenticated,
|
|
} = api();
|
|
const sameInstance = instance === currentInstance;
|
|
|
|
const [relationshipUIState, setRelationshipUIState] = useState('default');
|
|
const [relationship, setRelationship] = useState(null);
|
|
|
|
const { id, acct, url, username, locked, lastStatusAt, note, fields, moved } =
|
|
info;
|
|
const accountID = useRef(id);
|
|
|
|
const {
|
|
following,
|
|
showingReblogs,
|
|
notifying,
|
|
followedBy,
|
|
blocking,
|
|
blockedBy,
|
|
muting,
|
|
mutingNotifications,
|
|
requested,
|
|
domainBlocking,
|
|
endorsed,
|
|
note: privateNote,
|
|
} = relationship || {};
|
|
|
|
const [currentInfo, setCurrentInfo] = useState(null);
|
|
const [isSelf, setIsSelf] = useState(false);
|
|
|
|
const acctWithInstance = acct.includes('@') ? acct : `${acct}@${instance}`;
|
|
|
|
useEffect(() => {
|
|
if (info) {
|
|
const currentAccount = getCurrentAccountID();
|
|
let currentID;
|
|
(async () => {
|
|
if (sameInstance && authenticated) {
|
|
currentID = id;
|
|
} else if (!sameInstance && currentAuthenticated) {
|
|
// Grab this account from my logged-in instance
|
|
const acctHasInstance = info.acct.includes('@');
|
|
try {
|
|
const results = await currentMasto.v2.search.fetch({
|
|
q: acctHasInstance ? info.acct : `${info.username}@${instance}`,
|
|
type: 'accounts',
|
|
limit: 1,
|
|
resolve: true,
|
|
});
|
|
console.log('🥏 Fetched account from logged-in instance', results);
|
|
if (results.accounts.length) {
|
|
currentID = results.accounts[0].id;
|
|
setCurrentInfo(results.accounts[0]);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
if (!currentID) return;
|
|
|
|
if (currentAccount === currentID) {
|
|
// It's myself!
|
|
setIsSelf(true);
|
|
return;
|
|
}
|
|
|
|
accountID.current = currentID;
|
|
|
|
// if (moved) return;
|
|
|
|
setRelationshipUIState('loading');
|
|
|
|
const fetchRelationships = currentMasto.v1.accounts.relationships.fetch(
|
|
{
|
|
id: [currentID],
|
|
},
|
|
);
|
|
|
|
try {
|
|
const relationships = await fetchRelationships;
|
|
console.log('fetched relationship', relationships);
|
|
setRelationshipUIState('default');
|
|
|
|
if (relationships.length) {
|
|
const relationship = relationships[0];
|
|
setRelationship(relationship);
|
|
onRelationshipChange({ relationship, currentID });
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
setRelationshipUIState('error');
|
|
}
|
|
})();
|
|
}
|
|
}, [info, authenticated]);
|
|
|
|
useEffect(() => {
|
|
if (info && isSelf) {
|
|
updateAccount(info);
|
|
}
|
|
}, [info, isSelf]);
|
|
|
|
const loading = relationshipUIState === 'loading';
|
|
|
|
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
|
|
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
|
|
const [showPrivateNoteModal, setShowPrivateNoteModal] = useState(false);
|
|
const [showEditProfile, setShowEditProfile] = useState(false);
|
|
const [lists, setLists] = useState([]);
|
|
|
|
return (
|
|
<>
|
|
<div class="actions">
|
|
<span>
|
|
{followedBy ? (
|
|
<span class="tag">
|
|
<Trans>Follows you</Trans>
|
|
</span>
|
|
) : !!lastStatusAt ? (
|
|
<small class="insignificant">
|
|
<Trans>
|
|
Last post:{' '}
|
|
<span class="ib">
|
|
{niceDateTime(lastStatusAt, {
|
|
hideTime: true,
|
|
})}
|
|
</span>
|
|
</Trans>
|
|
</small>
|
|
) : (
|
|
<span />
|
|
)}
|
|
{muting && (
|
|
<span class="tag danger">
|
|
<Trans>Muted</Trans>
|
|
</span>
|
|
)}
|
|
{blocking && (
|
|
<span class="tag danger">
|
|
<Trans>Blocked</Trans>
|
|
</span>
|
|
)}
|
|
</span>{' '}
|
|
<span class="buttons">
|
|
{!!privateNote && (
|
|
<button
|
|
type="button"
|
|
class="private-note-tag"
|
|
title={t`Private note`}
|
|
onClick={() => {
|
|
setShowPrivateNoteModal(true);
|
|
}}
|
|
dir="auto"
|
|
>
|
|
<span>{privateNote}</span>
|
|
</button>
|
|
)}
|
|
<Menu2
|
|
portal={{
|
|
target: document.body,
|
|
}}
|
|
containerProps={{
|
|
style: {
|
|
// Higher than the backdrop
|
|
zIndex: 1001,
|
|
},
|
|
}}
|
|
align="center"
|
|
position="anchor"
|
|
overflow="auto"
|
|
menuButton={
|
|
<button type="button" class="plain" disabled={loading}>
|
|
<Icon icon="more" size="l" alt={t`More`} />
|
|
</button>
|
|
}
|
|
onMenuChange={(e) => {
|
|
if (following && e.open) {
|
|
// Fetch lists that have this account
|
|
(async () => {
|
|
try {
|
|
const lists = await currentMasto.v1.accounts
|
|
.$select(accountID.current)
|
|
.lists.list();
|
|
console.log('fetched account lists', lists);
|
|
setLists(lists);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
})();
|
|
}
|
|
}}
|
|
>
|
|
{currentAuthenticated && !isSelf && (
|
|
<>
|
|
<MenuItem
|
|
onClick={() => {
|
|
showCompose({
|
|
draftStatus: {
|
|
status: `@${currentInfo?.acct || acct} `,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<Icon icon="at" />
|
|
<span>
|
|
<Trans>Mention @{username}</Trans>
|
|
</span>
|
|
</MenuItem>
|
|
<MenuItem
|
|
onClick={() => {
|
|
setShowTranslatedBio(true);
|
|
}}
|
|
>
|
|
<Icon icon="translate" />
|
|
<span>
|
|
<Trans>Translate bio</Trans>
|
|
</span>
|
|
</MenuItem>
|
|
{supports('@mastodon/profile-private-note') && (
|
|
<MenuItem
|
|
onClick={() => {
|
|
setShowPrivateNoteModal(true);
|
|
}}
|
|
>
|
|
<Icon icon="pencil" />
|
|
<span>
|
|
{privateNote ? t`Edit private note` : t`Add private note`}
|
|
</span>
|
|
</MenuItem>
|
|
)}
|
|
{following && !!relationship && (
|
|
<>
|
|
<MenuItem
|
|
onClick={() => {
|
|
setRelationshipUIState('loading');
|
|
(async () => {
|
|
try {
|
|
const rel = await currentMasto.v1.accounts
|
|
.$select(accountID.current)
|
|
.follow({
|
|
notify: !notifying,
|
|
});
|
|
if (rel) setRelationship(rel);
|
|
setRelationshipUIState('default');
|
|
showToast(
|
|
rel.notifying
|
|
? t`Notifications enabled for @${username}'s posts.`
|
|
: t` Notifications disabled for @${username}'s posts.`,
|
|
);
|
|
} catch (e) {
|
|
alert(e);
|
|
setRelationshipUIState('error');
|
|
}
|
|
})();
|
|
}}
|
|
>
|
|
<Icon icon="notification" />
|
|
<span>
|
|
{notifying
|
|
? t`Disable notifications`
|
|
: t`Enable notifications`}
|
|
</span>
|
|
</MenuItem>
|
|
<MenuItem
|
|
onClick={() => {
|
|
setRelationshipUIState('loading');
|
|
(async () => {
|
|
try {
|
|
const rel = await currentMasto.v1.accounts
|
|
.$select(accountID.current)
|
|
.follow({
|
|
reblogs: !showingReblogs,
|
|
});
|
|
if (rel) setRelationship(rel);
|
|
setRelationshipUIState('default');
|
|
showToast(
|
|
rel.showingReblogs
|
|
? t`Boosts from @${username} enabled.`
|
|
: t`Boosts from @${username} disabled.`,
|
|
);
|
|
} catch (e) {
|
|
alert(e);
|
|
setRelationshipUIState('error');
|
|
}
|
|
})();
|
|
}}
|
|
>
|
|
<Icon icon="rocket" />
|
|
<span>
|
|
{showingReblogs ? t`Disable boosts` : t`Enable boosts`}
|
|
</span>
|
|
</MenuItem>
|
|
</>
|
|
)}
|
|
{/* Add/remove from lists is only possible if following the account */}
|
|
{following && (
|
|
<MenuItem
|
|
onClick={() => {
|
|
setShowAddRemoveLists(true);
|
|
}}
|
|
>
|
|
<Icon icon="list" />
|
|
{lists.length ? (
|
|
<>
|
|
<small class="menu-grow">
|
|
<Trans>Add/Remove from Lists</Trans>
|
|
<br />
|
|
<span class="more-insignificant">
|
|
{lists.map((list) => list.title).join(', ')}
|
|
</span>
|
|
</small>
|
|
<small class="more-insignificant">{lists.length}</small>
|
|
</>
|
|
) : (
|
|
<span>
|
|
<Trans>Add/Remove from Lists</Trans>
|
|
</span>
|
|
)}
|
|
</MenuItem>
|
|
)}
|
|
<MenuDivider />
|
|
</>
|
|
)}
|
|
<MenuItem
|
|
onClick={() => {
|
|
const handle = `@${currentInfo?.acct || acctWithInstance}`;
|
|
try {
|
|
navigator.clipboard.writeText(handle);
|
|
showToast(t`Handle copied`);
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast(t`Unable to copy handle`);
|
|
}
|
|
}}
|
|
>
|
|
<Icon icon="copy" />
|
|
<small>
|
|
<Trans>Copy handle</Trans>
|
|
<br />
|
|
<span class="more-insignificant bidi-isolate">
|
|
@{currentInfo?.acct || acctWithInstance}
|
|
</span>
|
|
</small>
|
|
</MenuItem>
|
|
<MenuItem href={url} target="_blank">
|
|
<Icon icon="external" />
|
|
<small class="menu-double-lines">{niceAccountURL(url)}</small>
|
|
</MenuItem>
|
|
<div class="menu-horizontal">
|
|
<MenuItem
|
|
onClick={() => {
|
|
// Copy url to clipboard
|
|
try {
|
|
navigator.clipboard.writeText(url);
|
|
showToast(t`Link copied`);
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast(t`Unable to copy link`);
|
|
}
|
|
}}
|
|
>
|
|
<Icon icon="link" />
|
|
<span>
|
|
<Trans>Copy</Trans>
|
|
</span>
|
|
</MenuItem>
|
|
{navigator?.share &&
|
|
navigator?.canShare?.({
|
|
url,
|
|
}) && (
|
|
<MenuItem
|
|
onClick={() => {
|
|
try {
|
|
navigator.share({
|
|
url,
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert(t`Sharing doesn't seem to work.`);
|
|
}
|
|
}}
|
|
>
|
|
<Icon icon="share" />
|
|
<span>
|
|
<Trans>Share…</Trans>
|
|
</span>
|
|
</MenuItem>
|
|
)}
|
|
</div>
|
|
{!!relationship && (
|
|
<>
|
|
<MenuDivider />
|
|
{muting ? (
|
|
<MenuItem
|
|
onClick={() => {
|
|
setRelationshipUIState('loading');
|
|
(async () => {
|
|
try {
|
|
const newRelationship = await currentMasto.v1.accounts
|
|
.$select(currentInfo?.id || id)
|
|
.unmute();
|
|
console.log('unmuting', newRelationship);
|
|
setRelationship(newRelationship);
|
|
setRelationshipUIState('default');
|
|
showToast(t`Unmuted @${username}`);
|
|
states.reloadGenericAccounts.id = 'mute';
|
|
states.reloadGenericAccounts.counter++;
|
|
} catch (e) {
|
|
console.error(e);
|
|
setRelationshipUIState('error');
|
|
}
|
|
})();
|
|
}}
|
|
>
|
|
<Icon icon="unmute" />
|
|
<span>
|
|
<Trans>Unmute @{username}</Trans>
|
|
</span>
|
|
</MenuItem>
|
|
) : (
|
|
<SubMenu2
|
|
menuClassName="menu-blur"
|
|
openTrigger="clickOnly"
|
|
direction="bottom"
|
|
overflow="auto"
|
|
shift={16}
|
|
label={
|
|
<>
|
|
<Icon icon="mute" />
|
|
<span class="menu-grow">
|
|
<Trans>Mute @{username}…</Trans>
|
|
</span>
|
|
<span
|
|
style={{
|
|
textOverflow: 'clip',
|
|
}}
|
|
>
|
|
<Icon icon="time" />
|
|
<Icon icon="chevron-right" />
|
|
</span>
|
|
</>
|
|
}
|
|
>
|
|
<div class="menu-wrap">
|
|
{MUTE_DURATIONS.map((duration) => (
|
|
<MenuItem
|
|
onClick={() => {
|
|
setRelationshipUIState('loading');
|
|
(async () => {
|
|
try {
|
|
const newRelationship =
|
|
await currentMasto.v1.accounts
|
|
.$select(currentInfo?.id || id)
|
|
.mute({
|
|
duration,
|
|
});
|
|
console.log('muting', newRelationship);
|
|
setRelationship(newRelationship);
|
|
setRelationshipUIState('default');
|
|
showToast(
|
|
t`Muted @${username} for ${
|
|
typeof MUTE_DURATIONS_LABELS[duration] ===
|
|
'function'
|
|
? MUTE_DURATIONS_LABELS[duration]()
|
|
: _(MUTE_DURATIONS_LABELS[duration])
|
|
}`,
|
|
);
|
|
states.reloadGenericAccounts.id = 'mute';
|
|
states.reloadGenericAccounts.counter++;
|
|
} catch (e) {
|
|
console.error(e);
|
|
setRelationshipUIState('error');
|
|
showToast(t`Unable to mute @${username}`);
|
|
}
|
|
})();
|
|
}}
|
|
>
|
|
{typeof MUTE_DURATIONS_LABELS[duration] === 'function'
|
|
? MUTE_DURATIONS_LABELS[duration]()
|
|
: _(MUTE_DURATIONS_LABELS[duration])}
|
|
</MenuItem>
|
|
))}
|
|
</div>
|
|
</SubMenu2>
|
|
)}
|
|
{followedBy && (
|
|
<MenuConfirm
|
|
subMenu
|
|
menuItemClassName="danger"
|
|
confirmLabel={
|
|
<>
|
|
<Icon icon="user-x" />
|
|
<span>
|
|
<Trans>Remove @{username} from followers?</Trans>
|
|
</span>
|
|
</>
|
|
}
|
|
onClick={() => {
|
|
setRelationshipUIState('loading');
|
|
(async () => {
|
|
try {
|
|
const newRelationship = await currentMasto.v1.accounts
|
|
.$select(currentInfo?.id || id)
|
|
.removeFromFollowers();
|
|
console.log(
|
|
'removing from followers',
|
|
newRelationship,
|
|
);
|
|
setRelationship(newRelationship);
|
|
setRelationshipUIState('default');
|
|
showToast(t`@${username} removed from followers`);
|
|
states.reloadGenericAccounts.id = 'followers';
|
|
states.reloadGenericAccounts.counter++;
|
|
} catch (e) {
|
|
console.error(e);
|
|
setRelationshipUIState('error');
|
|
}
|
|
})();
|
|
}}
|
|
>
|
|
<Icon icon="user-x" />
|
|
<span>
|
|
<Trans>Remove follower…</Trans>
|
|
</span>
|
|
</MenuConfirm>
|
|
)}
|
|
<MenuConfirm
|
|
subMenu
|
|
confirm={!blocking}
|
|
confirmLabel={
|
|
<>
|
|
<Icon icon="block" />
|
|
<span>
|
|
<Trans>Block @{username}?</Trans>
|
|
</span>
|
|
</>
|
|
}
|
|
menuItemClassName="danger"
|
|
onClick={() => {
|
|
// if (!blocking && !confirm(`Block @${username}?`)) {
|
|
// return;
|
|
// }
|
|
setRelationshipUIState('loading');
|
|
(async () => {
|
|
try {
|
|
if (blocking) {
|
|
const newRelationship = await currentMasto.v1.accounts
|
|
.$select(currentInfo?.id || id)
|
|
.unblock();
|
|
console.log('unblocking', newRelationship);
|
|
setRelationship(newRelationship);
|
|
setRelationshipUIState('default');
|
|
showToast(t`Unblocked @${username}`);
|
|
} else {
|
|
const newRelationship = await currentMasto.v1.accounts
|
|
.$select(currentInfo?.id || id)
|
|
.block();
|
|
console.log('blocking', newRelationship);
|
|
setRelationship(newRelationship);
|
|
setRelationshipUIState('default');
|
|
showToast(t`Blocked @${username}`);
|
|
}
|
|
states.reloadGenericAccounts.id = 'block';
|
|
states.reloadGenericAccounts.counter++;
|
|
} catch (e) {
|
|
console.error(e);
|
|
setRelationshipUIState('error');
|
|
if (blocking) {
|
|
showToast(t`Unable to unblock @${username}`);
|
|
} else {
|
|
showToast(t`Unable to block @${username}`);
|
|
}
|
|
}
|
|
})();
|
|
}}
|
|
>
|
|
{blocking ? (
|
|
<>
|
|
<Icon icon="unblock" />
|
|
<span>
|
|
<Trans>Unblock @{username}</Trans>
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Icon icon="block" />
|
|
<span>
|
|
<Trans>Block @{username}…</Trans>
|
|
</span>
|
|
</>
|
|
)}
|
|
</MenuConfirm>
|
|
<MenuItem
|
|
className="danger"
|
|
onClick={() => {
|
|
states.showReportModal = {
|
|
account: currentInfo || info,
|
|
};
|
|
}}
|
|
>
|
|
<Icon icon="flag" />
|
|
<span>
|
|
<Trans>Report @{username}…</Trans>
|
|
</span>
|
|
</MenuItem>
|
|
</>
|
|
)}
|
|
{currentAuthenticated &&
|
|
isSelf &&
|
|
standalone &&
|
|
supports('@mastodon/profile-edit') && (
|
|
<>
|
|
<MenuDivider />
|
|
<MenuItem
|
|
onClick={() => {
|
|
setShowEditProfile(true);
|
|
}}
|
|
>
|
|
<Icon icon="pencil" />
|
|
<span>
|
|
<Trans>Edit profile</Trans>
|
|
</span>
|
|
</MenuItem>
|
|
</>
|
|
)}
|
|
{import.meta.env.DEV && currentAuthenticated && isSelf && (
|
|
<>
|
|
<MenuDivider />
|
|
<MenuItem
|
|
onClick={async () => {
|
|
const relationships =
|
|
await currentMasto.v1.accounts.relationships.fetch({
|
|
id: [accountID.current],
|
|
});
|
|
const { note } = relationships[0] || {};
|
|
if (note) {
|
|
alert(note);
|
|
console.log(note);
|
|
}
|
|
}}
|
|
>
|
|
<Icon icon="pencil" />
|
|
<span>See note</span>
|
|
</MenuItem>
|
|
</>
|
|
)}
|
|
</Menu2>
|
|
{!relationship && relationshipUIState === 'loading' && (
|
|
<Loader abrupt />
|
|
)}
|
|
{!!relationship && !moved && (
|
|
<MenuConfirm
|
|
confirm={following || requested}
|
|
confirmLabel={
|
|
<span>
|
|
{requested
|
|
? t`Withdraw follow request?`
|
|
: t`Unfollow @${info.acct || info.username}?`}
|
|
</span>
|
|
}
|
|
menuItemClassName="danger"
|
|
align="end"
|
|
disabled={loading}
|
|
onClick={() => {
|
|
setRelationshipUIState('loading');
|
|
(async () => {
|
|
try {
|
|
let newRelationship;
|
|
|
|
if (following || requested) {
|
|
// const yes = confirm(
|
|
// requested
|
|
// ? 'Withdraw follow request?'
|
|
// : `Unfollow @${info.acct || info.username}?`,
|
|
// );
|
|
|
|
// if (yes) {
|
|
newRelationship = await currentMasto.v1.accounts
|
|
.$select(accountID.current)
|
|
.unfollow();
|
|
// }
|
|
} else {
|
|
newRelationship = await currentMasto.v1.accounts
|
|
.$select(accountID.current)
|
|
.follow();
|
|
}
|
|
|
|
if (newRelationship) setRelationship(newRelationship);
|
|
setRelationshipUIState('default');
|
|
} catch (e) {
|
|
alert(e);
|
|
setRelationshipUIState('error');
|
|
}
|
|
})();
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
class={`${following || requested ? 'light swap' : ''}`}
|
|
data-swap-state={following || requested ? 'danger' : ''}
|
|
disabled={loading}
|
|
>
|
|
{following ? (
|
|
<>
|
|
<span>
|
|
<Trans>Following</Trans>
|
|
</span>
|
|
<span>
|
|
<Trans>Unfollow…</Trans>
|
|
</span>
|
|
</>
|
|
) : requested ? (
|
|
<>
|
|
<span>
|
|
<Trans>Requested</Trans>
|
|
</span>
|
|
<span>
|
|
<Trans>Withdraw…</Trans>
|
|
</span>
|
|
</>
|
|
) : locked ? (
|
|
<>
|
|
<Icon icon="lock" />{' '}
|
|
<span>
|
|
<Trans>Follow</Trans>
|
|
</span>
|
|
</>
|
|
) : (
|
|
t`Follow`
|
|
)}
|
|
</button>
|
|
</MenuConfirm>
|
|
)}
|
|
</span>
|
|
</div>
|
|
{!!showTranslatedBio && (
|
|
<Modal
|
|
onClose={() => {
|
|
setShowTranslatedBio(false);
|
|
}}
|
|
>
|
|
<TranslatedBioSheet
|
|
note={note}
|
|
fields={fields}
|
|
onClose={() => setShowTranslatedBio(false)}
|
|
/>
|
|
</Modal>
|
|
)}
|
|
{!!showAddRemoveLists && (
|
|
<Modal
|
|
onClose={() => {
|
|
setShowAddRemoveLists(false);
|
|
}}
|
|
>
|
|
<AddRemoveListsSheet
|
|
accountID={accountID.current}
|
|
onClose={() => setShowAddRemoveLists(false)}
|
|
/>
|
|
</Modal>
|
|
)}
|
|
{!!showPrivateNoteModal && (
|
|
<Modal
|
|
onClose={() => {
|
|
setShowPrivateNoteModal(false);
|
|
}}
|
|
>
|
|
<PrivateNoteSheet
|
|
account={info}
|
|
note={privateNote}
|
|
onRelationshipChange={(relationship) => {
|
|
setRelationship(relationship);
|
|
// onRelationshipChange({ relationship, currentID: accountID.current });
|
|
}}
|
|
onClose={() => setShowPrivateNoteModal(false)}
|
|
/>
|
|
</Modal>
|
|
)}
|
|
{!!showEditProfile && (
|
|
<Modal
|
|
onClose={() => {
|
|
setShowEditProfile(false);
|
|
}}
|
|
>
|
|
<EditProfileSheet
|
|
onClose={({ state, account } = {}) => {
|
|
setShowEditProfile(false);
|
|
if (state === 'success' && account) {
|
|
onProfileUpdate(account);
|
|
}
|
|
}}
|
|
/>
|
|
</Modal>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Apply more alpha if high luminence
|
|
function lightenRGB([r, g, b]) {
|
|
const luminence = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
console.log('luminence', luminence);
|
|
let alpha;
|
|
if (luminence >= 220) {
|
|
alpha = 1;
|
|
} else if (luminence <= 50) {
|
|
alpha = 0.1;
|
|
} else {
|
|
alpha = luminence / 255;
|
|
}
|
|
alpha = Math.min(1, alpha);
|
|
return [r, g, b, alpha];
|
|
}
|
|
|
|
function niceAccountURL(url) {
|
|
if (!url) return;
|
|
const urlObj = URL.parse(url);
|
|
const { host, pathname } = urlObj;
|
|
const path = pathname.replace(/\/$/, '').replace(/^\//, '');
|
|
return (
|
|
<>
|
|
<span class="more-insignificant">{punycode.toUnicode(host)}/</span>
|
|
<wbr />
|
|
<span>{path}</span>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TranslatedBioSheet({ note, fields, onClose }) {
|
|
const fieldsText =
|
|
fields
|
|
?.map(({ name, value }) => `${name}\n${getHTMLText(value)}`)
|
|
.join('\n\n') || '';
|
|
|
|
const text = getHTMLText(note) + (fieldsText ? `\n\n${fieldsText}` : '');
|
|
|
|
return (
|
|
<div class="sheet">
|
|
{!!onClose && (
|
|
<button type="button" class="sheet-close" onClick={onClose}>
|
|
<Icon icon="x" alt={t`Close`} />
|
|
</button>
|
|
)}
|
|
<header>
|
|
<h2>
|
|
<Trans>Translated Bio</Trans>
|
|
</h2>
|
|
</header>
|
|
<main>
|
|
<p
|
|
style={{
|
|
whiteSpace: 'pre-wrap',
|
|
}}
|
|
>
|
|
{text}
|
|
</p>
|
|
<TranslationBlock forceTranslate text={text} />
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AddRemoveListsSheet({ accountID, onClose }) {
|
|
const { masto } = api();
|
|
const [uiState, setUIState] = useState('default');
|
|
const [lists, setLists] = useState([]);
|
|
const [listsContainingAccount, setListsContainingAccount] = useState([]);
|
|
const [reloadCount, reload] = useReducer((c) => c + 1, 0);
|
|
|
|
useEffect(() => {
|
|
setUIState('loading');
|
|
(async () => {
|
|
try {
|
|
const lists = await getLists();
|
|
setLists(lists);
|
|
const listsContainingAccount = await masto.v1.accounts
|
|
.$select(accountID)
|
|
.lists.list();
|
|
console.log({ lists, listsContainingAccount });
|
|
setListsContainingAccount(listsContainingAccount);
|
|
setUIState('default');
|
|
} catch (e) {
|
|
console.error(e);
|
|
setUIState('error');
|
|
}
|
|
})();
|
|
}, [reloadCount]);
|
|
|
|
const [showListAddEditModal, setShowListAddEditModal] = useState(false);
|
|
|
|
return (
|
|
<div class="sheet" id="list-add-remove-container">
|
|
{!!onClose && (
|
|
<button type="button" class="sheet-close" onClick={onClose}>
|
|
<Icon icon="x" alt={t`Close`} />
|
|
</button>
|
|
)}
|
|
<header>
|
|
<h2>
|
|
<Trans>Add/Remove from Lists</Trans>
|
|
</h2>
|
|
</header>
|
|
<main>
|
|
{lists.length > 0 ? (
|
|
<ul class="list-add-remove">
|
|
{lists.map((list) => {
|
|
const inList = listsContainingAccount.some(
|
|
(l) => l.id === list.id,
|
|
);
|
|
return (
|
|
<li>
|
|
<button
|
|
type="button"
|
|
class={`light ${inList ? 'checked' : ''}`}
|
|
disabled={uiState === 'loading'}
|
|
onClick={() => {
|
|
setUIState('loading');
|
|
(async () => {
|
|
try {
|
|
if (inList) {
|
|
await masto.v1.lists
|
|
.$select(list.id)
|
|
.accounts.remove({
|
|
accountIds: [accountID],
|
|
});
|
|
} else {
|
|
await masto.v1.lists
|
|
.$select(list.id)
|
|
.accounts.create({
|
|
accountIds: [accountID],
|
|
});
|
|
}
|
|
// setUIState('default');
|
|
reload();
|
|
} catch (e) {
|
|
console.error(e);
|
|
setUIState('error');
|
|
alert(
|
|
inList
|
|
? t`Unable to remove from list.`
|
|
: t`Unable to add to list.`,
|
|
);
|
|
}
|
|
})();
|
|
}}
|
|
>
|
|
<Icon icon="check-circle" alt="☑️" />
|
|
<span>{list.title}</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
) : uiState === 'loading' ? (
|
|
<p class="ui-state">
|
|
<Loader abrupt />
|
|
</p>
|
|
) : uiState === 'error' ? (
|
|
<p class="ui-state">
|
|
<Trans>Unable to load lists.</Trans>
|
|
</p>
|
|
) : (
|
|
<p class="ui-state">
|
|
<Trans>No lists.</Trans>
|
|
</p>
|
|
)}
|
|
<button
|
|
type="button"
|
|
class="plain2"
|
|
onClick={() => setShowListAddEditModal(true)}
|
|
disabled={uiState !== 'default'}
|
|
>
|
|
<Icon icon="plus" size="l" />{' '}
|
|
<span>
|
|
<Trans>New list</Trans>
|
|
</span>
|
|
</button>
|
|
</main>
|
|
{showListAddEditModal && (
|
|
<Modal
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) {
|
|
setShowListAddEditModal(false);
|
|
}
|
|
}}
|
|
>
|
|
<ListAddEdit
|
|
list={showListAddEditModal?.list}
|
|
onClose={(result) => {
|
|
if (result.state === 'success') {
|
|
reload();
|
|
}
|
|
setShowListAddEditModal(false);
|
|
}}
|
|
/>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PrivateNoteSheet({
|
|
account,
|
|
note: initialNote,
|
|
onRelationshipChange = () => {},
|
|
onClose = () => {},
|
|
}) {
|
|
const { masto } = api();
|
|
const [uiState, setUIState] = useState('default');
|
|
const textareaRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
let timer;
|
|
if (textareaRef.current && !initialNote) {
|
|
timer = setTimeout(() => {
|
|
textareaRef.current.focus?.();
|
|
}, 100);
|
|
}
|
|
return () => {
|
|
clearTimeout(timer);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div class="sheet" id="private-note-container">
|
|
{!!onClose && (
|
|
<button type="button" class="sheet-close" onClick={onClose}>
|
|
<Icon icon="x" alt={t`Close`} />
|
|
</button>
|
|
)}
|
|
<header>
|
|
<b>
|
|
<Trans>
|
|
Private note about @{account?.username || account?.acct}
|
|
</Trans>
|
|
</b>
|
|
</header>
|
|
<main>
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.target);
|
|
const note = formData.get('note');
|
|
if (note?.trim() !== initialNote?.trim()) {
|
|
setUIState('loading');
|
|
(async () => {
|
|
try {
|
|
const newRelationship = await masto.v1.accounts
|
|
.$select(account?.id)
|
|
.note.create({
|
|
comment: note,
|
|
});
|
|
console.log('updated relationship', newRelationship);
|
|
setUIState('default');
|
|
onRelationshipChange(newRelationship);
|
|
onClose();
|
|
} catch (e) {
|
|
console.error(e);
|
|
setUIState('error');
|
|
alert(e?.message || t`Unable to update private note.`);
|
|
}
|
|
})();
|
|
}
|
|
}}
|
|
>
|
|
<textarea
|
|
ref={textareaRef}
|
|
name="note"
|
|
disabled={uiState === 'loading'}
|
|
dir="auto"
|
|
>
|
|
{initialNote}
|
|
</textarea>
|
|
<footer>
|
|
<button
|
|
type="button"
|
|
class="light"
|
|
disabled={uiState === 'loading'}
|
|
onClick={() => {
|
|
onClose?.();
|
|
}}
|
|
>
|
|
<Trans>Cancel</Trans>
|
|
</button>
|
|
<span>
|
|
<Loader abrupt hidden={uiState !== 'loading'} />
|
|
<button disabled={uiState === 'loading'} type="submit">
|
|
<Trans>Save & close</Trans>
|
|
</button>
|
|
</span>
|
|
</footer>
|
|
</form>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EditProfileSheet({ onClose = () => {} }) {
|
|
const { masto } = api();
|
|
const [uiState, setUIState] = useState('loading');
|
|
const [account, setAccount] = useState(null);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const acc = await masto.v1.accounts.verifyCredentials();
|
|
setAccount(acc);
|
|
setUIState('default');
|
|
} catch (e) {
|
|
console.error(e);
|
|
setUIState('error');
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
console.log('EditProfileSheet', account);
|
|
const { displayName, source } = account || {};
|
|
const { note, fields } = source || {};
|
|
const fieldsAttributesRef = useRef(null);
|
|
|
|
return (
|
|
<div class="sheet" id="edit-profile-container">
|
|
{!!onClose && (
|
|
<button type="button" class="sheet-close" onClick={onClose}>
|
|
<Icon icon="x" alt={t`Close`} />
|
|
</button>
|
|
)}
|
|
<header>
|
|
<b>
|
|
<Trans>Edit profile</Trans>
|
|
</b>
|
|
</header>
|
|
<main>
|
|
{uiState === 'loading' ? (
|
|
<p class="ui-state">
|
|
<Loader abrupt />
|
|
</p>
|
|
) : (
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.target);
|
|
const displayName = formData.get('display_name');
|
|
const note = formData.get('note');
|
|
const fieldsAttributesFields =
|
|
fieldsAttributesRef.current.querySelectorAll(
|
|
'input[name^="fields_attributes"]',
|
|
);
|
|
const fieldsAttributes = [];
|
|
fieldsAttributesFields.forEach((field) => {
|
|
const name = field.name;
|
|
const [_, index, key] =
|
|
name.match(/fields_attributes\[(\d+)\]\[(.+)\]/) || [];
|
|
const value = field.value ? field.value.trim() : '';
|
|
if (index && key && value) {
|
|
if (!fieldsAttributes[index]) fieldsAttributes[index] = {};
|
|
fieldsAttributes[index][key] = value;
|
|
}
|
|
});
|
|
// Fill in the blanks
|
|
fieldsAttributes.forEach((field) => {
|
|
if (field.name && !field.value) {
|
|
field.value = '';
|
|
}
|
|
});
|
|
|
|
(async () => {
|
|
try {
|
|
const newAccount = await masto.v1.accounts.updateCredentials({
|
|
displayName,
|
|
note,
|
|
fieldsAttributes,
|
|
});
|
|
console.log('updated account', newAccount);
|
|
onClose?.({
|
|
state: 'success',
|
|
account: newAccount,
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert(e?.message || t`Unable to update profile.`);
|
|
}
|
|
})();
|
|
}}
|
|
>
|
|
<p>
|
|
<label>
|
|
Name{' '}
|
|
<input
|
|
type="text"
|
|
name="display_name"
|
|
defaultValue={displayName}
|
|
maxLength={30}
|
|
disabled={uiState === 'loading'}
|
|
dir="auto"
|
|
/>
|
|
</label>
|
|
</p>
|
|
<p>
|
|
<label>
|
|
<Trans>Bio</Trans>
|
|
<textarea
|
|
defaultValue={note}
|
|
name="note"
|
|
maxLength={500}
|
|
rows="5"
|
|
disabled={uiState === 'loading'}
|
|
dir="auto"
|
|
/>
|
|
</label>
|
|
</p>
|
|
{/* Table for fields; name and values are in fields, min 4 rows */}
|
|
<p>
|
|
<Trans>Extra fields</Trans>
|
|
</p>
|
|
<table ref={fieldsAttributesRef}>
|
|
<thead>
|
|
<tr>
|
|
<th>
|
|
<Trans>Label</Trans>
|
|
</th>
|
|
<th>
|
|
<Trans>Content</Trans>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{Array.from({ length: Math.max(4, fields.length) }).map(
|
|
(_, i) => {
|
|
const { name = '', value = '' } = fields[i] || {};
|
|
return (
|
|
<FieldsAttributesRow
|
|
key={i}
|
|
name={name}
|
|
value={value}
|
|
index={i}
|
|
disabled={uiState === 'loading'}
|
|
/>
|
|
);
|
|
},
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
<footer>
|
|
<button
|
|
type="button"
|
|
class="light"
|
|
disabled={uiState === 'loading'}
|
|
onClick={() => {
|
|
onClose?.();
|
|
}}
|
|
>
|
|
<Trans>Cancel</Trans>
|
|
</button>
|
|
<button type="submit" disabled={uiState === 'loading'}>
|
|
<Trans>Save</Trans>
|
|
</button>
|
|
</footer>
|
|
</form>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FieldsAttributesRow({ name, value, disabled, index: i }) {
|
|
const [hasValue, setHasValue] = useState(!!value);
|
|
return (
|
|
<tr>
|
|
<td>
|
|
<input
|
|
type="text"
|
|
name={`fields_attributes[${i}][name]`}
|
|
defaultValue={name}
|
|
disabled={disabled}
|
|
maxLength={255}
|
|
required={hasValue}
|
|
dir="auto"
|
|
/>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="text"
|
|
name={`fields_attributes[${i}][value]`}
|
|
defaultValue={value}
|
|
disabled={disabled}
|
|
maxLength={255}
|
|
onChange={(e) => setHasValue(!!e.currentTarget.value)}
|
|
dir="auto"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
function AccountHandleInfo({ acct, instance }) {
|
|
// acct = username or username@server
|
|
let [username, server] = acct.split('@');
|
|
if (!server) server = instance;
|
|
return (
|
|
<div class="handle-info">
|
|
<span class="handle-handle">
|
|
<b class="handle-username">{username}</b>
|
|
<span class="handle-at">@</span>
|
|
<b class="handle-server">{server}</b>
|
|
</span>
|
|
<div class="handle-legend">
|
|
<span class="ib">
|
|
<span class="handle-legend-icon username" /> <Trans>username</Trans>
|
|
</span>{' '}
|
|
<span class="ib">
|
|
<span class="handle-legend-icon server" />{' '}
|
|
<Trans>server domain name</Trans>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default AccountInfo;
|