reorganize code into smaller files & dirs

This commit is contained in:
Martti Malmi
2024-01-04 15:48:19 +02:00
parent 5ea2eb711f
commit afa6d39a56
321 changed files with 671 additions and 671 deletions

File diff suppressed because it is too large Load Diff

View 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%);
}

View 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;

View 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;
}

View 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>}
</>
);
}

View 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;
}

View 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>
)}
</>
);
}

View 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;

View 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>
);
}

View 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;

View File

@ -0,0 +1,3 @@
.placeholder {
color: var(--gray-light);
}

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}

View 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>
);
}

View 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;
}

View 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;
}

View 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;

View 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>
);
}

View 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;
}
}

View 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;

View 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;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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);
}

View 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>
);
}
}

View 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>
);
}

View 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;
}

View 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>
</>
);
}

View 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;
}

View 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>
);
}
}

View 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;
}