profilecard component

This commit is contained in:
Martti Malmi 2023-07-26 10:59:02 +03:00
parent c96bda4f8d
commit 93110f262f
5 changed files with 225 additions and 208 deletions

View File

@ -665,7 +665,8 @@ export default {
);
});
s = s.map((x) => (typeof x === 'string' ? x.trim() : x));
// remove leading and trailing newlines
s = s.map((x) => (typeof x === 'string' ? x.replace(/^\n+|\n+$/g, '') : x));
return s;
},

View File

@ -5,10 +5,9 @@ import Show from './helpers/Show';
type Props = {
children: JSX.Element | JSX.Element[];
small?: boolean;
};
const Dropdown = ({ children, small }: Props) => {
const Dropdown = ({ children }: Props) => {
const [open, setOpen] = useState(false);
const toggle = (e: MouseEvent, newOpenState: boolean) => {
@ -45,9 +44,7 @@ const Dropdown = ({ children, small }: Props) => {
onMouseEnter={(e) => toggle(e, true)}
onMouseLeave={(e) => toggle(e, false)}
>
<button className={`dropbtn btn btn-circle ${small && 'btn-sm'} btn-ghost text-neutral-500`}>
</button>
<button className="dropbtn btn btn-circle btn-sm btn-ghost text-neutral-500"></button>
<Show when={open}>
<div className="absolute z-10 p-2 flex flex-col gap-2 right-0 w-56 rounded-md shadow-lg bg-black border-neutral-500 border-2">
{children}

View File

@ -104,7 +104,7 @@ const EventDropdown = (props: EventDropdownProps) => {
return (
<div>
<Dropdown small>
<Dropdown>
<Copy className="btn btn-sm" key={`${id!}copy_link`} text={t('copy_link')} copyStr={url} />
<Copy
className="btn btn-sm"

View File

@ -1,55 +1,141 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { route } from 'preact-router';
import Block from '../../components/buttons/Block';
import Report from '../../components/buttons/Report';
import localState from '../../LocalState';
import Events from '../../nostr/Events';
import Key from '../../nostr/Key';
import PubSub from '../../nostr/PubSub';
import SocialNetwork from '../../nostr/SocialNetwork';
import { translate as t } from '../../translations/Translation.mjs';
import Copy from '../buttons/Copy';
import Follow from '../buttons/Follow';
import Dropdown from '../Dropdown';
import Show from '../helpers/Show';
import QRModal from '../modal/QRModal';
import Avatar from './Avatar';
import Name from './Name';
import ProfilePicture from './ProfilePicture';
interface Props {
hexPub: string;
npub: string;
banner: boolean;
picture: any;
blocked: boolean;
profilePictureError: boolean;
loggedIn: boolean;
isMyProfile: boolean;
showQR: boolean;
name: string;
nip05: string;
about: string;
followedUserCount: number;
followerCount: number;
}
const ProfileCard = (props: Props) => {
const {
hexPub,
npub,
banner,
picture,
blocked,
profilePictureError,
loggedIn,
isMyProfile,
showQR,
name,
nip05,
about,
followedUserCount,
followerCount,
} = props;
const ProfileCard = (props: { hexPub: string; npub: string }) => {
const { hexPub, npub } = props;
const [profile, setProfile] = useState<any>({});
const [loggedIn, setLoggedIn] = useState<boolean>(false);
const [nostrAddress, setNostrAddress] = useState<string>('');
const [rawDataJson, setRawDataJson] = useState<string>('');
const [showQRState, setShowQRState] = useState<boolean>(showQR);
const history = useHistory();
const [showQrCode, setShowQrCode] = useState<boolean>(false);
const [isMyProfile, setIsMyProfile] = useState<boolean>(false);
const [blocked, setBlocked] = useState<boolean>(false);
const [followedUserCount, setFollowedUserCount] = useState<number>(0);
const [followerCount, setFollowerCount] = useState<number>(0);
async function viewAs(event) {
event.preventDefault();
route('/');
Key.login({ rpub: hexPub });
}
const getNostrProfile = useCallback((address, nostrAddress) => {
const subscriptions = [] as any[];
subscriptions.push(
PubSub.subscribe(
{
authors: [address],
kinds: [0, 3],
},
undefined,
false,
false,
),
);
fetch(`https://eu.rbr.bio/${address}/info.json`).then((res) => {
if (!res.ok) {
return;
}
res.json().then((json) => {
if (json) {
setFollowedUserCount(json.following?.length || followedUserCount);
setFollowerCount(json.followerCount || followerCount);
}
});
});
const setFollowCounts = () => {
if (address) {
setFollowedUserCount(
Math.max(SocialNetwork.followedByUser.get(address)?.size ?? 0, followedUserCount),
);
setFollowerCount(
Math.max(SocialNetwork.followersByUser.get(address)?.size ?? 0, followerCount),
);
}
};
setTimeout(() => {
subscriptions.push(SocialNetwork.getFollowersByUser(address, setFollowCounts));
subscriptions.push(SocialNetwork.getFollowedByUser(address, setFollowCounts));
}, 1000); // this causes social graph recursive loading, so let some other stuff like feed load first
subscriptions.push(
SocialNetwork.getProfile(
address,
(profile) => {
if (!profile) {
return;
}
const isIrisAddress = nostrAddress && nostrAddress.endsWith('@iris.to');
if (!isIrisAddress && profile.nip05 && profile.nip05valid) {
// replace url and history entry with iris.to/${profile.nip05} or if nip is user@iris.to, just iris.to/${user}
// TODO don't replace if at /likes or /replies
const nip05 = profile.nip05;
const nip05Parts = nip05.split('@');
const nip05User = nip05Parts[0];
const nip05Domain = nip05Parts[1];
let newUrl;
if (nip05Domain === 'iris.to') {
if (nip05User === '_') {
newUrl = 'iris';
} else {
newUrl = nip05User;
}
} else {
if (nip05User === '_') {
newUrl = nip05Domain;
} else {
newUrl = nip05;
}
}
setNostrAddress(newUrl);
// replace part before first slash with new url
newUrl = window.location.pathname.replace(/[^/]+/, newUrl);
const previousState = window.history.state;
window.history.replaceState(previousState, '', newUrl);
}
let lightning = profile.lud16 || profile.lud06;
if (lightning && !lightning.startsWith('lightning:')) {
lightning = 'lightning:' + lightning;
}
let website =
profile.website &&
(profile.website.match(/^https?:\/\//) ? profile.website : 'http://' + profile.website);
// remove trailing slash
if (website && website.endsWith('/')) {
website = website.slice(0, -1);
}
setProfile(profile);
},
true,
),
);
return () => {
subscriptions.forEach((unsub) => unsub());
};
}, []);
useEffect(() => {
const rawDataArray = [];
const rawDataArray = [] as Event[];
const profileEvent = Events.db.findOne({
kind: 0,
pubkey: hexPub,
@ -59,57 +145,103 @@ const ProfileCard = (props: Props) => {
pubkey: hexPub,
});
if (profileEvent) {
delete profileEvent.$loki;
rawDataArray.push(profileEvent);
}
if (followEvent) {
delete followEvent.$loki;
rawDataArray.push(followEvent);
}
setRawDataJson(JSON.stringify(rawDataArray, null, 2));
setIsMyProfile(hexPub === Key.getPubKey());
getNostrProfile(hexPub, nostrAddress);
const unsubLoggedIn = localState.get('loggedIn').on((loggedIn) => {
setLoggedIn(loggedIn);
});
const unsubBlocked = SocialNetwork.getBlockedUsers((blockedUsers) => {
setBlocked(blockedUsers.has(hexPub));
});
return () => {
unsubBlocked();
unsubLoggedIn();
};
}, [hexPub]);
const profilePicture =
!blocked && !profilePictureError ? (
<ProfilePicture
key={`${hexPub}picture`}
picture={picture}
onError={() => /* Handle error here */ null}
/>
) : (
<Avatar key={`${npub}avatar`} str={npub} hidePicture={true} width={128} />
);
const onClickHandler = () =>
!loggedIn && /* Call localState.get('showLoginModal').put(true) here */ null;
const profilePicture = !blocked ? (
<ProfilePicture
key={`${hexPub}picture`}
picture={profile.picture}
onError={() => /* Handle error here */ null}
/>
) : (
<Avatar key={`${npub}avatar`} str={npub} hidePicture={true} width={128} />
);
const onClickHandler = () => !loggedIn && localState.get('showLoginModal').put(true);
return (
<div key={`${hexPub}details`}>
<div className="mb-2 mx-2 md:px-2 md:mx-0 flex flex-col gap-2">
<div className="flex flex-row">
<div className={banner ? '-mt-20' : ''}>{profilePicture}</div>
<div className={profile.banner ? '-mt-20' : ''}>{profilePicture}</div>
<div className="flex-1 justify-end flex">
<div onClick={onClickHandler}>
<Show when={isMyProfile}>
<button
className="btn btn-sm btn-neutral"
onClick={() => loggedIn && history.push('/profile/edit')}
>
<a className="btn btn-sm btn-neutral" href="/profile/edit">
{t('edit_profile')}
</button>
</a>
</Show>
<Show when={!isMyProfile}>
<Follow key={`${hexPub}follow`} id={hexPub} />
{/* More stuff to add here */}
<button
className="btn btn-neutral btn-sm"
onClick={() => loggedIn && route(`/chat/${npub}`)}
>
{t('send_message')}
</button>
</Show>
</div>
<div className="profile-actions">
<Dropdown>{/* Add more things here */}</Dropdown>
<Dropdown>
<Copy
className="btn btn-sm"
key={`${hexPub}copyLink`}
text={t('copy_link')}
copyStr={window.location.href}
/>
<Copy
className="btn btn-sm"
key={`${hexPub}copyNpub`}
text={t('copy_user_ID')}
copyStr={npub}
/>
<button className="btn btn-sm" onClick={() => setShowQrCode(true)}>
{t('show_qr_code')}
</button>
<Copy
className="btn btn-sm"
key={`${hexPub}copyData`}
text={t('copy_raw_data')}
copyStr={rawDataJson}
/>
<Show when={!isMyProfile && !Key.getPrivKey()}>
<button className="btn btn-sm" onClick={viewAs}>
{t('view_as') + ' '}
<Name pub={hexPub} hideBadge={true} />
</button>
</Show>
<Show when={!isMyProfile}>
<>
<Block className="btn btn-sm" id={hexPub} />
<Report className="btn btn-sm" id={hexPub} />
</>
</Show>
</Dropdown>
</div>
</div>
</div>
{/* More code here */}
</div>
{showQRState && <QRModal pub={hexPub} onClose={() => setShowQRState(false)} />}
<Show when={showQrCode}>
<QRModal pub={hexPub} onClose={() => setShowQrCode(false)} />
</Show>
</div>
);
};

View File

@ -10,15 +10,12 @@ import ProfileCard from '../components/user/ProfileCard';
import Helpers from '../Helpers';
import localState from '../LocalState';
import Key from '../nostr/Key';
import PubSub from '../nostr/PubSub';
import SocialNetwork from '../nostr/SocialNetwork';
import { translate as t } from '../translations/Translation.mjs';
import View from './View';
class Profile extends View {
followedUsers: Set<string>;
followers: Set<string>;
subscriptions: any[];
unsub: any;
@ -28,14 +25,12 @@ class Profile extends View {
followedUserCount: 0,
followerCount: 0,
};
this.followedUsers = new Set();
this.followers = new Set();
this.id = 'profile';
this.subscriptions = [];
}
getNotification() {
if (this.state.noFollowers && this.followers.has(Key.getPubKey())) {
if (this.state.noFollowers /* && this.followers.has(Key.getPubKey()) */) {
return (
<div className="msg">
<div className="msg-content">
@ -172,7 +167,7 @@ class Profile extends View {
<meta property="og:title" content={ogTitle} />
<meta property="og:description" content={description} />
</Helmet>
<ProfileCard />
<ProfileCard npub={this.state.npub} hexPub={this.state.hexPub} />
<Show when={!blocked}>
{this.renderTabs()}
{this.renderTab()}
@ -182,138 +177,6 @@ class Profile extends View {
);
}
getNostrProfile(address, nostrAddress) {
this.unsub = PubSub.subscribe(
{
authors: [address],
kinds: [0, 3],
},
undefined,
false,
false,
);
fetch(`https://eu.rbr.bio/${address}/info.json`).then((res) => {
if (!res.ok) {
return;
}
res.json().then((json) => {
if (json) {
this.setState({
followerCount: json.followerCount || this.state.followerCount,
followedUserCount: json.following?.length || this.state.followedUserCount,
});
}
});
});
const setFollowCounts = () => {
address &&
this.setState({
followedUserCount: Math.max(
SocialNetwork.followedByUser.get(address)?.size ?? 0,
this.state.followedUserCount,
),
followerCount: Math.max(
SocialNetwork.followersByUser.get(address)?.size ?? 0,
this.state.followerCount,
),
});
};
setTimeout(() => {
this.subscriptions.push(SocialNetwork.getFollowersByUser(address, setFollowCounts));
this.subscriptions.push(SocialNetwork.getFollowedByUser(address, setFollowCounts));
}, 1000); // this causes social graph recursive loading, so let some other stuff like feed load first
const unsub = SocialNetwork.getProfile(
address,
(profile) => {
if (!profile) {
return;
}
const isIrisAddress = nostrAddress && nostrAddress.endsWith('@iris.to');
if (!isIrisAddress && profile.nip05 && profile.nip05valid) {
// replace url and history entry with iris.to/${profile.nip05} or if nip is user@iris.to, just iris.to/${user}
// TODO don't replace if at /likes or /replies
const nip05 = profile.nip05;
const nip05Parts = nip05.split('@');
const nip05User = nip05Parts[0];
const nip05Domain = nip05Parts[1];
let newUrl;
if (nip05Domain === 'iris.to') {
if (nip05User === '_') {
newUrl = 'iris';
} else {
newUrl = nip05User;
}
} else {
if (nip05User === '_') {
newUrl = nip05Domain;
} else {
newUrl = nip05;
}
}
this.setState({ nostrAddress: newUrl });
// replace part before first slash with new url
newUrl = window.location.pathname.replace(/[^/]+/, newUrl);
const previousState = window.history.state;
window.history.replaceState(previousState, '', newUrl);
}
let lightning = profile.lud16 || profile.lud06;
if (lightning && !lightning.startsWith('lightning:')) {
lightning = 'lightning:' + lightning;
}
let website =
profile.website &&
(profile.website.match(/^https?:\/\//) ? profile.website : 'http://' + profile.website);
// remove trailing slash
if (website && website.endsWith('/')) {
website = website.slice(0, -1);
}
let banner;
try {
banner = profile.banner && new URL(profile.banner).toString();
banner = isSafeOrigin(banner)
? banner
: `https://imgproxy.iris.to/insecure/plain/${banner}`;
} catch (e) {
console.log('Invalid banner URL', profile.banner);
}
// profile may contain arbitrary fields, so be careful what you pass to setState
this.setState({
name: profile.name,
display_name: profile.display_name,
about: Helpers.highlightText(profile.about),
picture: profile.picture,
nip05: profile.nip05valid && profile.nip05,
lightning,
website: website,
banner,
profile,
});
},
true,
);
this.subscriptions.push(unsub);
}
loadProfile(hexPub: string, nostrAddress?: string) {
const isMyProfile = hexPub === Key.getPubKey();
localState.get('isMyProfile').put(isMyProfile);
this.setState({ isMyProfile });
this.followedUsers = new Set();
this.followers = new Set();
localState.get('noFollowers').on(this.inject());
this.getNostrProfile(hexPub, nostrAddress);
this.subscriptions.push(
SocialNetwork.getBlockedUsers((blockedUsers) => {
this.setState({ blocked: blockedUsers.has(hexPub) });
}),
);
}
componentWillUnmount() {
super.componentWillUnmount();
this.unsub?.();
@ -330,6 +193,30 @@ class Profile extends View {
}
}
loadProfile(hexPub: string) {
const isMyProfile = hexPub === Key.getPubKey();
localState.get('isMyProfile').put(isMyProfile);
localState.get('noFollowers').on(this.inject());
this.subscriptions.push(
SocialNetwork.getProfile(hexPub, (profile) => {
let banner;
try {
banner = profile.banner && new URL(profile.banner).toString();
if (!banner) {
return;
}
banner = isSafeOrigin(banner)
? banner
: `https://imgproxy.iris.to/insecure/plain/${banner}`;
this.setState({ banner });
} catch (e) {
console.log('Invalid banner URL', profile.banner);
}
}),
);
}
componentDidMount() {
this.restoreScrollPosition();
const pub = this.props.id;
@ -356,7 +243,7 @@ class Profile extends View {
const npub = Key.toNostrBech32Address(pubKey, 'npub');
if (npub && npub !== pubKey) {
this.setState({ npub, hexPub: pubKey });
this.loadProfile(pubKey, nostrAddress);
this.loadProfile(pubKey);
}
} else {
this.setState({ notFound: true });