forked from Kieran/snort
1
0
Fork 0

Profile hover cards

This commit is contained in:
Kieran 2023-10-09 16:32:14 +01:00
parent b62b877f5a
commit 8d882a0844
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
16 changed files with 184 additions and 75 deletions

View File

@ -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",

View File

@ -248,9 +248,13 @@ export default function Text({
chunks.push(<CashuNuts token={element.content} />);
}
if (element.type === "link" || (element.type === "media" && element.mimeType?.startsWith("unknown"))) {
chunks.push(
<HyperText link={element.content} depth={depth} showLinkPreview={!(disableLinkPreview ?? false)} />,
);
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" />);

View File

@ -1,2 +0,0 @@
.follow-button {
}

View File

@ -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>
);

View File

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

View File

@ -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>
<>
<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>
<>
<Link
className={`pfp${className ? ` ${className}` : ""}`}
to={link === undefined ? profileLink(pubkey) : link}
onClick={handleClick}>
{inner()}
</Link>
{profileCard()}
</>
);
}
}

View File

@ -15,7 +15,3 @@
overflow: hidden;
text-overflow: ellipsis;
}
.profile-preview button {
min-width: 98px;
}

View File

@ -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">

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

View File

@ -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>>) {
const cached = TextCache.get(id);
if (cached) return cached;
const newCache = transformText(content, tags);
TextCache.set(id, newCache);
return newCache;
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>>) {

View File

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

View File

@ -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>
);

View File

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

View File

@ -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 {

View File

@ -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"