Profile hover cards
This commit is contained in:
parent
b62b877f5a
commit
8d882a0844
@ -16,6 +16,7 @@
|
||||
"@snort/system-web": "workspace:*",
|
||||
"@szhsin/react-menu": "^3.3.1",
|
||||
"@types/use-sync-external-store": "^0.0.4",
|
||||
"@uidotdev/usehooks": "^2.3.1",
|
||||
"@void-cat/api": "^1.0.4",
|
||||
"debug": "^4.3.4",
|
||||
"dexie": "^3.2.4",
|
||||
|
@ -248,10 +248,14 @@ export default function Text({
|
||||
chunks.push(<CashuNuts token={element.content} />);
|
||||
}
|
||||
if (element.type === "link" || (element.type === "media" && element.mimeType?.startsWith("unknown"))) {
|
||||
if (disableMedia ?? false) {
|
||||
chunks.push(<DisableMedia content={element.content} />);
|
||||
} else {
|
||||
chunks.push(
|
||||
<HyperText link={element.content} depth={depth} showLinkPreview={!(disableLinkPreview ?? false)} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (element.type === "custom_emoji") {
|
||||
chunks.push(<ProxyImg src={element.content} size={15} className="custom-emoji" />);
|
||||
}
|
||||
|
@ -1,2 +0,0 @@
|
||||
.follow-button {
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
import "./FollowButton.css";
|
||||
import FormattedMessage from "Element/FormattedMessage";
|
||||
import { HexKey } from "@snort/system";
|
||||
|
||||
@ -20,7 +19,7 @@ export default function FollowButton(props: FollowButtonProps) {
|
||||
const publisher = useEventPublisher();
|
||||
const { follows, relays, readonly } = useLogin(s => ({ follows: s.follows, relays: s.relays, readonly: s.readonly }));
|
||||
const isFollowing = follows.item.includes(pubkey);
|
||||
const baseClassname = `${props.className ? ` ${props.className}` : ""}follow-button`;
|
||||
const baseClassname = props.className ? `${props.className} ` : "";
|
||||
|
||||
async function follow(pubkey: HexKey) {
|
||||
if (publisher) {
|
||||
@ -42,9 +41,12 @@ export default function FollowButton(props: FollowButtonProps) {
|
||||
|
||||
return (
|
||||
<AsyncButton
|
||||
className={isFollowing ? `${baseClassname} secondary` : baseClassname}
|
||||
className={isFollowing ? `${baseClassname} secondary` : `${baseClassname} primary`}
|
||||
disabled={readonly}
|
||||
onClick={() => (isFollowing ? unfollow(pubkey) : follow(pubkey))}>
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
isFollowing ? unfollow(pubkey) : follow(pubkey);
|
||||
}}>
|
||||
{isFollowing ? <FormattedMessage {...messages.Unfollow} /> : <FormattedMessage {...messages.Follow} />}
|
||||
</AsyncButton>
|
||||
);
|
||||
|
@ -43,3 +43,15 @@ a.pfp {
|
||||
background-color: var(--gray-superdark);
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
import "./ProfileImage.css";
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import React, { ReactNode, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { HexKey, UserMetadata } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { useHover } from "@uidotdev/usehooks";
|
||||
import { ControlledMenu } from "@szhsin/react-menu";
|
||||
|
||||
import { profileLink } from "SnortUtils";
|
||||
import Avatar from "Element/User/Avatar";
|
||||
@ -11,6 +13,9 @@ import Nip05 from "Element/User/Nip05";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import Icon from "Icons/Icon";
|
||||
import DisplayName from "./DisplayName";
|
||||
import Text from "Element/Text";
|
||||
import FollowButton from "Element/User/FollowButton";
|
||||
import { UserWebsiteLink } from "Element/User/UserWebsiteLink";
|
||||
|
||||
export interface ProfileImageProps {
|
||||
pubkey: HexKey;
|
||||
@ -27,6 +32,7 @@ export interface ProfileImageProps {
|
||||
imageOverlay?: ReactNode;
|
||||
showFollowingMark?: boolean;
|
||||
icons?: ReactNode;
|
||||
showProfileCard?: boolean;
|
||||
}
|
||||
|
||||
export default function ProfileImage({
|
||||
@ -44,11 +50,29 @@ export default function ProfileImage({
|
||||
onClick,
|
||||
showFollowingMark = true,
|
||||
icons,
|
||||
showProfileCard,
|
||||
}: ProfileImageProps) {
|
||||
const user = useUserProfile(profile ? "" : pubkey) ?? profile;
|
||||
const nip05 = defaultNip ? defaultNip : user?.nip05;
|
||||
const { follows } = useLogin();
|
||||
const doesFollow = follows.item.includes(pubkey);
|
||||
const [ref, hovering] = useHover<HTMLDivElement>();
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const [t, setT] = useState<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (hovering) {
|
||||
const tn = setTimeout(() => {
|
||||
setShowProfileMenu(true);
|
||||
}, 1000);
|
||||
setT(tn);
|
||||
} else {
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
setT(undefined);
|
||||
}
|
||||
}
|
||||
}, [hovering]);
|
||||
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
if (link === "") {
|
||||
@ -60,7 +84,7 @@ export default function ProfileImage({
|
||||
function inner() {
|
||||
return (
|
||||
<>
|
||||
<div className="avatar-wrapper">
|
||||
<div className="avatar-wrapper" ref={ref}>
|
||||
<Avatar
|
||||
pubkey={pubkey}
|
||||
user={user}
|
||||
@ -93,20 +117,61 @@ export default function ProfileImage({
|
||||
);
|
||||
}
|
||||
|
||||
function profileCard() {
|
||||
if (showProfileCard ?? true) {
|
||||
return (
|
||||
<ControlledMenu
|
||||
state={showProfileMenu ? "open" : "closed"}
|
||||
anchorRef={ref}
|
||||
menuClassName="profile-card"
|
||||
onClose={() => setShowProfileMenu(false)}>
|
||||
<div className="flex-column g8">
|
||||
<div className="flex f-space">
|
||||
<ProfileImage pubkey={""} profile={user} showProfileCard={false} link="" />
|
||||
<div className="flex g8">
|
||||
{/*<button type="button">
|
||||
<FormattedMessage defaultMessage="Stalk" />
|
||||
</button>*/}
|
||||
<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} />
|
||||
</div>
|
||||
</ControlledMenu>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (link === "") {
|
||||
return (
|
||||
<>
|
||||
<div className={`pfp${className ? ` ${className}` : ""}`} onClick={handleClick}>
|
||||
{inner()}
|
||||
</div>
|
||||
{profileCard()}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
className={`pfp${className ? ` ${className}` : ""}`}
|
||||
to={link === undefined ? profileLink(pubkey) : link}
|
||||
onClick={handleClick}>
|
||||
{inner()}
|
||||
</Link>
|
||||
{profileCard()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,3 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.profile-preview button {
|
||||
min-width: 98px;
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ export interface ProfilePreviewProps {
|
||||
options?: {
|
||||
about?: boolean;
|
||||
linkToProfile?: boolean;
|
||||
profileCards?: boolean;
|
||||
};
|
||||
profile?: UserMetadata;
|
||||
actions?: ReactNode;
|
||||
@ -45,6 +46,7 @@ export default function ProfilePreview(props: ProfilePreviewProps) {
|
||||
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="follow-button-container">
|
||||
|
13
packages/app/src/Element/User/UserWebsiteLink.css
Normal file
13
packages/app/src/Element/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/Element/User/UserWebsiteLink.tsx
Normal file
29
packages/app/src/Element/User/UserWebsiteLink.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import "./UserWebsiteLink.css";
|
||||
import { MetadataCache, UserMetadata } from "@snort/system";
|
||||
import Icon from "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">
|
||||
<Icon name="link-02" size={16} />
|
||||
<a href={website_url} target="_blank" rel="noreferrer">
|
||||
{tryFormatWebsite(user.website)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -3,11 +3,14 @@ import { ParsedFragment, transformText } from "@snort/system";
|
||||
const TextCache = new Map<string, Array<ParsedFragment>>();
|
||||
|
||||
export function transformTextCached(id: string, content: string, tags: Array<Array<string>>) {
|
||||
if (content.length > 0) {
|
||||
const cached = TextCache.get(id);
|
||||
if (cached) return cached;
|
||||
const newCache = transformText(content, tags);
|
||||
TextCache.set(id, newCache);
|
||||
return newCache;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function useTextTransformer(id: string, content: string, tags: Array<Array<string>>) {
|
||||
|
@ -138,14 +138,6 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile .website a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.profile .website a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.profile .link svg {
|
||||
color: var(--highlight);
|
||||
}
|
||||
|
@ -59,6 +59,7 @@ import { useStatusFeed } from "Feed/StatusFeed";
|
||||
|
||||
import messages from "./messages";
|
||||
import { SpotlightMediaModal } from "Element/Deck/SpotlightMedia";
|
||||
import { UserWebsiteLink } from "Element/User/UserWebsiteLink";
|
||||
|
||||
const NOTES = 0;
|
||||
const REACTIONS = 1;
|
||||
@ -135,8 +136,6 @@ export default function ProfilePage() {
|
||||
const showBadges = login.preferences.showBadges ?? false;
|
||||
const showStatus = login.preferences.showStatus ?? true;
|
||||
|
||||
const website_url =
|
||||
user?.website && !user.website.startsWith("http") ? "https://" + user.website : user?.website || "";
|
||||
// feeds
|
||||
const { blocked } = useModeration();
|
||||
const pinned = usePinnedFeed(id);
|
||||
@ -295,28 +294,10 @@ export default function ProfilePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function tryFormatWebsite(url: string) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return `${u.hostname}${u.pathname !== "/" ? u.pathname : ""}`;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function links() {
|
||||
return (
|
||||
<>
|
||||
{user?.website && (
|
||||
<div className="link website f-ellipsis">
|
||||
<Icon name="link-02" size={16} />
|
||||
<a href={website_url} target="_blank" rel="noreferrer">
|
||||
{tryFormatWebsite(user.website)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UserWebsiteLink user={user} />
|
||||
{lnurl && (
|
||||
<div className="link lnurl f-ellipsis" onClick={() => setShowLnQr(true)}>
|
||||
<Icon name="zapCircle" size={16} />
|
||||
@ -433,7 +414,7 @@ export default function ProfilePage() {
|
||||
<Avatar pubkey={id ?? ""} user={user} onClick={() => setModalImage(user?.picture || "")} className="pointer" />
|
||||
<div className="profile-actions">
|
||||
{renderIcons()}
|
||||
{!isMe && id && <FollowButton className="primary" pubkey={id} />}
|
||||
{!isMe && id && <FollowButton pubkey={id} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -113,6 +113,21 @@ code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
line-height: 1.3em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a.ext {
|
||||
word-break: break-all;
|
||||
white-space: initial;
|
||||
}
|
||||
|
||||
#root {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@ -498,21 +513,6 @@ input:disabled {
|
||||
max-width: -moz-available;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
line-height: 1.3em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a.ext {
|
||||
word-break: break-all;
|
||||
white-space: initial;
|
||||
}
|
||||
|
||||
div.form {
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
|
@ -233,7 +233,7 @@ export function transformText(body: string, tags: Array<Array<string>>) {
|
||||
fragments = fragments
|
||||
.map(a => {
|
||||
if (typeof a === "string") {
|
||||
if (a.trim().length > 0) {
|
||||
if (a.length > 0) {
|
||||
return { type: "text", content: a } as ParsedFragment;
|
||||
}
|
||||
} else {
|
||||
|
11
yarn.lock
11
yarn.lock
@ -2714,6 +2714,7 @@ __metadata:
|
||||
"@types/webtorrent": ^0.109.3
|
||||
"@typescript-eslint/eslint-plugin": ^6.1.0
|
||||
"@typescript-eslint/parser": ^6.1.0
|
||||
"@uidotdev/usehooks": ^2.3.1
|
||||
"@void-cat/api": ^1.0.4
|
||||
"@webbtc/webln-types": ^1.0.10
|
||||
"@webpack-cli/generators": ^3.0.4
|
||||
@ -3852,6 +3853,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@uidotdev/usehooks@npm:^2.3.1":
|
||||
version: 2.3.1
|
||||
resolution: "@uidotdev/usehooks@npm:2.3.1"
|
||||
peerDependencies:
|
||||
react: ">=18.0.0"
|
||||
react-dom: ">=18.0.0"
|
||||
checksum: a1339b91bdb4176f59fc2dd8273065fccacb17749b7022879982ff874bda8e4e54a3f8d74f126e6224164fb2ad422f1cc40dac8705467960df525b207fcd3a79
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@void-cat/api@npm:^1.0.4":
|
||||
version: 1.0.7
|
||||
resolution: "@void-cat/api@npm:1.0.7"
|
||||
|
Loading…
x
Reference in New Issue
Block a user