reorganize code into smaller files & dirs
This commit is contained in:
1835
packages/app/src/Components/User/AnimalName.ts
Normal file
1835
packages/app/src/Components/User/AnimalName.ts
Normal file
File diff suppressed because it is too large
Load Diff
43
packages/app/src/Components/User/Avatar.css
Normal file
43
packages/app/src/Components/User/Avatar.css
Normal file
@ -0,0 +1,43 @@
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
height: 210px;
|
||||
width: 210px;
|
||||
background-image: var(--img-url);
|
||||
border: 1px solid transparent;
|
||||
background-origin: border-box;
|
||||
background-clip: content-box, border-box;
|
||||
background-size: cover;
|
||||
box-sizing: border-box;
|
||||
background-position: center;
|
||||
background-color: var(--gray);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.avatar[data-domain="iris.to"],
|
||||
.avatar[data-domain="snort.social"] {
|
||||
background-image: var(--img-url), var(--snort-gradient);
|
||||
}
|
||||
|
||||
.avatar .overlay {
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.avatar .icons {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform-origin: center;
|
||||
transform: rotate(-135deg) translateY(50%);
|
||||
}
|
71
packages/app/src/Components/User/Avatar.tsx
Normal file
71
packages/app/src/Components/User/Avatar.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import "./Avatar.css";
|
||||
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import type { UserMetadata } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { defaultAvatar, getDisplayName } from "@/Utils";
|
||||
import { ProxyImg } from "@/Components/ProxyImg";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
|
||||
interface AvatarProps {
|
||||
pubkey: string;
|
||||
user?: UserMetadata;
|
||||
onClick?: () => void;
|
||||
size?: number;
|
||||
image?: string;
|
||||
imageOverlay?: ReactNode;
|
||||
icons?: ReactNode;
|
||||
showTitle?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Avatar = ({
|
||||
pubkey,
|
||||
user,
|
||||
size,
|
||||
onClick,
|
||||
image,
|
||||
imageOverlay,
|
||||
icons,
|
||||
className,
|
||||
showTitle = true,
|
||||
}: AvatarProps) => {
|
||||
const url = useMemo(() => {
|
||||
return image ?? user?.picture ?? defaultAvatar(pubkey);
|
||||
}, [user, image, pubkey]);
|
||||
|
||||
const s = size ?? 120;
|
||||
const style = {} as React.CSSProperties;
|
||||
if (size) {
|
||||
style.width = `${size}px`;
|
||||
style.height = `${size}px`;
|
||||
}
|
||||
|
||||
const domain = user?.nip05 && user.nip05.split("@")[1];
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
className={classNames(
|
||||
"avatar relative flex items-center justify-center",
|
||||
{ "with-overlay": imageOverlay },
|
||||
className,
|
||||
)}
|
||||
data-domain={domain?.toLowerCase()}
|
||||
title={showTitle ? getDisplayName(user, "") : undefined}>
|
||||
<ProxyImg
|
||||
className="rounded-full object-cover aspect-square"
|
||||
src={url}
|
||||
size={s}
|
||||
alt={getDisplayName(user, "")}
|
||||
promptToLoadDirectly={false}
|
||||
missingImageElement={<Icon name="x" className="warning" />}
|
||||
/>
|
||||
{icons && <div className="icons">{icons}</div>}
|
||||
{imageOverlay && <div className="overlay">{imageOverlay}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Avatar;
|
20
packages/app/src/Components/User/AvatarEditor.css
Normal file
20
packages/app/src/Components/User/AvatarEditor.css
Normal file
@ -0,0 +1,20 @@
|
||||
.avatar .edit,
|
||||
.banner .edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg-color);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.avatar .edit.new {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.avatar .edit:hover {
|
||||
opacity: 0.5;
|
||||
}
|
54
packages/app/src/Components/User/AvatarEditor.tsx
Normal file
54
packages/app/src/Components/User/AvatarEditor.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import "./AvatarEditor.css";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import { useState } from "react";
|
||||
import useFileUpload from "@/Utils/Upload";
|
||||
import { openFile, unwrap } from "@/Utils";
|
||||
import Spinner from "@/Components/Icons/Spinner";
|
||||
|
||||
interface AvatarEditorProps {
|
||||
picture?: string;
|
||||
onPictureChange?: (newPicture: string) => void;
|
||||
}
|
||||
|
||||
export default function AvatarEditor({ picture, onPictureChange }: AvatarEditorProps) {
|
||||
const uploader = useFileUpload();
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function uploadFile() {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const f = await openFile();
|
||||
if (f) {
|
||||
const rsp = await uploader.upload(f, f.name);
|
||||
console.log(rsp);
|
||||
if (typeof rsp?.error === "string") {
|
||||
setError(`Upload failed: ${rsp.error}`);
|
||||
} else {
|
||||
onPictureChange?.(unwrap(rsp.url));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(`Upload failed: ${e.message}`);
|
||||
} else {
|
||||
setError(`Upload failed`);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center items-center">
|
||||
<div style={{ backgroundImage: `url(${picture})` }} className="avatar">
|
||||
<div className={`edit${picture ? "" : " new"}`} onClick={() => uploadFile().catch(console.error)}>
|
||||
{loading ? <Spinner /> : <Icon name={picture ? "edit" : "camera-plus"} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <b className="error">{error}</b>}
|
||||
</>
|
||||
);
|
||||
}
|
34
packages/app/src/Components/User/BadgeList.css
Normal file
34
packages/app/src/Components/User/BadgeList.css
Normal file
@ -0,0 +1,34 @@
|
||||
.badge-list {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge-item {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.badge-item:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.badge-info p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge-info h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badges-item {
|
||||
align-items: flex-start;
|
||||
}
|
71
packages/app/src/Components/User/BadgeList.tsx
Normal file
71
packages/app/src/Components/User/BadgeList.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import "./BadgeList.css";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
import { ProxyImg } from "@/Components/ProxyImg";
|
||||
import Modal from "@/Components/Modal/Modal";
|
||||
import Username from "@/Components/User/Username";
|
||||
import { findTag } from "@/Utils";
|
||||
import CloseButton from "@/Components/Button/CloseButton";
|
||||
|
||||
export default function BadgeList({ badges }: { badges: TaggedNostrEvent[] }) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const badgeMetadata = badges.map(b => {
|
||||
const thumb = findTag(b, "thumb");
|
||||
const image = findTag(b, "image");
|
||||
const name = findTag(b, "name");
|
||||
const description = findTag(b, "description");
|
||||
return {
|
||||
id: b.id,
|
||||
pubkey: b.pubkey,
|
||||
name,
|
||||
description,
|
||||
thumb: thumb?.length ?? 0 > 0 ? thumb : image,
|
||||
image,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div className="badge-list" onClick={() => setShowModal(!showModal)}>
|
||||
{badgeMetadata.slice(0, 8).map(({ id, name, thumb }) => (
|
||||
<ProxyImg alt={name} key={id} className="badge-item" size={64} src={thumb} />
|
||||
))}
|
||||
</div>
|
||||
{showModal && (
|
||||
<Modal id="badges" className="reactions-modal" onClose={() => setShowModal(false)}>
|
||||
<div className="reactions-view">
|
||||
<CloseButton className="absolute right-2 top-2" onClick={() => setShowModal(false)} />
|
||||
<div className="reactions-header">
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Badges" id="h8XMJL" />
|
||||
</h2>
|
||||
</div>
|
||||
<div className="body">
|
||||
{badgeMetadata.map(({ id, name, pubkey, description, image }) => {
|
||||
return (
|
||||
<div key={id} className="reactions-item badges-item">
|
||||
<ProxyImg className="reaction-icon" src={image} size={64} alt={name} />
|
||||
<div className="badge-info">
|
||||
<h3>{name}</h3>
|
||||
<p>{description}</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="By: {author}"
|
||||
id="RfhLwC"
|
||||
values={{ author: <Username pubkey={pubkey} onLinkVisit={() => setShowModal(false)} /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
24
packages/app/src/Components/User/BlockButton.tsx
Normal file
24
packages/app/src/Components/User/BlockButton.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/system";
|
||||
import useModeration from "@/Hooks/useModeration";
|
||||
|
||||
import messages from "../messages";
|
||||
|
||||
interface BlockButtonProps {
|
||||
pubkey: HexKey;
|
||||
}
|
||||
|
||||
const BlockButton = ({ pubkey }: BlockButtonProps) => {
|
||||
const { block, unblock, isBlocked } = useModeration();
|
||||
return isBlocked(pubkey) ? (
|
||||
<button className="secondary" type="button" onClick={() => unblock(pubkey)}>
|
||||
<FormattedMessage {...messages.Unblock} />
|
||||
</button>
|
||||
) : (
|
||||
<button className="secondary" type="button" onClick={() => block(pubkey)}>
|
||||
<FormattedMessage {...messages.Block} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockButton;
|
15
packages/app/src/Components/User/BlockList.tsx
Normal file
15
packages/app/src/Components/User/BlockList.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import BlockButton from "@/Components/User/BlockButton";
|
||||
import ProfilePreview from "@/Components/User/ProfilePreview";
|
||||
import useModeration from "@/Hooks/useModeration";
|
||||
|
||||
export default function BlockList() {
|
||||
const { blocked } = useModeration();
|
||||
|
||||
return (
|
||||
<div className="main-content p">
|
||||
{blocked.map(a => {
|
||||
return <ProfilePreview actions={<BlockButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
58
packages/app/src/Components/User/Bookmarks.tsx
Normal file
58
packages/app/src/Components/User/Bookmarks.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { useState, useMemo, ChangeEvent } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey, TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
import Note from "@/Components/Event/Note";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { UserCache } from "@/Cache";
|
||||
|
||||
import messages from "../messages";
|
||||
|
||||
interface BookmarksProps {
|
||||
pubkey: HexKey;
|
||||
bookmarks: readonly TaggedNostrEvent[];
|
||||
related: readonly TaggedNostrEvent[];
|
||||
}
|
||||
|
||||
const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => {
|
||||
const [onlyPubkey, setOnlyPubkey] = useState<HexKey | "all">("all");
|
||||
const { publicKey } = useLogin(s => ({ publicKey: s.publicKey }));
|
||||
const ps = useMemo(() => {
|
||||
return [...new Set(bookmarks.map(ev => ev.pubkey))];
|
||||
}, [bookmarks]);
|
||||
|
||||
function renderOption(p: HexKey) {
|
||||
const profile = UserCache.getFromCache(p);
|
||||
return profile ? <option value={p}>{profile?.display_name || profile?.name}</option> : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-end p">
|
||||
<select
|
||||
disabled={ps.length <= 1}
|
||||
value={onlyPubkey}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => setOnlyPubkey(e.target.value)}>
|
||||
<option value="all">
|
||||
<FormattedMessage {...messages.All} />
|
||||
</option>
|
||||
{ps.map(renderOption)}
|
||||
</select>
|
||||
</div>
|
||||
{bookmarks
|
||||
.filter(b => (onlyPubkey === "all" ? true : b.pubkey === onlyPubkey))
|
||||
.map(n => {
|
||||
return (
|
||||
<Note
|
||||
key={n.id}
|
||||
data={n}
|
||||
related={related}
|
||||
options={{ showTime: false, showBookmarked: true, canUnbookmark: publicKey === pubkey }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bookmarks;
|
3
packages/app/src/Components/User/DisplayName.css
Normal file
3
packages/app/src/Components/User/DisplayName.css
Normal file
@ -0,0 +1,3 @@
|
||||
.placeholder {
|
||||
color: var(--gray-light);
|
||||
}
|
20
packages/app/src/Components/User/DisplayName.tsx
Normal file
20
packages/app/src/Components/User/DisplayName.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import "./DisplayName.css";
|
||||
import { useMemo } from "react";
|
||||
import { HexKey, UserMetadata } from "@snort/system";
|
||||
import { getDisplayNameOrPlaceHolder } from "@/Utils";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface DisplayNameProps {
|
||||
pubkey: HexKey;
|
||||
user?: UserMetadata | undefined;
|
||||
}
|
||||
|
||||
const DisplayName = ({ pubkey }: DisplayNameProps) => {
|
||||
const profile = useUserProfile(pubkey);
|
||||
const [name, isPlaceHolder] = useMemo(() => getDisplayNameOrPlaceHolder(profile, pubkey), [profile, pubkey]);
|
||||
|
||||
return <span className={classNames({ placeholder: isPlaceHolder })}>{name}</span>;
|
||||
};
|
||||
|
||||
export default DisplayName;
|
49
packages/app/src/Components/User/FollowButton.tsx
Normal file
49
packages/app/src/Components/User/FollowButton.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/system";
|
||||
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import { parseId } from "@/Utils";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import AsyncButton from "@/Components/Button/AsyncButton";
|
||||
|
||||
import messages from "../messages";
|
||||
import { FollowsFeed } from "@/Cache";
|
||||
|
||||
export interface FollowButtonProps {
|
||||
pubkey: HexKey;
|
||||
className?: string;
|
||||
}
|
||||
export default function FollowButton(props: FollowButtonProps) {
|
||||
const pubkey = parseId(props.pubkey);
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const { follows, readonly } = useLogin(s => ({ follows: s.follows, readonly: s.readonly }));
|
||||
const isFollowing = follows.item.includes(pubkey);
|
||||
const baseClassname = props.className ? `${props.className} ` : "";
|
||||
|
||||
async function follow(pubkey: HexKey) {
|
||||
if (publisher) {
|
||||
const ev = await publisher.contactList([pubkey, ...follows.item].map(a => ["p", a]));
|
||||
system.BroadcastEvent(ev);
|
||||
await FollowsFeed.backFill(system, [pubkey]);
|
||||
}
|
||||
}
|
||||
|
||||
async function unfollow(pubkey: HexKey) {
|
||||
if (publisher) {
|
||||
const ev = await publisher.contactList(follows.item.filter(a => a !== pubkey).map(a => ["p", a]));
|
||||
system.BroadcastEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AsyncButton
|
||||
className={isFollowing ? `${baseClassname} secondary` : `${baseClassname} primary`}
|
||||
disabled={readonly}
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
await (isFollowing ? unfollow(pubkey) : follow(pubkey));
|
||||
}}>
|
||||
{isFollowing ? <FormattedMessage {...messages.Unfollow} /> : <FormattedMessage {...messages.Follow} />}
|
||||
</AsyncButton>
|
||||
);
|
||||
}
|
37
packages/app/src/Components/User/FollowDistanceIndicator.tsx
Normal file
37
packages/app/src/Components/User/FollowDistanceIndicator.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { HexKey, socialGraphInstance } from "@snort/system";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface FollowDistanceIndicatorProps {
|
||||
pubkey: HexKey;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FollowDistanceIndicator({ pubkey, className }: FollowDistanceIndicatorProps) {
|
||||
const followDistance = socialGraphInstance.getFollowDistance(pubkey);
|
||||
let followDistanceColor = "";
|
||||
let title = "";
|
||||
|
||||
if (followDistance === 0) {
|
||||
title = "You";
|
||||
followDistanceColor = "success";
|
||||
} else if (followDistance <= 1) {
|
||||
followDistanceColor = "success";
|
||||
title = "Following";
|
||||
} else if (followDistance === 2) {
|
||||
const followedByFriendsCount = socialGraphInstance.followedByFriendsCount(pubkey);
|
||||
if (followedByFriendsCount > 10) {
|
||||
followDistanceColor = "text-nostr-orange";
|
||||
}
|
||||
title = `Followed by ${followedByFriendsCount} friends`;
|
||||
} else if (followDistance > 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classNames("icon-circle", className)} title={title}>
|
||||
<Icon name="check" className={followDistanceColor} size={10} />
|
||||
</span>
|
||||
);
|
||||
}
|
71
packages/app/src/Components/User/FollowListBase.tsx
Normal file
71
packages/app/src/Components/User/FollowListBase.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { ReactNode } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/system";
|
||||
import { dedupe } from "@snort/shared";
|
||||
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import ProfilePreview from "@/Components/User/ProfilePreview";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
|
||||
import messages from "../messages";
|
||||
import { FollowsFeed } from "@/Cache";
|
||||
import AsyncButton from "../Button/AsyncButton";
|
||||
import { setFollows } from "@/Utils/Login";
|
||||
|
||||
export interface FollowListBaseProps {
|
||||
pubkeys: HexKey[];
|
||||
title?: ReactNode;
|
||||
showFollowAll?: boolean;
|
||||
showAbout?: boolean;
|
||||
className?: string;
|
||||
actions?: ReactNode;
|
||||
profileActions?: (pk: string) => ReactNode;
|
||||
}
|
||||
|
||||
export default function FollowListBase({
|
||||
pubkeys,
|
||||
title,
|
||||
showFollowAll,
|
||||
showAbout,
|
||||
className,
|
||||
actions,
|
||||
profileActions,
|
||||
}: FollowListBaseProps) {
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const { id, follows } = useLogin(s => ({ id: s.id, follows: s.follows }));
|
||||
const login = useLogin();
|
||||
|
||||
async function followAll() {
|
||||
if (publisher) {
|
||||
const newFollows = dedupe([...pubkeys, ...follows.item]);
|
||||
const ev = await publisher.contactList(newFollows.map(a => ["p", a]));
|
||||
setFollows(id, newFollows, ev.created_at);
|
||||
await system.BroadcastEvent(ev);
|
||||
await FollowsFeed.backFill(system, pubkeys);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col g8">
|
||||
{(showFollowAll ?? true) && (
|
||||
<div className="flex items-center">
|
||||
<div className="grow font-bold">{title}</div>
|
||||
{actions}
|
||||
<AsyncButton className="transparent" type="button" onClick={() => followAll()} disabled={login.readonly}>
|
||||
<FormattedMessage {...messages.FollowAll} />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
)}
|
||||
<div className={className}>
|
||||
{pubkeys?.map(a => (
|
||||
<ProfilePreview
|
||||
pubkey={a}
|
||||
key={a}
|
||||
options={{ about: showAbout, profileCards: true }}
|
||||
actions={profileActions?.(a)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
59
packages/app/src/Components/User/FollowedBy.tsx
Normal file
59
packages/app/src/Components/User/FollowedBy.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import FollowDistanceIndicator from "@/Components/User/FollowDistanceIndicator";
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Fragment } from "react";
|
||||
import { ProfileLink } from "@/Components/User/ProfileLink";
|
||||
import DisplayName from "@/Components/User/DisplayName";
|
||||
import { socialGraphInstance } from "@snort/system";
|
||||
|
||||
const MAX_FOLLOWED_BY_FRIENDS = 3;
|
||||
|
||||
export default function FollowedBy({ pubkey }: { pubkey: string }) {
|
||||
const followedByFriends = socialGraphInstance.followedByFriends(pubkey);
|
||||
const followedByFriendsArray = Array.from(followedByFriends).slice(0, MAX_FOLLOWED_BY_FRIENDS);
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center">
|
||||
<FollowDistanceIndicator className="p-2" pubkey={pubkey} />
|
||||
{followedByFriendsArray.map((a, index) => {
|
||||
const zIndex = followedByFriendsArray.length - index;
|
||||
|
||||
return (
|
||||
<div className={`inline-block ${index > 0 ? "-ml-5" : ""}`} key={a} style={{ zIndex }}>
|
||||
<ProfileImage showFollowDistance={false} pubkey={a} size={24} showUsername={false} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{followedByFriends.size > 0 && (
|
||||
<div className="text-gray-light">
|
||||
<span className="mr-1">
|
||||
<FormattedMessage defaultMessage="Followed by" id="6mr8WU" />
|
||||
</span>
|
||||
{followedByFriendsArray.map((a, index) => (
|
||||
<Fragment key={a}>
|
||||
<ProfileLink pubkey={a} className="link inline">
|
||||
<DisplayName user={undefined} pubkey={a} />
|
||||
</ProfileLink>
|
||||
{index < followedByFriendsArray.length - 1 && ","}{" "}
|
||||
</Fragment>
|
||||
))}
|
||||
{followedByFriends.size > MAX_FOLLOWED_BY_FRIENDS && (
|
||||
<span>
|
||||
<FormattedMessage
|
||||
defaultMessage="and {count} others you follow"
|
||||
id="CYkOCI"
|
||||
values={{ count: followedByFriends.size - MAX_FOLLOWED_BY_FRIENDS }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{followedByFriends.size === 0 && (
|
||||
<div className="text-gray-light">
|
||||
<FormattedMessage defaultMessage="Not followed by anyone you follow" id="IgsWFG" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
7
packages/app/src/Components/User/Following.css
Normal file
7
packages/app/src/Components/User/Following.css
Normal file
@ -0,0 +1,7 @@
|
||||
span.following {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--font-secondary-color);
|
||||
background-color: var(--gray-superdark);
|
||||
}
|
18
packages/app/src/Components/User/Following.tsx
Normal file
18
packages/app/src/Components/User/Following.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import "./Following.css";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
|
||||
export function FollowingMark({ pubkey }: { pubkey: string }) {
|
||||
const { follows } = useLogin(s => ({ follows: s.follows }));
|
||||
const doesFollow = follows.item.includes(pubkey);
|
||||
if (!doesFollow) return;
|
||||
|
||||
return (
|
||||
<span className="following flex g4">
|
||||
<Icon name="check" className="success" size={12} />
|
||||
<FormattedMessage defaultMessage="following" id="+tShPg" />
|
||||
</span>
|
||||
);
|
||||
}
|
10
packages/app/src/Components/User/FollowsYou.css
Normal file
10
packages/app/src/Components/User/FollowsYou.css
Normal file
@ -0,0 +1,10 @@
|
||||
.follows-you {
|
||||
color: var(--font-secondary-color);
|
||||
font-size: var(--font-size-tiny);
|
||||
margin-left: 0.2em;
|
||||
font-weight: normal;
|
||||
padding: 4px 6px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
line-height: 1;
|
||||
}
|
13
packages/app/src/Components/User/FollowsYou.tsx
Normal file
13
packages/app/src/Components/User/FollowsYou.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import "./FollowsYou.css";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import messages from "../messages";
|
||||
|
||||
export interface FollowsYouProps {
|
||||
followsMe: boolean;
|
||||
}
|
||||
|
||||
export default function FollowsYou({ followsMe }: FollowsYouProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
return followsMe ? <span className="follows-you">{formatMessage(messages.FollowsYou)}</span> : null;
|
||||
}
|
24
packages/app/src/Components/User/MuteButton.tsx
Normal file
24
packages/app/src/Components/User/MuteButton.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/system";
|
||||
import useModeration from "@/Hooks/useModeration";
|
||||
|
||||
import messages from "../messages";
|
||||
|
||||
interface MuteButtonProps {
|
||||
pubkey: HexKey;
|
||||
}
|
||||
|
||||
const MuteButton = ({ pubkey }: MuteButtonProps) => {
|
||||
const { mute, unmute, isMuted } = useModeration();
|
||||
return isMuted(pubkey) ? (
|
||||
<button className="secondary" type="button" onClick={() => unmute(pubkey)}>
|
||||
<FormattedMessage {...messages.Unmute} />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => mute(pubkey)}>
|
||||
<FormattedMessage {...messages.Mute} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MuteButton;
|
36
packages/app/src/Components/User/MutedList.tsx
Normal file
36
packages/app/src/Components/User/MutedList.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/system";
|
||||
import MuteButton from "@/Components/User/MuteButton";
|
||||
import ProfilePreview from "@/Components/User/ProfilePreview";
|
||||
import useModeration from "@/Hooks/useModeration";
|
||||
|
||||
import messages from "../messages";
|
||||
|
||||
export interface MutedListProps {
|
||||
pubkeys: HexKey[];
|
||||
}
|
||||
|
||||
export default function MutedList({ pubkeys }: MutedListProps) {
|
||||
const { isMuted, muteAll } = useModeration();
|
||||
const hasAllMuted = pubkeys.every(isMuted);
|
||||
|
||||
return (
|
||||
<div className="p">
|
||||
<div className="flex justify-between">
|
||||
<div className="bold">
|
||||
<FormattedMessage {...messages.MuteCount} values={{ n: pubkeys?.length }} />
|
||||
</div>
|
||||
<button
|
||||
disabled={hasAllMuted || pubkeys.length === 0}
|
||||
className="transparent"
|
||||
type="button"
|
||||
onClick={() => muteAll(pubkeys)}>
|
||||
<FormattedMessage {...messages.MuteAll} />
|
||||
</button>
|
||||
</div>
|
||||
{pubkeys?.map(a => {
|
||||
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
52
packages/app/src/Components/User/Nip05.css
Normal file
52
packages/app/src/Components/User/Nip05.css
Normal file
@ -0,0 +1,52 @@
|
||||
.nip05 {
|
||||
color: var(--font-secondary-color);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.nip05 .domain {
|
||||
color: var(--font-secondary-color);
|
||||
background-color: var(--font-secondary-color);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="iris.to"],
|
||||
.nip05 .domain[data-domain="snort.social"] {
|
||||
background-image: var(--snort-gradient);
|
||||
}
|
||||
|
||||
/* NO PAYMENTS MADE ~~ REMOVING
|
||||
.nip05 .domain[data-domain="nostrplebs.com"] {
|
||||
color: var(--highlight);
|
||||
background-color: var(--highlight);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="nostrpurple.com"] {
|
||||
color: var(--highlight);
|
||||
background-color: var(--highlight);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="nostr.fan"] {
|
||||
color: var(--highlight);
|
||||
background-color: var(--highlight);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="nostriches.net"] {
|
||||
color: var(--highlight);
|
||||
background-color: var(--highlight);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
*/
|
||||
|
||||
.nip05 .badge {
|
||||
color: var(--highlight);
|
||||
margin-left: 0.2em;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.zap .pfp .display-name {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
35
packages/app/src/Components/User/Nip05.tsx
Normal file
35
packages/app/src/Components/User/Nip05.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import "./Nip05.css";
|
||||
import { HexKey } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
export function useIsVerified(pubkey?: HexKey, bypassCheck?: boolean) {
|
||||
const profile = useUserProfile(pubkey);
|
||||
return { isVerified: bypassCheck || profile?.isNostrAddressValid };
|
||||
}
|
||||
|
||||
export interface Nip05Params {
|
||||
nip05?: string;
|
||||
pubkey: HexKey;
|
||||
verifyNip?: boolean;
|
||||
}
|
||||
|
||||
const Nip05 = ({ nip05, pubkey, verifyNip = true }: Nip05Params) => {
|
||||
const [name, domain] = nip05 ? nip05.split("@") : [];
|
||||
const isDefaultUser = name === "_";
|
||||
const { isVerified } = useIsVerified(pubkey, !verifyNip);
|
||||
|
||||
return (
|
||||
<div className={`flex nip05${!isVerified ? " failed" : ""}`}>
|
||||
{!isDefaultUser && isVerified && <span className="nick">{`${name}@`}</span>}
|
||||
{isVerified && (
|
||||
<>
|
||||
<span className="domain" data-domain={domain?.toLowerCase()}>
|
||||
{domain}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Nip05;
|
37
packages/app/src/Components/User/NoteToSelf.css
Normal file
37
packages/app/src/Components/User/NoteToSelf.css
Normal file
@ -0,0 +1,37 @@
|
||||
.nts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nts .avatar-wrapper {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.nts .avatar {
|
||||
border-width: 1px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nts .avatar.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nts a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nts .name {
|
||||
margin-top: -0.2em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nts .nip05 {
|
||||
margin: 0;
|
||||
margin-top: -0.2em;
|
||||
}
|
31
packages/app/src/Components/User/NoteToSelf.tsx
Normal file
31
packages/app/src/Components/User/NoteToSelf.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import "./NoteToSelf.css";
|
||||
import classNames from "classnames";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
|
||||
import messages from "../messages";
|
||||
|
||||
export interface NoteToSelfProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function NoteLabel() {
|
||||
return (
|
||||
<div className="bold flex items-center g4">
|
||||
<FormattedMessage {...messages.NoteToSelf} /> <Icon name="badge" size={15} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NoteToSelf({ className }: NoteToSelfProps) {
|
||||
return (
|
||||
<div className={classNames("nts", className)}>
|
||||
<div className="avatar-wrapper">
|
||||
<div className="avatar">
|
||||
<Icon name="book-closed" size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<NoteLabel />
|
||||
</div>
|
||||
);
|
||||
}
|
15
packages/app/src/Components/User/ProfileCard.css
Normal file
15
packages/app/src/Components/User/ProfileCard.css
Normal file
@ -0,0 +1,15 @@
|
||||
.profile-card {
|
||||
width: 360px;
|
||||
border-radius: 16px;
|
||||
background: var(--gray-superdark);
|
||||
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.profile-card > div {
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.light .profile-card > div {
|
||||
color: black;
|
||||
}
|
76
packages/app/src/Components/User/ProfileCard.tsx
Normal file
76
packages/app/src/Components/User/ProfileCard.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import "./ProfileCard.css";
|
||||
|
||||
import { ControlledMenu } from "@szhsin/react-menu";
|
||||
import { UserMetadata } from "@snort/system";
|
||||
|
||||
import FollowButton from "./FollowButton";
|
||||
import ProfileImage from "./ProfileImage";
|
||||
import { UserWebsiteLink } from "./UserWebsiteLink";
|
||||
import Text from "@/Components/Text/Text";
|
||||
import { useEffect, useState } from "react";
|
||||
import useLogin from "../../Hooks/useLogin";
|
||||
import FollowedBy from "@/Components/User/FollowedBy";
|
||||
|
||||
export function ProfileCard({
|
||||
pubkey,
|
||||
user,
|
||||
show,
|
||||
delay,
|
||||
}: {
|
||||
pubkey: string;
|
||||
user?: UserMetadata;
|
||||
show: boolean;
|
||||
delay?: number;
|
||||
}) {
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const [t, setT] = useState<ReturnType<typeof setTimeout>>();
|
||||
const { publicKey: myPublicKey } = useLogin(s => ({ publicKey: s.publicKey }));
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
const tn = setTimeout(() => {
|
||||
setShowProfileMenu(true);
|
||||
}, delay ?? 1000);
|
||||
setT(tn);
|
||||
} else {
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
setT(undefined);
|
||||
}
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
if (!show && !showProfileMenu) return;
|
||||
return (
|
||||
<ControlledMenu
|
||||
state={showProfileMenu ? "open" : "closed"}
|
||||
menuClassName="profile-card"
|
||||
onClose={() => setShowProfileMenu(false)}
|
||||
align="end">
|
||||
<div className="flex flex-col g8">
|
||||
<div className="flex justify-between">
|
||||
<ProfileImage pubkey={pubkey} profile={user} showProfileCard={false} link="" />
|
||||
<div className="flex g8">
|
||||
{/*<button type="button" onClick={() => {
|
||||
LoginStore.loginWithPubkey(pubkey, LoginSessionType.PublicKey, undefined, undefined, undefined, true);
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="Stalk" />
|
||||
</button>*/}
|
||||
{myPublicKey !== pubkey && <FollowButton pubkey={pubkey} />}
|
||||
</div>
|
||||
</div>
|
||||
<Text
|
||||
id={`profile-card-${pubkey}`}
|
||||
content={user?.about ?? ""}
|
||||
creator={pubkey}
|
||||
tags={[]}
|
||||
disableMedia={true}
|
||||
disableLinkPreview={true}
|
||||
truncate={250}
|
||||
/>
|
||||
<UserWebsiteLink user={user} />
|
||||
{myPublicKey && <FollowedBy pubkey={pubkey} />}
|
||||
</div>
|
||||
</ControlledMenu>
|
||||
);
|
||||
}
|
41
packages/app/src/Components/User/ProfileImage.css
Normal file
41
packages/app/src/Components/User/ProfileImage.css
Normal file
@ -0,0 +1,41 @@
|
||||
.pfp {
|
||||
display: grid;
|
||||
grid-template-columns: min-content auto;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pfp .avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
a.pfp {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pfp .profile-name {
|
||||
max-width: stretch;
|
||||
max-width: -webkit-fill-available;
|
||||
max-width: -moz-available;
|
||||
}
|
||||
|
||||
.pfp a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pfp .icon-circle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform-origin: center;
|
||||
padding: 4px;
|
||||
border-radius: 100%;
|
||||
background-color: var(--gray-superdark);
|
||||
transform: rotate(135deg);
|
||||
}
|
142
packages/app/src/Components/User/ProfileImage.tsx
Normal file
142
packages/app/src/Components/User/ProfileImage.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import "./ProfileImage.css";
|
||||
|
||||
import React, { ReactNode, useCallback, useRef, useState } from "react";
|
||||
import { HexKey, UserMetadata } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import Avatar from "@/Components/User/Avatar";
|
||||
import DisplayName from "./DisplayName";
|
||||
import { ProfileLink } from "./ProfileLink";
|
||||
import { ProfileCard } from "./ProfileCard";
|
||||
import FollowDistanceIndicator from "@/Components/User/FollowDistanceIndicator";
|
||||
import { useCommunityLeader } from "@/Hooks/useCommunityLeaders";
|
||||
import { LeaderBadge } from "@/Components/CommunityLeaders/LeaderBadge";
|
||||
|
||||
export interface ProfileImageProps {
|
||||
pubkey: HexKey;
|
||||
subHeader?: JSX.Element;
|
||||
showUsername?: boolean;
|
||||
className?: string;
|
||||
link?: string;
|
||||
defaultNip?: string;
|
||||
verifyNip?: boolean;
|
||||
overrideUsername?: string;
|
||||
profile?: UserMetadata;
|
||||
size?: number;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
imageOverlay?: ReactNode;
|
||||
showFollowDistance?: boolean;
|
||||
icons?: ReactNode;
|
||||
showProfileCard?: boolean;
|
||||
showBadges?: boolean;
|
||||
}
|
||||
|
||||
export default function ProfileImage({
|
||||
pubkey,
|
||||
subHeader,
|
||||
showUsername = true,
|
||||
className,
|
||||
link,
|
||||
overrideUsername,
|
||||
profile,
|
||||
size,
|
||||
imageOverlay,
|
||||
onClick,
|
||||
showFollowDistance = true,
|
||||
icons,
|
||||
showProfileCard = false,
|
||||
showBadges = false,
|
||||
}: ProfileImageProps) {
|
||||
const user = useUserProfile(profile ? "" : pubkey) ?? profile;
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const leader = useCommunityLeader(pubkey);
|
||||
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
hoverTimeoutRef.current && clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = setTimeout(() => setIsHovering(true), 100); // Adjust timeout as needed
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
hoverTimeoutRef.current && clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = setTimeout(() => setIsHovering(false), 300); // Adjust timeout as needed
|
||||
}, []);
|
||||
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
if (link === "") {
|
||||
e.preventDefault();
|
||||
onClick?.(e);
|
||||
}
|
||||
}
|
||||
|
||||
function inner() {
|
||||
return (
|
||||
<>
|
||||
<div className="avatar-wrapper" onMouseEnter={handleMouseEnter}>
|
||||
<Avatar
|
||||
pubkey={pubkey}
|
||||
user={user}
|
||||
size={size}
|
||||
imageOverlay={imageOverlay}
|
||||
showTitle={!showProfileCard}
|
||||
icons={
|
||||
showFollowDistance || icons ? (
|
||||
<>
|
||||
{icons}
|
||||
{showFollowDistance && <FollowDistanceIndicator pubkey={pubkey} />}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{showUsername && (
|
||||
<div className="f-ellipsis">
|
||||
<div className="flex gap-2 items-center font-medium">
|
||||
{overrideUsername ? overrideUsername : <DisplayName pubkey={pubkey} user={user} />}
|
||||
{leader && showBadges && CONFIG.features.communityLeaders && <LeaderBadge />}
|
||||
</div>
|
||||
<div className="subheader">{subHeader}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function profileCard() {
|
||||
if (showProfileCard && user && isHovering) {
|
||||
return (
|
||||
<div className="absolute shadow-lg z-10">
|
||||
<ProfileCard pubkey={pubkey} user={user} show={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (link === "") {
|
||||
return (
|
||||
<>
|
||||
<div className={classNames("pfp", className)} onClick={handleClick}>
|
||||
{inner()}
|
||||
</div>
|
||||
{profileCard()}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="relative" onMouseLeave={handleMouseLeave}>
|
||||
<ProfileLink
|
||||
pubkey={pubkey}
|
||||
className={classNames("pfp", className)}
|
||||
user={user}
|
||||
explicitLink={link}
|
||||
onClick={handleClick}>
|
||||
{inner()}
|
||||
</ProfileLink>
|
||||
{profileCard()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
54
packages/app/src/Components/User/ProfileLink.tsx
Normal file
54
packages/app/src/Components/User/ProfileLink.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { ReactNode, useContext } from "react";
|
||||
import { Link, LinkProps } from "react-router-dom";
|
||||
import { UserMetadata, NostrLink, NostrPrefix, MetadataCache } from "@snort/system";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { randomSample } from "@/Utils";
|
||||
|
||||
export function ProfileLink({
|
||||
pubkey,
|
||||
user,
|
||||
explicitLink,
|
||||
children,
|
||||
...others
|
||||
}: {
|
||||
pubkey: string;
|
||||
user?: UserMetadata | MetadataCache;
|
||||
explicitLink?: string;
|
||||
children?: ReactNode;
|
||||
} & Omit<LinkProps, "to">) {
|
||||
const system = useContext(SnortContext);
|
||||
const relays = system.RelayCache.getFromCache(pubkey)
|
||||
?.relays?.filter(a => a.settings.write)
|
||||
?.map(a => a.url);
|
||||
|
||||
function profileLink() {
|
||||
if (explicitLink) {
|
||||
return explicitLink;
|
||||
}
|
||||
if (
|
||||
user?.nip05 &&
|
||||
user.nip05.endsWith(`@${CONFIG.nip05Domain}`) &&
|
||||
(!("isNostrAddressValid" in user) || user.isNostrAddressValid)
|
||||
) {
|
||||
const [username] = user.nip05.split("@");
|
||||
return `/${username}`;
|
||||
}
|
||||
return `/${new NostrLink(
|
||||
NostrPrefix.Profile,
|
||||
pubkey,
|
||||
undefined,
|
||||
undefined,
|
||||
relays ? randomSample(relays, 3) : undefined,
|
||||
).encode(CONFIG.profileLinkPrefix)}`;
|
||||
}
|
||||
|
||||
const oFiltered = others as Record<string, unknown>;
|
||||
delete oFiltered["user"];
|
||||
delete oFiltered["link"];
|
||||
delete oFiltered["children"];
|
||||
return (
|
||||
<Link {...oFiltered} to={profileLink()} state={user}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
17
packages/app/src/Components/User/ProfilePreview.css
Normal file
17
packages/app/src/Components/User/ProfilePreview.css
Normal file
@ -0,0 +1,17 @@
|
||||
.profile-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 59px;
|
||||
}
|
||||
|
||||
.profile-preview .pfp {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.profile-preview .about {
|
||||
font-size: small;
|
||||
color: var(--font-secondary-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
64
packages/app/src/Components/User/ProfilePreview.tsx
Normal file
64
packages/app/src/Components/User/ProfilePreview.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import "./ProfilePreview.css";
|
||||
import { ReactNode } from "react";
|
||||
import { HexKey, UserMetadata } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
import FollowButton from "@/Components/User/FollowButton";
|
||||
|
||||
export interface ProfilePreviewProps {
|
||||
pubkey: HexKey;
|
||||
options?: {
|
||||
about?: boolean;
|
||||
linkToProfile?: boolean;
|
||||
profileCards?: boolean;
|
||||
};
|
||||
profile?: UserMetadata;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
export default function ProfilePreview(props: ProfilePreviewProps) {
|
||||
const pubkey = props.pubkey;
|
||||
const { ref, inView } = useInView({ triggerOnce: true });
|
||||
const user = useUserProfile(inView ? pubkey : undefined);
|
||||
const options = {
|
||||
about: true,
|
||||
...props.options,
|
||||
};
|
||||
|
||||
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (props.onClick) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`justify-between profile-preview${props.className ? ` ${props.className}` : ""}`}
|
||||
ref={ref}
|
||||
onClick={handleClick}>
|
||||
{inView && (
|
||||
<>
|
||||
<ProfileImage
|
||||
pubkey={pubkey}
|
||||
profile={props.profile}
|
||||
link={options.linkToProfile ?? true ? undefined : ""}
|
||||
subHeader={options.about ? <div className="about">{user?.about}</div> : undefined}
|
||||
showProfileCard={options.profileCards}
|
||||
/>
|
||||
{props.actions ?? (
|
||||
<div className="whitespace-nowrap">
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
13
packages/app/src/Components/User/UserWebsiteLink.css
Normal file
13
packages/app/src/Components/User/UserWebsiteLink.css
Normal file
@ -0,0 +1,13 @@
|
||||
.user-profile-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-profile-link a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.user-profile-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
29
packages/app/src/Components/User/UserWebsiteLink.tsx
Normal file
29
packages/app/src/Components/User/UserWebsiteLink.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import "./UserWebsiteLink.css";
|
||||
import { MetadataCache, UserMetadata } from "@snort/system";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
|
||||
export function UserWebsiteLink({ user }: { user?: MetadataCache | UserMetadata }) {
|
||||
const website_url =
|
||||
user?.website && !user.website.startsWith("http") ? "https://" + user.website : user?.website || "";
|
||||
|
||||
function tryFormatWebsite(url: string) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return `${u.hostname}${u.pathname !== "/" ? u.pathname : ""}`;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
if (user?.website) {
|
||||
return (
|
||||
<div className="user-profile-link f-ellipsis flex gap-2 items-center">
|
||||
<Icon name="link-02" size={16} />
|
||||
<a href={website_url} target="_blank" rel="noreferrer">
|
||||
{tryFormatWebsite(user.website)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
15
packages/app/src/Components/User/Username.tsx
Normal file
15
packages/app/src/Components/User/Username.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { HexKey } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
import { ProfileLink } from "./ProfileLink";
|
||||
import DisplayName from "./DisplayName";
|
||||
|
||||
export default function Username({ pubkey, onLinkVisit }: { pubkey: HexKey; onLinkVisit(): void }) {
|
||||
const user = useUserProfile(pubkey);
|
||||
|
||||
return user ? (
|
||||
<ProfileLink pubkey={pubkey} onClick={onLinkVisit} user={user}>
|
||||
<DisplayName pubkey={pubkey} user={user} />
|
||||
</ProfileLink>
|
||||
) : null;
|
||||
}
|
Reference in New Issue
Block a user