refactor: polish
This commit is contained in:
parent
4b3e7710e0
commit
c274c0a842
@ -42,7 +42,7 @@
|
||||
top: 48px;
|
||||
border-left: 1px solid var(--border-color);
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.subthread-container.subthread-mid:not(.subthread-last) .line-container:before {
|
||||
@ -52,7 +52,7 @@
|
||||
left: calc(48px / 2 + 16px);
|
||||
top: 0;
|
||||
height: 48px;
|
||||
z-index: 1;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.subthread-container.subthread-last .line-container:before {
|
||||
@ -62,7 +62,7 @@
|
||||
left: calc(48px / 2 + 16px);
|
||||
top: 0;
|
||||
height: 48px;
|
||||
z-index: 1;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.divider {
|
||||
|
@ -63,6 +63,7 @@
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
display: block;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ export default function TrendingUsers({
|
||||
pubkeys={trendingUsersData.slice(0, count) as HexKey[]}
|
||||
title={title}
|
||||
showFollowAll={true}
|
||||
className="flex flex-col gap-2"
|
||||
profilePreviewProps={{
|
||||
options: {
|
||||
about: true,
|
||||
|
@ -1,38 +0,0 @@
|
||||
.avatar {
|
||||
@apply rounded-full;
|
||||
height: 210px;
|
||||
width: 210px;
|
||||
background-color: var(--gray);
|
||||
z-index: 2;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.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);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
@ -1,8 +1,6 @@
|
||||
import "./Avatar.css";
|
||||
|
||||
import type { UserMetadata } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { HTMLProps, ReactNode, useMemo } from "react";
|
||||
|
||||
import { ProxyImg } from "@/Components/ProxyImg";
|
||||
import { defaultAvatar, getDisplayName } from "@/Utils";
|
||||
@ -22,14 +20,15 @@ interface AvatarProps {
|
||||
const Avatar = ({
|
||||
pubkey,
|
||||
user,
|
||||
size,
|
||||
size = 48,
|
||||
onClick,
|
||||
image,
|
||||
imageOverlay,
|
||||
icons,
|
||||
className,
|
||||
showTitle = true,
|
||||
}: AvatarProps) => {
|
||||
...others
|
||||
}: AvatarProps & Omit<HTMLProps<HTMLDivElement>, "onClick" | "style" | "className">) => {
|
||||
const defaultImg = defaultAvatar(pubkey);
|
||||
const url = useMemo(() => {
|
||||
if ((image?.length ?? 0) > 0) return image;
|
||||
@ -51,22 +50,39 @@ const Avatar = ({
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
className={classNames(
|
||||
"avatar relative flex items-center justify-center",
|
||||
{ "with-overlay": imageOverlay },
|
||||
"relative rounded-full aspect-square flex items-center justify-center gap-2 bg-gray",
|
||||
{ "outline outline-2 outline-nostr-purple m-[2px]": isDefault },
|
||||
className,
|
||||
)}
|
||||
data-domain={domain?.toLowerCase()}
|
||||
title={showTitle ? getDisplayName(user, "") : undefined}>
|
||||
title={showTitle ? getDisplayName(user, "") : undefined}
|
||||
{...others}>
|
||||
<ProxyImg
|
||||
className="rounded-full object-cover aspect-square"
|
||||
className="absolute rounded-full w-full h-full object-cover"
|
||||
src={url}
|
||||
size={s}
|
||||
alt={getDisplayName(user, "")}
|
||||
promptToLoadDirectly={false}
|
||||
/>
|
||||
{icons && <div className="icons">{icons}</div>}
|
||||
{imageOverlay && <div className="overlay">{imageOverlay}</div>}
|
||||
{icons && (
|
||||
<div
|
||||
className="absolute flex items-center justify-center w-full h-full origin-center"
|
||||
style={{
|
||||
transform: "rotate(-135deg) translateY(50%)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
transform: "rotate(135deg)",
|
||||
}}>
|
||||
{icons}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{imageOverlay && (
|
||||
<div className="absolute rounded-full bg-black/40 w-full h-full flex items-center justify-center">
|
||||
{imageOverlay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { HexKey } from "@snort/system";
|
||||
import React from "react";
|
||||
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
|
||||
@ -13,6 +12,8 @@ export function AvatarGroup({ ids, onClick, size }: { ids: HexKey[]; onClick?: (
|
||||
pubkey={a}
|
||||
size={size ?? 24}
|
||||
showUsername={false}
|
||||
showBadges={false}
|
||||
showProfileCard={false}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { HexKey, socialGraphInstance } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
|
||||
@ -31,8 +30,10 @@ export default function FollowDistanceIndicator({ pubkey, className }: FollowDis
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classNames("icon-circle", className)} title={title}>
|
||||
<div
|
||||
className={classNames("w-5 h-5 bg-gray-superdark rounded-full flex items-center justify-center", className)}
|
||||
title={title}>
|
||||
<Icon name="check" className={followDistanceColor} size={10} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ export default function FollowListBase({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col g8">
|
||||
<div className="flex flex-col gap-2">
|
||||
{(showFollowAll ?? true) && (
|
||||
<div className="flex items-center">
|
||||
<div className="grow font-bold">{title}</div>
|
||||
@ -45,9 +45,7 @@ export default function FollowListBase({
|
||||
</div>
|
||||
)}
|
||||
<div className={className}>
|
||||
{pubkeys?.map((a, index) => (
|
||||
<ProfilePreview pubkey={a} key={a} waitUntilInView={index > 10} {...profilePreviewProps} />
|
||||
))}
|
||||
{pubkeys?.slice(0, 20).map(a => <ProfilePreview pubkey={a} key={a} {...profilePreviewProps} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -4,7 +4,6 @@ import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { AvatarGroup } from "@/Components/User/AvatarGroup";
|
||||
import DisplayName from "@/Components/User/DisplayName";
|
||||
import FollowDistanceIndicator from "@/Components/User/FollowDistanceIndicator";
|
||||
import { ProfileLink } from "@/Components/User/ProfileLink";
|
||||
|
||||
const MAX_FOLLOWED_BY_FRIENDS = 3;
|
||||
@ -31,9 +30,8 @@ export default function FollowedBy({ pubkey }: { pubkey: HexKey }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center">
|
||||
<FollowDistanceIndicator className="p-2" pubkey={pubkey} />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center">
|
||||
<AvatarGroup ids={followedByFriendsArray} />
|
||||
</div>
|
||||
{totalFollowedByFriends > 0 && (
|
||||
|
@ -1,41 +0,0 @@
|
||||
.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);
|
||||
}
|
@ -1,5 +1,3 @@
|
||||
import "./ProfileImage.css";
|
||||
|
||||
import { HexKey, UserMetadata } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import classNames from "classnames";
|
||||
@ -31,6 +29,7 @@ export interface ProfileImageProps {
|
||||
icons?: ReactNode;
|
||||
showProfileCard?: boolean;
|
||||
showBadges?: boolean;
|
||||
displayNameClassName?: string;
|
||||
}
|
||||
|
||||
export default function ProfileImage({
|
||||
@ -48,6 +47,7 @@ export default function ProfileImage({
|
||||
icons,
|
||||
showProfileCard = false,
|
||||
showBadges = false,
|
||||
displayNameClassName,
|
||||
}: ProfileImageProps) {
|
||||
const user = useUserProfile(profile ? "" : pubkey) ?? profile;
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
@ -75,13 +75,13 @@ export default function ProfileImage({
|
||||
function inner() {
|
||||
return (
|
||||
<>
|
||||
<div className="avatar-wrapper" onMouseEnter={handleMouseEnter}>
|
||||
<Avatar
|
||||
pubkey={pubkey}
|
||||
user={user}
|
||||
size={size}
|
||||
imageOverlay={imageOverlay}
|
||||
showTitle={!showProfileCard}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
icons={
|
||||
showFollowDistance || icons ? (
|
||||
<>
|
||||
@ -91,14 +91,13 @@ export default function ProfileImage({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{showUsername && (
|
||||
<div className="f-ellipsis">
|
||||
<div className={displayNameClassName}>
|
||||
<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>
|
||||
{subHeader}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@ -108,7 +107,7 @@ export default function ProfileImage({
|
||||
function profileCard() {
|
||||
if (showProfileCard && user && isHovering) {
|
||||
return (
|
||||
<div className="absolute shadow-lg z-10 fade-in">
|
||||
<div className="absolute shadow-lg fade-in">
|
||||
<ProfileCard pubkey={pubkey} user={user} show={true} delay={100} />
|
||||
</div>
|
||||
);
|
||||
@ -116,10 +115,17 @@ export default function ProfileImage({
|
||||
return null;
|
||||
}
|
||||
|
||||
const classNamesOverInner = classNames(
|
||||
"min-w-0",
|
||||
{
|
||||
"grid grid-cols-[min-content_auto] gap-3 items-center": showUsername,
|
||||
},
|
||||
className,
|
||||
);
|
||||
if (link === "") {
|
||||
return (
|
||||
<>
|
||||
<div className={classNames("pfp", className)} onClick={handleClick}>
|
||||
<div className={classNamesOverInner} onClick={handleClick}>
|
||||
{inner()}
|
||||
</div>
|
||||
{profileCard()}
|
||||
@ -127,12 +133,12 @@ export default function ProfileImage({
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="relative" onMouseLeave={handleMouseLeave}>
|
||||
<div onMouseLeave={handleMouseLeave}>
|
||||
<ProfileLink
|
||||
pubkey={pubkey}
|
||||
className={classNames("pfp", className)}
|
||||
user={user}
|
||||
explicitLink={link}
|
||||
className={classNamesOverInner}
|
||||
onClick={handleClick}>
|
||||
{inner()}
|
||||
</ProfileLink>
|
||||
|
@ -4,6 +4,7 @@ import { ReactNode, useContext } from "react";
|
||||
import { Link, LinkProps } from "react-router-dom";
|
||||
|
||||
import { randomSample } from "@/Utils";
|
||||
import { useProfileLink } from "@/Hooks/useProfileLink";
|
||||
|
||||
export function ProfileLink({
|
||||
pubkey,
|
||||
@ -17,39 +18,13 @@ export function ProfileLink({
|
||||
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 link = useProfileLink(pubkey, user);
|
||||
const oFiltered = others as Record<string, unknown>;
|
||||
delete oFiltered["user"];
|
||||
delete oFiltered["link"];
|
||||
delete oFiltered["children"];
|
||||
return (
|
||||
<Link {...oFiltered} to={profileLink()} state={user}>
|
||||
<Link {...oFiltered} to={explicitLink ?? link} state={user}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
|
@ -1,17 +0,0 @@
|
||||
.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;
|
||||
}
|
@ -1,9 +1,7 @@
|
||||
import "./ProfilePreview.css";
|
||||
|
||||
import { HexKey, UserMetadata } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { ReactNode } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import classNames from "classnames";
|
||||
import { forwardRef, ReactNode } from "react";
|
||||
|
||||
import FollowButton from "@/Components/User/FollowButton";
|
||||
import ProfileImage, { ProfileImageProps } from "@/Components/User/ProfileImage";
|
||||
@ -17,13 +15,14 @@ export interface ProfilePreviewProps {
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
waitUntilInView?: boolean;
|
||||
profileImageProps?: Omit<ProfileImageProps, "pubkey" | "profile">;
|
||||
}
|
||||
export default function ProfilePreview(props: ProfilePreviewProps) {
|
||||
const ProfilePreview = forwardRef<HTMLDivElement, ProfilePreviewProps>(function ProfilePreview(
|
||||
props: ProfilePreviewProps,
|
||||
ref,
|
||||
) {
|
||||
const pubkey = props.pubkey;
|
||||
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "500px" });
|
||||
const user = useUserProfile(inView ? pubkey : undefined);
|
||||
const user = useUserProfile(pubkey);
|
||||
const options = {
|
||||
about: true,
|
||||
...props.options,
|
||||
@ -39,16 +38,19 @@ export default function ProfilePreview(props: ProfilePreviewProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`justify-between profile-preview${props.className ? ` ${props.className}` : ""}`}
|
||||
ref={ref}
|
||||
onClick={handleClick}>
|
||||
{(!props.waitUntilInView || inView) && (
|
||||
<>
|
||||
<div className={classNames("flex items-center justify-between", props.className)} ref={ref} onClick={handleClick}>
|
||||
<ProfileImage
|
||||
pubkey={pubkey}
|
||||
profile={props.profile}
|
||||
subHeader={options.about && <div className="about">{user?.about}</div>}
|
||||
className="overflow-hidden"
|
||||
displayNameClassName="min-w-0"
|
||||
subHeader={
|
||||
options.about && (
|
||||
<div className="text-sm text-secondary whitespace-nowrap text-ellipsis overflow-hidden">
|
||||
{user?.about}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{...props.profileImageProps}
|
||||
/>
|
||||
{props.actions ?? (
|
||||
@ -56,9 +58,9 @@ export default function ProfilePreview(props: ProfilePreviewProps) {
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default ProfilePreview;
|
||||
|
@ -86,9 +86,9 @@ export default function ZapModal(props: SendSatsProps) {
|
||||
if (!(props.show ?? false)) return null;
|
||||
return (
|
||||
<Modal id="send-sats" className="lnurl-modal" onClose={onClose}>
|
||||
<div className="p flex flex-col g12">
|
||||
<div className="flex g12">
|
||||
<div className="flex items-center grow">
|
||||
<div className="p flex flex-col gap-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex items-center grow gap-3">
|
||||
{props.title || <ZapModalTitle amount={amount} targets={props.targets} zapper={zapper} />}
|
||||
</div>
|
||||
<CloseButton onClick={onClose} />
|
||||
|
@ -10,7 +10,7 @@ export function useArticles() {
|
||||
const sub = useMemo(() => {
|
||||
const rb = new RequestBuilder("articles");
|
||||
if (followList.length > 0) {
|
||||
rb.withFilter().kinds([EventKind.LongFormTextNote]).authors(followList).limit(20);
|
||||
rb.withFilter().kinds([EventKind.LongFormTextNote]).authors(followList);
|
||||
}
|
||||
return rb;
|
||||
}, [followList]);
|
||||
|
@ -8,14 +8,14 @@ export function useNotificationsView() {
|
||||
const publicKey = useLogin(s => s.publicKey);
|
||||
const kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt];
|
||||
const req = useMemo(() => {
|
||||
if (publicKey) {
|
||||
const rb = new RequestBuilder("notifications");
|
||||
rb.withOptions({
|
||||
leaveOpen: true,
|
||||
});
|
||||
if (publicKey) {
|
||||
rb.withFilter().kinds(kinds).tag("p", [publicKey]);
|
||||
return rb;
|
||||
}
|
||||
return rb;
|
||||
}, [publicKey]);
|
||||
return useRequestBuilder(req);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { NostrLink } from "@snort/system";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import useLogin from "./useLogin";
|
||||
|
||||
@ -6,11 +7,13 @@ import useLogin from "./useLogin";
|
||||
* Simple hook for adding / removing follows
|
||||
*/
|
||||
export default function useFollowsControls() {
|
||||
const { state } = useLogin(s => ({ v: s.state.version, state: s.state }));
|
||||
const { state, v } = useLogin(s => ({ v: s.state.version, state: s.state }));
|
||||
|
||||
return useMemo(() => {
|
||||
const follows = state.follows;
|
||||
return {
|
||||
isFollowing: (pk: string) => {
|
||||
return state.follows?.includes(pk);
|
||||
return follows?.includes(pk);
|
||||
},
|
||||
addFollow: async (pk: Array<string>) => {
|
||||
for (const p of pk) {
|
||||
@ -27,6 +30,7 @@ export default function useFollowsControls() {
|
||||
setFollows: async (pk: Array<string>) => {
|
||||
await state.replaceFollows(pk.map(a => NostrLink.publicKey(a)));
|
||||
},
|
||||
followList: state.follows ?? [],
|
||||
followList: follows ?? [],
|
||||
};
|
||||
}, [v]);
|
||||
}
|
||||
|
25
packages/app/src/Hooks/useProfileLink.ts
Normal file
25
packages/app/src/Hooks/useProfileLink.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { CachedMetadata, NostrLink, UserMetadata } from "@snort/system";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { useContext } from "react";
|
||||
|
||||
import { randomSample } from "@/Utils";
|
||||
|
||||
export function useProfileLink(pubkey?: string, user?: UserMetadata | CachedMetadata) {
|
||||
const system = useContext(SnortContext);
|
||||
if (!pubkey) return "#";
|
||||
const relays = system.relayCache
|
||||
.getFromCache(pubkey)
|
||||
?.relays?.filter(a => a.settings.write)
|
||||
?.map(a => a.url);
|
||||
|
||||
if (
|
||||
user?.nip05 &&
|
||||
user.nip05.endsWith(`@${CONFIG.nip05Domain}`) &&
|
||||
(!("isNostrAddressValid" in user) || user.isNostrAddressValid)
|
||||
) {
|
||||
const [username] = user.nip05.split("@");
|
||||
return `/${username}`;
|
||||
}
|
||||
const link = NostrLink.profile(pubkey, relays ? randomSample(relays, 3) : undefined);
|
||||
return `/${link.encode(CONFIG.profileLinkPrefix)}`;
|
||||
}
|
20
packages/app/src/Hooks/useWindowSize.ts
Normal file
20
packages/app/src/Hooks/useWindowSize.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useWindowSize() {
|
||||
const [dims, setDims] = useState({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setDims({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", handler);
|
||||
return () => window.removeEventListener("resize", handler);
|
||||
}, []);
|
||||
return dims;
|
||||
}
|
@ -1,11 +1,15 @@
|
||||
import { socialGraphInstance, TaggedNostrEvent } from "@snort/system";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export default function useWoT() {
|
||||
return {
|
||||
return useMemo(
|
||||
() => ({
|
||||
sortEvents: (events: Array<TaggedNostrEvent>) =>
|
||||
events.sort(
|
||||
(a, b) => socialGraphInstance.getFollowDistance(a.pubkey) - socialGraphInstance.getFollowDistance(b.pubkey),
|
||||
),
|
||||
followDistance: (pk: string) => socialGraphInstance.getFollowDistance(pk),
|
||||
};
|
||||
}),
|
||||
[socialGraphInstance.root],
|
||||
);
|
||||
}
|
||||
|
@ -1,14 +1,13 @@
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import classNames from "classnames";
|
||||
import React, { useState } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import NavLink from "@/Components/Button/NavLink";
|
||||
import { NoteCreatorButton } from "@/Components/Event/Create/NoteCreatorButton";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import Avatar from "@/Components/User/Avatar";
|
||||
import { ProfileLink } from "@/Components/User/ProfileLink";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import useWindowSize from "@/Hooks/useWindowSize";
|
||||
|
||||
import ProfileMenu from "./ProfileMenu";
|
||||
|
||||
type MenuItem = {
|
||||
label?: string;
|
||||
@ -34,33 +33,21 @@ const MENU_ITEMS: MenuItem[] = [
|
||||
];
|
||||
|
||||
const Footer = () => {
|
||||
const { publicKey, readonly } = useLogin(s => ({
|
||||
publicKey: s.publicKey,
|
||||
const { readonly } = useLogin(s => ({
|
||||
readonly: s.readonly,
|
||||
}));
|
||||
const profile = useUserProfile(publicKey);
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const readOnlyIcon = readonly && (
|
||||
<span style={{ transform: "rotate(135deg)" }} title={formatMessage({ defaultMessage: "Read-only", id: "djNL6D" })}>
|
||||
<Icon name="openeye" className="text-nostr-red" size={20} />
|
||||
</span>
|
||||
);
|
||||
const pageSize = useWindowSize();
|
||||
const isMobile = pageSize.width <= 768; //max-md
|
||||
if (!isMobile) return;
|
||||
|
||||
return (
|
||||
<footer className="md:hidden fixed bottom-0 z-10 w-full bg-base-200 pb-safe-area bg-background">
|
||||
<div className="flex">
|
||||
<div className="grid grid-flow-col">
|
||||
{MENU_ITEMS.map((item, index) => (
|
||||
<FooterNavItem key={index} item={item} readonly={readonly} />
|
||||
))}
|
||||
{publicKey && (
|
||||
<ProfileLink
|
||||
className="flex flex-grow p-2 justify-center items-center cursor-pointer"
|
||||
pubkey={publicKey}
|
||||
user={profile}>
|
||||
<Avatar pubkey={publicKey} user={profile} icons={readOnlyIcon} size={40} />
|
||||
</ProfileLink>
|
||||
)}
|
||||
|
||||
<ProfileMenu className="flex justify-center items-center" />
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
@ -83,7 +70,7 @@ const FooterNavItem = ({ item, readonly }: { item: MenuItem; readonly: boolean }
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={({ isActive }) =>
|
||||
classNames({ active: isActive || isHovered }, "flex flex-grow p-4 justify-center items-center cursor-pointer")
|
||||
classNames({ active: isActive || isHovered }, "flex flex-1 p-4 justify-center items-center cursor-pointer")
|
||||
}>
|
||||
<Icon name={`${item.icon}-solid`} className="icon-solid" size={24} />
|
||||
<Icon name={`${item.icon}-outline`} className="icon-outline" size={24} />
|
||||
|
@ -1,20 +1,19 @@
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import classNames from "classnames";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import NavLink from "@/Components/Button/NavLink";
|
||||
import { NoteCreatorButton } from "@/Components/Event/Create/NoteCreatorButton";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import Avatar from "@/Components/User/Avatar";
|
||||
import { ProfileLink } from "@/Components/User/ProfileLink";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import useWindowSize from "@/Hooks/useWindowSize";
|
||||
import { HasNotificationsMarker } from "@/Pages/Layout/HasNotificationsMarker";
|
||||
import { WalletBalance } from "@/Pages/Layout/WalletBalance";
|
||||
import { subscribeToNotifications } from "@/Utils/Notifications";
|
||||
|
||||
import { LogoHeader } from "./LogoHeader";
|
||||
import ProfileMenu from "./ProfileMenu";
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{
|
||||
@ -79,20 +78,17 @@ export default function NavSidebar({ narrow = false }: { narrow?: boolean }) {
|
||||
publicKey: s.publicKey,
|
||||
readonly: s.readonly,
|
||||
}));
|
||||
const profile = useUserProfile(publicKey);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { publisher } = useEventPublisher();
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const pageSize = useWindowSize();
|
||||
const isMobile = pageSize.width <= 768; //max-md
|
||||
if (isMobile) return;
|
||||
|
||||
const className = classNames(
|
||||
{ "xl:w-56 xl:gap-2 xl:items-start": !narrow },
|
||||
"select-none overflow-y-auto hide-scrollbar sticky items-center border-r border-border-color top-0 z-20 h-screen max-h-screen hidden md:flex flex-col px-2 py-4 flex-shrink-0 gap-1",
|
||||
);
|
||||
|
||||
const readOnlyIcon = readonly && (
|
||||
<span style={{ transform: "rotate(135deg)" }} title={formatMessage({ defaultMessage: "Read-only", id: "djNL6D" })}>
|
||||
<Icon name="openeye" className="text-nostr-red" size={20} />
|
||||
</span>
|
||||
"select-none overflow-y-auto hide-scrollbar sticky items-center border-r border-border-color top-0 z-20 h-screen max-h-screen flex flex-col px-2 py-4 flex-shrink-0 gap-1",
|
||||
);
|
||||
|
||||
return (
|
||||
@ -156,21 +152,7 @@ export default function NavSidebar({ narrow = false }: { narrow?: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{publicKey && (
|
||||
<>
|
||||
<ProfileLink pubkey={publicKey} user={profile} className="hover:no-underline">
|
||||
<div className="mt-2 flex flex-row items-center justify-center font-bold text-md p-1 xl:px-4 xl:py-3 hover:bg-secondary rounded-full cursor-pointer">
|
||||
<Avatar pubkey={publicKey} user={profile} size={40} icons={readOnlyIcon} />
|
||||
{!narrow && <span className="hidden xl:inline ml-3">{profile?.name}</span>}
|
||||
</div>
|
||||
</ProfileLink>
|
||||
{readonly && (
|
||||
<div className="hidden xl:block text-nostr-red text-sm m-3">
|
||||
<FormattedMessage defaultMessage="Read-only" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ProfileMenu />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
90
packages/app/src/Pages/Layout/ProfileMenu.tsx
Normal file
90
packages/app/src/Pages/Layout/ProfileMenu.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
import classNames from "classnames";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import AsyncButton from "@/Components/Button/AsyncButton";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
import ProfilePreview from "@/Components/User/ProfilePreview";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { useProfileLink } from "@/Hooks/useProfileLink";
|
||||
import useWindowSize from "@/Hooks/useWindowSize";
|
||||
import { LoginStore } from "@/Utils/Login";
|
||||
|
||||
export default function ProfileMenu({ className }: { className?: string }) {
|
||||
const { publicKey, readonly } = useLogin(s => ({
|
||||
publicKey: s.publicKey,
|
||||
readonly: s.readonly,
|
||||
}));
|
||||
const logins = LoginStore.getSessions();
|
||||
const navigate = useNavigate();
|
||||
const link = useProfileLink(publicKey);
|
||||
|
||||
const pageSize = useWindowSize();
|
||||
const isNarrow = pageSize.width <= 1280; //xl
|
||||
function profile() {
|
||||
return (
|
||||
<ProfilePreview
|
||||
pubkey={publicKey!}
|
||||
className={isNarrow ? "!justify-center" : ""}
|
||||
actions={<>{!isNarrow && <Icon name="arrowFront" className="rotate-90 align-end" size={14} />}</>}
|
||||
profileImageProps={{
|
||||
size: 40,
|
||||
link: "",
|
||||
showBadges: false,
|
||||
showProfileCard: false,
|
||||
showFollowDistance: false,
|
||||
displayNameClassName: "max-xl:hidden",
|
||||
subHeader: readonly ? (
|
||||
<div className="max-xl:hidden text-nostr-red text-sm">
|
||||
<FormattedMessage defaultMessage="Read Only" />
|
||||
</div>
|
||||
) : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!publicKey) return;
|
||||
return (
|
||||
<div className={classNames("w-full cursor-pointer", className)}>
|
||||
<Menu menuButton={profile()} menuClassName="ctx-menu no-icons">
|
||||
<div className="close-menu-container">
|
||||
<MenuItem>
|
||||
<div className="close-menu" />
|
||||
</MenuItem>
|
||||
</div>
|
||||
<MenuItem onClick={() => navigate(link)}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Icon name="user" />
|
||||
<FormattedMessage defaultMessage="Profile" />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem className="!uppercase !text-xs !font-medium !text-gray-light">
|
||||
<FormattedMessage defaultMessage="Switch accounts" />
|
||||
</MenuItem>
|
||||
{logins
|
||||
.filter(a => a.pubkey !== publicKey)
|
||||
.map(a => (
|
||||
<MenuItem key={a.id}>
|
||||
<ProfileImage
|
||||
pubkey={a.pubkey}
|
||||
link=""
|
||||
size={24}
|
||||
showBadges={false}
|
||||
showProfileCard={false}
|
||||
showFollowDistance={false}
|
||||
onClick={() => LoginStore.switchAccount(a.id)}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem>
|
||||
<AsyncButton className="!bg-gray-light !text-white">
|
||||
<FormattedMessage defaultMessage="Add Account" />
|
||||
</AsyncButton>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -12,12 +12,17 @@ import TrendingHashtags from "@/Components/Trending/TrendingHashtags";
|
||||
import TrendingNotes from "@/Components/Trending/TrendingPosts";
|
||||
import TrendingUsers from "@/Components/Trending/TrendingUsers";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import useWindowSize from "@/Hooks/useWindowSize";
|
||||
|
||||
export default function RightColumn() {
|
||||
const { pubkey } = useLogin(s => ({ pubkey: s.publicKey }));
|
||||
const hideRightColumnPaths = ["/login", "/new", "/messages"];
|
||||
const show = !hideRightColumnPaths.some(path => globalThis.location.pathname.startsWith(path));
|
||||
|
||||
const pageSize = useWindowSize();
|
||||
const isDesktop = pageSize.width >= 1024; //max-xl
|
||||
if (!isDesktop) return;
|
||||
|
||||
const widgets = pubkey
|
||||
? [
|
||||
RightColumnWidget.TaskList,
|
||||
|
@ -72,12 +72,32 @@ export function ZapsProfileTab({ id }: { id: HexKey }) {
|
||||
|
||||
export function FollowersTab({ id }: { id: HexKey }) {
|
||||
const followers = useFollowersFeed(id);
|
||||
return <FollowsList pubkeys={followers} showAbout={true} className="p" />;
|
||||
return (
|
||||
<FollowsList
|
||||
pubkeys={followers}
|
||||
className="p"
|
||||
profilePreviewProps={{
|
||||
options: {
|
||||
about: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FollowsTab({ id }: { id: HexKey }) {
|
||||
const follows = useFollowsFeed(id);
|
||||
return <FollowsList pubkeys={follows} showAbout={true} className="p" />;
|
||||
return (
|
||||
<FollowsList
|
||||
pubkeys={follows}
|
||||
className="p"
|
||||
profilePreviewProps={{
|
||||
options: {
|
||||
about: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RelaysTab({ id }: { id: HexKey }) {
|
||||
|
@ -10,20 +10,20 @@ export default function AccountsPage() {
|
||||
const sub = getActiveSubscriptions(LoginStore.allSubscriptions());
|
||||
|
||||
return (
|
||||
<div className="flex flex-col g12">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Logins" />
|
||||
</h3>
|
||||
{logins.map(a => (
|
||||
<div className="card flex" key={a.id}>
|
||||
<div className="" key={a.id}>
|
||||
<ProfilePreview
|
||||
pubkey={a.pubkey}
|
||||
options={{
|
||||
about: false,
|
||||
}}
|
||||
actions={
|
||||
<div className="flex-1">
|
||||
<button className="mb10" onClick={() => LoginStore.switchAccount(a.id)}>
|
||||
<div className="align-end flex gap-2">
|
||||
<button onClick={() => LoginStore.switchAccount(a.id)}>
|
||||
<FormattedMessage defaultMessage="Switch" />
|
||||
</button>
|
||||
<button onClick={() => LoginStore.removeSession(a.id)}>
|
||||
|
@ -799,7 +799,6 @@ button.tall {
|
||||
|
||||
.ctx-menu li {
|
||||
background: #1e1e1e !important;
|
||||
padding: 8px 24px !important;
|
||||
display: grid !important;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
@ -807,6 +806,7 @@ button.tall {
|
||||
|
||||
.ctx-menu:not(.no-icons) li {
|
||||
grid-template-columns: 2rem auto !important;
|
||||
padding: 8px 24px !important;
|
||||
}
|
||||
|
||||
.light .ctx-menu li {
|
||||
|
@ -348,6 +348,9 @@
|
||||
"6uMqL1": {
|
||||
"defaultMessage": "Unpaid"
|
||||
},
|
||||
"6xNr8c": {
|
||||
"defaultMessage": "Switch accounts"
|
||||
},
|
||||
"6xap9L": {
|
||||
"defaultMessage": "Good"
|
||||
},
|
||||
@ -1611,9 +1614,6 @@
|
||||
"djLctd": {
|
||||
"defaultMessage": "Amount in sats"
|
||||
},
|
||||
"djNL6D": {
|
||||
"defaultMessage": "Read-only"
|
||||
},
|
||||
"dmcsBA": {
|
||||
"defaultMessage": "Classified Listing"
|
||||
},
|
||||
@ -1983,6 +1983,9 @@
|
||||
"mfe8RW": {
|
||||
"defaultMessage": "Option: {n}"
|
||||
},
|
||||
"mmPSWH": {
|
||||
"defaultMessage": "Read Only"
|
||||
},
|
||||
"n1Whvj": {
|
||||
"defaultMessage": "Switch"
|
||||
},
|
||||
|
@ -19,6 +19,7 @@ export const System = new NostrSystem({
|
||||
optimizer: hasWasm ? WasmOptimizer : undefined,
|
||||
db: SystemDb,
|
||||
buildFollowGraph: true,
|
||||
automaticOutboxModel: true,
|
||||
});
|
||||
|
||||
System.on("auth", async (c, r, cb) => {
|
||||
|
@ -115,6 +115,7 @@
|
||||
"6mr8WU": "Followed by",
|
||||
"6pdxsi": "Extra metadata fields and tags",
|
||||
"6uMqL1": "Unpaid",
|
||||
"6xNr8c": "Switch accounts",
|
||||
"6xap9L": "Good",
|
||||
"7+Domh": "Notes",
|
||||
"712i26": "Proxy uses HODL invoices to forward the payment, which hides the pubkey of your node",
|
||||
@ -534,7 +535,6 @@
|
||||
"ddd3JX": "Popular Hashtags",
|
||||
"deEeEI": "Register",
|
||||
"djLctd": "Amount in sats",
|
||||
"djNL6D": "Read-only",
|
||||
"dmcsBA": "Classified Listing",
|
||||
"dmsiLv": "A default Zap Pool split of {n} has been configured for {site} developers, you can disable it at any time in {link}",
|
||||
"e5x8FT": "Kind",
|
||||
@ -658,6 +658,7 @@
|
||||
"mKhgP9": "{n,plural,=0{} =1{zapped} other{zapped}}",
|
||||
"mOFG3K": "Start",
|
||||
"mfe8RW": "Option: {n}",
|
||||
"mmPSWH": "Read Only",
|
||||
"n1Whvj": "Switch",
|
||||
"n5l7tP": "Time-Based Calendar Event",
|
||||
"n8k1SG": "{n}MiB",
|
||||
|
@ -1,7 +1,15 @@
|
||||
import { ID, STR, UID } from "./UniqueIds";
|
||||
import { HexKey, NostrEvent } from "..";
|
||||
import EventEmitter from "eventemitter3";
|
||||
import { unixNowMs } from "@snort/shared";
|
||||
import debug from "debug";
|
||||
|
||||
export default class SocialGraph {
|
||||
export interface SocialGraphEvents {
|
||||
changeRoot: () => void;
|
||||
}
|
||||
|
||||
export default class SocialGraph extends EventEmitter<SocialGraphEvents> {
|
||||
#log = debug("SocialGraph");
|
||||
root: UID;
|
||||
followDistanceByUser = new Map<UID, number>();
|
||||
usersByFollowDistance = new Map<number, Set<UID>>();
|
||||
@ -10,6 +18,7 @@ export default class SocialGraph {
|
||||
latestFollowEventTimestamps = new Map<UID, number>();
|
||||
|
||||
constructor(root: HexKey) {
|
||||
super();
|
||||
this.root = ID(root);
|
||||
this.followDistanceByUser.set(this.root, 0);
|
||||
this.usersByFollowDistance.set(0, new Set([this.root]));
|
||||
@ -20,6 +29,7 @@ export default class SocialGraph {
|
||||
if (rootId === this.root) {
|
||||
return;
|
||||
}
|
||||
const start = unixNowMs();
|
||||
this.root = rootId;
|
||||
this.followDistanceByUser.clear();
|
||||
this.usersByFollowDistance.clear();
|
||||
@ -45,6 +55,8 @@ export default class SocialGraph {
|
||||
}
|
||||
}
|
||||
}
|
||||
this.emit("changeRoot");
|
||||
this.#log(`Rebuilding root took ${(unixNowMs() - start).toFixed(2)} ms`);
|
||||
}
|
||||
|
||||
handleEvent(evs: NostrEvent | Array<NostrEvent>) {
|
||||
|
@ -130,7 +130,7 @@ export class DefaultConnectionPool<T extends ConnectionType = Connection>
|
||||
this.#connectionBuilder = builder;
|
||||
} else {
|
||||
this.#connectionBuilder = (addr, options, ephemeral) => {
|
||||
const sync = new DefaultSyncModule(this.#system.config.fallbackSync);
|
||||
const sync = new DefaultSyncModule(this.#system.config.fallbackSync, this.#system);
|
||||
return new Connection(addr, options, ephemeral, sync) as unknown as T;
|
||||
};
|
||||
}
|
||||
|
@ -240,7 +240,6 @@ export class Connection extends EventEmitter<ConnectionTypeEvents> implements Co
|
||||
return;
|
||||
}
|
||||
this.emit("event", msg[1] as string, ev);
|
||||
// todo: stats events received
|
||||
break;
|
||||
}
|
||||
case "EOSE": {
|
||||
|
@ -24,7 +24,7 @@ export interface Thread {
|
||||
export const enum EventType {
|
||||
Regular,
|
||||
Replaceable,
|
||||
ParameterizedReplaceable,
|
||||
Addressable,
|
||||
}
|
||||
|
||||
export abstract class EventExt {
|
||||
@ -164,7 +164,7 @@ export abstract class EventExt {
|
||||
static getType(kind: number) {
|
||||
const legacyReplaceable = [0, 3, 41];
|
||||
if (kind >= 30_000 && kind < 40_000) {
|
||||
return EventType.ParameterizedReplaceable;
|
||||
return EventType.Addressable;
|
||||
} else if (kind >= 10_000 && kind < 20_000) {
|
||||
return EventType.Replaceable;
|
||||
} else if (legacyReplaceable.includes(kind)) {
|
||||
@ -174,9 +174,14 @@ export abstract class EventExt {
|
||||
}
|
||||
}
|
||||
|
||||
static isReplaceable(kind: number) {
|
||||
const t = EventExt.getType(kind);
|
||||
return t === EventType.Replaceable || t === EventType.Addressable;
|
||||
}
|
||||
|
||||
static isValid(ev: NostrEvent) {
|
||||
const type = EventExt.getType(ev.kind);
|
||||
if (type === EventType.ParameterizedReplaceable) {
|
||||
if (type === EventType.Addressable) {
|
||||
if (!findTag(ev, "d")) return false;
|
||||
}
|
||||
return ev.sig !== undefined;
|
||||
|
@ -100,7 +100,7 @@ export class NoteCollection extends KeyedReplaceableNoteStore {
|
||||
constructor() {
|
||||
super(e => {
|
||||
switch (EventExt.getType(e.kind)) {
|
||||
case EventType.ParameterizedReplaceable:
|
||||
case EventType.Addressable:
|
||||
return `${e.kind}:${e.pubkey}:${findTag(e, "d")}`;
|
||||
case EventType.Replaceable:
|
||||
return `${e.kind}:${e.pubkey}`;
|
||||
|
@ -29,6 +29,7 @@ export interface Optimizer {
|
||||
flatMerge(all: Array<FlatReqFilter>): Array<ReqFilter>;
|
||||
compress(all: Array<ReqFilter>): Array<ReqFilter>;
|
||||
schnorrVerify(ev: NostrEvent): boolean;
|
||||
batchVerify(evs: Array<NostrEvent>): Array<boolean>;
|
||||
}
|
||||
|
||||
export const DefaultOptimizer = {
|
||||
|
@ -3,14 +3,18 @@ import { EventExt, EventType } from "../event-ext";
|
||||
import { NoteCollection } from "../note-collection";
|
||||
import { RangeSync } from "./range-sync";
|
||||
import { NegentropyFlow } from "../negentropy/negentropy-flow";
|
||||
import { SystemConfig } from "../system";
|
||||
import { SystemConfig, SystemInterface } from "../system";
|
||||
import { findTag } from "../utils";
|
||||
|
||||
export interface ConnectionSyncModule {
|
||||
sync: (c: Connection, item: SyncCommand, cb?: () => void) => void;
|
||||
}
|
||||
|
||||
export class DefaultSyncModule implements ConnectionSyncModule {
|
||||
constructor(readonly method: SystemConfig["fallbackSync"]) {}
|
||||
constructor(
|
||||
readonly method: SystemConfig["fallbackSync"],
|
||||
readonly system: SystemInterface,
|
||||
) {}
|
||||
|
||||
sync(c: Connection, item: SyncCommand, cb?: () => void) {
|
||||
const [_, id, eventSet, ...filters] = item;
|
||||
@ -58,6 +62,36 @@ export class DefaultSyncModule implements ConnectionSyncModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a set of filters down into individual filters
|
||||
* which can be used to since request updates to replaceable events
|
||||
*/
|
||||
#breakdownReplaceable(item: SyncCommand) {
|
||||
const [type, id, eventSet, ...filters] = item;
|
||||
|
||||
const flat = filters.flatMap(a => this.system.optimizer.expandFilter(a));
|
||||
const mapped = flat.map(a => {
|
||||
if (!a.kinds || !a.authors) return a;
|
||||
if (EventExt.isReplaceable(a.kinds)) {
|
||||
const latest = eventSet.find(
|
||||
b => b.kind === a.kinds && b.pubkey === a.authors && (!a["#d"] || findTag(b, "d") === a["#d"]),
|
||||
);
|
||||
if (latest) {
|
||||
return {
|
||||
...a,
|
||||
since: latest.created_at + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
return a;
|
||||
});
|
||||
const compressed = this.system.optimizer.flatMerge(mapped);
|
||||
if (compressed.length !== filters.length) {
|
||||
console.debug("COMPRESSED", id, filters, compressed);
|
||||
}
|
||||
return compressed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Using the latest data, fetch only newer items
|
||||
*
|
||||
@ -66,11 +100,17 @@ export class DefaultSyncModule implements ConnectionSyncModule {
|
||||
#syncSince(c: Connection, item: SyncCommand, cb?: () => void) {
|
||||
const [type, id, eventSet, ...filters] = item;
|
||||
if (type !== "SYNC") throw new Error("Must be a SYNC command");
|
||||
const latest = eventSet.reduce((acc, v) => (acc = v.created_at > acc ? v.created_at : acc), 0);
|
||||
const newFilters = filters.map(a => ({
|
||||
//const broken = this.#breakdownReplaceable(item);
|
||||
const latest = eventSet
|
||||
//.filter(a => !EventExt.isReplaceable(a.kind))
|
||||
.reduce((acc, v) => (acc = v.created_at > acc ? v.created_at : acc), 0);
|
||||
const newFilters = filters.map(a => {
|
||||
if (a.since || latest === 0) return a;
|
||||
return {
|
||||
...a,
|
||||
since: latest + 1,
|
||||
}));
|
||||
};
|
||||
});
|
||||
c.request(["REQ", id, ...newFilters], cb);
|
||||
}
|
||||
|
||||
|
@ -129,10 +129,7 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
|
||||
if (prevTag && prevTag[1] !== this.#base.id) {
|
||||
throw new Error("Previous tag does not match our version");
|
||||
}
|
||||
if (
|
||||
EventExt.getType(ev.kind) !== EventType.Replaceable &&
|
||||
EventExt.getType(ev.kind) !== EventType.ParameterizedReplaceable
|
||||
) {
|
||||
if (EventExt.getType(ev.kind) !== EventType.Replaceable && EventExt.getType(ev.kind) !== EventType.Addressable) {
|
||||
throw new Error("Not a replacable event kind");
|
||||
}
|
||||
if (this.#base.created_at >= ev.created_at) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user