refactor: polish

This commit is contained in:
kieran 2024-09-20 22:15:12 +01:00
parent 4b3e7710e0
commit c274c0a842
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
39 changed files with 419 additions and 320 deletions

View File

@ -42,7 +42,7 @@
top: 48px; top: 48px;
border-left: 1px solid var(--border-color); border-left: 1px solid var(--border-color);
height: 100%; height: 100%;
z-index: 1; z-index: -1;
} }
.subthread-container.subthread-mid:not(.subthread-last) .line-container:before { .subthread-container.subthread-mid:not(.subthread-last) .line-container:before {
@ -52,7 +52,7 @@
left: calc(48px / 2 + 16px); left: calc(48px / 2 + 16px);
top: 0; top: 0;
height: 48px; height: 48px;
z-index: 1; z-index: -1;
} }
.subthread-container.subthread-last .line-container:before { .subthread-container.subthread-last .line-container:before {
@ -62,7 +62,7 @@
left: calc(48px / 2 + 16px); left: calc(48px / 2 + 16px);
top: 0; top: 0;
height: 48px; height: 48px;
z-index: 1; z-index: -1;
} }
.divider { .divider {

View File

@ -63,6 +63,7 @@
object-fit: cover; object-fit: cover;
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: 100%;
display: block; display: block;
border-radius: 0; border-radius: 0;
} }

View File

@ -40,6 +40,7 @@ export default function TrendingUsers({
pubkeys={trendingUsersData.slice(0, count) as HexKey[]} pubkeys={trendingUsersData.slice(0, count) as HexKey[]}
title={title} title={title}
showFollowAll={true} showFollowAll={true}
className="flex flex-col gap-2"
profilePreviewProps={{ profilePreviewProps={{
options: { options: {
about: true, about: true,

View File

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

View File

@ -1,8 +1,6 @@
import "./Avatar.css";
import type { UserMetadata } from "@snort/system"; import type { UserMetadata } from "@snort/system";
import classNames from "classnames"; import classNames from "classnames";
import { ReactNode, useMemo } from "react"; import { HTMLProps, ReactNode, useMemo } from "react";
import { ProxyImg } from "@/Components/ProxyImg"; import { ProxyImg } from "@/Components/ProxyImg";
import { defaultAvatar, getDisplayName } from "@/Utils"; import { defaultAvatar, getDisplayName } from "@/Utils";
@ -22,14 +20,15 @@ interface AvatarProps {
const Avatar = ({ const Avatar = ({
pubkey, pubkey,
user, user,
size, size = 48,
onClick, onClick,
image, image,
imageOverlay, imageOverlay,
icons, icons,
className, className,
showTitle = true, showTitle = true,
}: AvatarProps) => { ...others
}: AvatarProps & Omit<HTMLProps<HTMLDivElement>, "onClick" | "style" | "className">) => {
const defaultImg = defaultAvatar(pubkey); const defaultImg = defaultAvatar(pubkey);
const url = useMemo(() => { const url = useMemo(() => {
if ((image?.length ?? 0) > 0) return image; if ((image?.length ?? 0) > 0) return image;
@ -51,22 +50,39 @@ const Avatar = ({
onClick={onClick} onClick={onClick}
style={style} style={style}
className={classNames( className={classNames(
"avatar relative flex items-center justify-center", "relative rounded-full aspect-square flex items-center justify-center gap-2 bg-gray",
{ "with-overlay": imageOverlay },
{ "outline outline-2 outline-nostr-purple m-[2px]": isDefault }, { "outline outline-2 outline-nostr-purple m-[2px]": isDefault },
className, className,
)} )}
data-domain={domain?.toLowerCase()} data-domain={domain?.toLowerCase()}
title={showTitle ? getDisplayName(user, "") : undefined}> title={showTitle ? getDisplayName(user, "") : undefined}
{...others}>
<ProxyImg <ProxyImg
className="rounded-full object-cover aspect-square" className="absolute rounded-full w-full h-full object-cover"
src={url} src={url}
size={s} size={s}
alt={getDisplayName(user, "")} alt={getDisplayName(user, "")}
promptToLoadDirectly={false} promptToLoadDirectly={false}
/> />
{icons && <div className="icons">{icons}</div>} {icons && (
{imageOverlay && <div className="overlay">{imageOverlay}</div>} <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> </div>
); );
}; };

View File

@ -1,5 +1,4 @@
import { HexKey } from "@snort/system"; import { HexKey } from "@snort/system";
import React from "react";
import ProfileImage from "@/Components/User/ProfileImage"; import ProfileImage from "@/Components/User/ProfileImage";
@ -13,6 +12,8 @@ export function AvatarGroup({ ids, onClick, size }: { ids: HexKey[]; onClick?: (
pubkey={a} pubkey={a}
size={size ?? 24} size={size ?? 24}
showUsername={false} showUsername={false}
showBadges={false}
showProfileCard={false}
/> />
</div> </div>
)); ));

View File

@ -1,6 +1,5 @@
import { HexKey, socialGraphInstance } from "@snort/system"; import { HexKey, socialGraphInstance } from "@snort/system";
import classNames from "classnames"; import classNames from "classnames";
import React from "react";
import Icon from "@/Components/Icons/Icon"; import Icon from "@/Components/Icons/Icon";
@ -31,8 +30,10 @@ export default function FollowDistanceIndicator({ pubkey, className }: FollowDis
} }
return ( 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} /> <Icon name="check" className={followDistanceColor} size={10} />
</span> </div>
); );
} }

View File

@ -34,7 +34,7 @@ export default function FollowListBase({
} }
return ( return (
<div className="flex flex-col g8"> <div className="flex flex-col gap-2">
{(showFollowAll ?? true) && ( {(showFollowAll ?? true) && (
<div className="flex items-center"> <div className="flex items-center">
<div className="grow font-bold">{title}</div> <div className="grow font-bold">{title}</div>
@ -45,9 +45,7 @@ export default function FollowListBase({
</div> </div>
)} )}
<div className={className}> <div className={className}>
{pubkeys?.map((a, index) => ( {pubkeys?.slice(0, 20).map(a => <ProfilePreview pubkey={a} key={a} {...profilePreviewProps} />)}
<ProfilePreview pubkey={a} key={a} waitUntilInView={index > 10} {...profilePreviewProps} />
))}
</div> </div>
</div> </div>
); );

View File

@ -4,7 +4,6 @@ import { FormattedMessage } from "react-intl";
import { AvatarGroup } from "@/Components/User/AvatarGroup"; import { AvatarGroup } from "@/Components/User/AvatarGroup";
import DisplayName from "@/Components/User/DisplayName"; import DisplayName from "@/Components/User/DisplayName";
import FollowDistanceIndicator from "@/Components/User/FollowDistanceIndicator";
import { ProfileLink } from "@/Components/User/ProfileLink"; import { ProfileLink } from "@/Components/User/ProfileLink";
const MAX_FOLLOWED_BY_FRIENDS = 3; const MAX_FOLLOWED_BY_FRIENDS = 3;
@ -31,9 +30,8 @@ export default function FollowedBy({ pubkey }: { pubkey: HexKey }) {
}; };
return ( return (
<div className="flex flex-row items-center"> <div className="flex items-center gap-2">
<div className="flex flex-row items-center"> <div className="flex items-center">
<FollowDistanceIndicator className="p-2" pubkey={pubkey} />
<AvatarGroup ids={followedByFriendsArray} /> <AvatarGroup ids={followedByFriendsArray} />
</div> </div>
{totalFollowedByFriends > 0 && ( {totalFollowedByFriends > 0 && (

View File

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

View File

@ -1,5 +1,3 @@
import "./ProfileImage.css";
import { HexKey, UserMetadata } from "@snort/system"; import { HexKey, UserMetadata } from "@snort/system";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import classNames from "classnames"; import classNames from "classnames";
@ -31,6 +29,7 @@ export interface ProfileImageProps {
icons?: ReactNode; icons?: ReactNode;
showProfileCard?: boolean; showProfileCard?: boolean;
showBadges?: boolean; showBadges?: boolean;
displayNameClassName?: string;
} }
export default function ProfileImage({ export default function ProfileImage({
@ -48,6 +47,7 @@ export default function ProfileImage({
icons, icons,
showProfileCard = false, showProfileCard = false,
showBadges = false, showBadges = false,
displayNameClassName,
}: ProfileImageProps) { }: ProfileImageProps) {
const user = useUserProfile(profile ? "" : pubkey) ?? profile; const user = useUserProfile(profile ? "" : pubkey) ?? profile;
const [isHovering, setIsHovering] = useState(false); const [isHovering, setIsHovering] = useState(false);
@ -75,30 +75,29 @@ export default function ProfileImage({
function inner() { function inner() {
return ( return (
<> <>
<div className="avatar-wrapper" onMouseEnter={handleMouseEnter}> <Avatar
<Avatar pubkey={pubkey}
pubkey={pubkey} user={user}
user={user} size={size}
size={size} imageOverlay={imageOverlay}
imageOverlay={imageOverlay} showTitle={!showProfileCard}
showTitle={!showProfileCard} onMouseEnter={handleMouseEnter}
icons={ icons={
showFollowDistance || icons ? ( showFollowDistance || icons ? (
<> <>
{icons} {icons}
{showFollowDistance && <FollowDistanceIndicator pubkey={pubkey} />} {showFollowDistance && <FollowDistanceIndicator pubkey={pubkey} />}
</> </>
) : undefined ) : undefined
} }
/> />
</div>
{showUsername && ( {showUsername && (
<div className="f-ellipsis"> <div className={displayNameClassName}>
<div className="flex gap-2 items-center font-medium"> <div className="flex gap-2 items-center font-medium">
{overrideUsername ? overrideUsername : <DisplayName pubkey={pubkey} user={user} />} {overrideUsername ? overrideUsername : <DisplayName pubkey={pubkey} user={user} />}
{leader && showBadges && CONFIG.features.communityLeaders && <LeaderBadge />} {leader && showBadges && CONFIG.features.communityLeaders && <LeaderBadge />}
</div> </div>
<div className="subheader">{subHeader}</div> {subHeader}
</div> </div>
)} )}
</> </>
@ -108,7 +107,7 @@ export default function ProfileImage({
function profileCard() { function profileCard() {
if (showProfileCard && user && isHovering) { if (showProfileCard && user && isHovering) {
return ( 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} /> <ProfileCard pubkey={pubkey} user={user} show={true} delay={100} />
</div> </div>
); );
@ -116,10 +115,17 @@ export default function ProfileImage({
return null; return null;
} }
const classNamesOverInner = classNames(
"min-w-0",
{
"grid grid-cols-[min-content_auto] gap-3 items-center": showUsername,
},
className,
);
if (link === "") { if (link === "") {
return ( return (
<> <>
<div className={classNames("pfp", className)} onClick={handleClick}> <div className={classNamesOverInner} onClick={handleClick}>
{inner()} {inner()}
</div> </div>
{profileCard()} {profileCard()}
@ -127,12 +133,12 @@ export default function ProfileImage({
); );
} else { } else {
return ( return (
<div className="relative" onMouseLeave={handleMouseLeave}> <div onMouseLeave={handleMouseLeave}>
<ProfileLink <ProfileLink
pubkey={pubkey} pubkey={pubkey}
className={classNames("pfp", className)}
user={user} user={user}
explicitLink={link} explicitLink={link}
className={classNamesOverInner}
onClick={handleClick}> onClick={handleClick}>
{inner()} {inner()}
</ProfileLink> </ProfileLink>

View File

@ -4,6 +4,7 @@ import { ReactNode, useContext } from "react";
import { Link, LinkProps } from "react-router-dom"; import { Link, LinkProps } from "react-router-dom";
import { randomSample } from "@/Utils"; import { randomSample } from "@/Utils";
import { useProfileLink } from "@/Hooks/useProfileLink";
export function ProfileLink({ export function ProfileLink({
pubkey, pubkey,
@ -17,39 +18,13 @@ export function ProfileLink({
explicitLink?: string; explicitLink?: string;
children?: ReactNode; children?: ReactNode;
} & Omit<LinkProps, "to">) { } & Omit<LinkProps, "to">) {
const system = useContext(SnortContext); const link = useProfileLink(pubkey, user);
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>; const oFiltered = others as Record<string, unknown>;
delete oFiltered["user"]; delete oFiltered["user"];
delete oFiltered["link"]; delete oFiltered["link"];
delete oFiltered["children"]; delete oFiltered["children"];
return ( return (
<Link {...oFiltered} to={profileLink()} state={user}> <Link {...oFiltered} to={explicitLink ?? link} state={user}>
{children} {children}
</Link> </Link>
); );

View File

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

View File

@ -1,9 +1,7 @@
import "./ProfilePreview.css";
import { HexKey, UserMetadata } from "@snort/system"; import { HexKey, UserMetadata } from "@snort/system";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import { ReactNode } from "react"; import classNames from "classnames";
import { useInView } from "react-intersection-observer"; import { forwardRef, ReactNode } from "react";
import FollowButton from "@/Components/User/FollowButton"; import FollowButton from "@/Components/User/FollowButton";
import ProfileImage, { ProfileImageProps } from "@/Components/User/ProfileImage"; import ProfileImage, { ProfileImageProps } from "@/Components/User/ProfileImage";
@ -17,13 +15,14 @@ export interface ProfilePreviewProps {
actions?: ReactNode; actions?: ReactNode;
className?: string; className?: string;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void; onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
waitUntilInView?: boolean;
profileImageProps?: Omit<ProfileImageProps, "pubkey" | "profile">; 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 pubkey = props.pubkey;
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "500px" }); const user = useUserProfile(pubkey);
const user = useUserProfile(inView ? pubkey : undefined);
const options = { const options = {
about: true, about: true,
...props.options, ...props.options,
@ -39,26 +38,29 @@ export default function ProfilePreview(props: ProfilePreviewProps) {
return ( return (
<> <>
<div <div className={classNames("flex items-center justify-between", props.className)} ref={ref} onClick={handleClick}>
className={`justify-between profile-preview${props.className ? ` ${props.className}` : ""}`} <ProfileImage
ref={ref} pubkey={pubkey}
onClick={handleClick}> profile={props.profile}
{(!props.waitUntilInView || inView) && ( className="overflow-hidden"
<> displayNameClassName="min-w-0"
<ProfileImage subHeader={
pubkey={pubkey} options.about && (
profile={props.profile} <div className="text-sm text-secondary whitespace-nowrap text-ellipsis overflow-hidden">
subHeader={options.about && <div className="about">{user?.about}</div>} {user?.about}
{...props.profileImageProps}
/>
{props.actions ?? (
<div className="whitespace-nowrap">
<FollowButton pubkey={pubkey} />
</div> </div>
)} )
</> }
{...props.profileImageProps}
/>
{props.actions ?? (
<div className="whitespace-nowrap">
<FollowButton pubkey={pubkey} />
</div>
)} )}
</div> </div>
</> </>
); );
} });
export default ProfilePreview;

View File

@ -86,9 +86,9 @@ export default function ZapModal(props: SendSatsProps) {
if (!(props.show ?? false)) return null; if (!(props.show ?? false)) return null;
return ( return (
<Modal id="send-sats" className="lnurl-modal" onClose={onClose}> <Modal id="send-sats" className="lnurl-modal" onClose={onClose}>
<div className="p flex flex-col g12"> <div className="p flex flex-col gap-3">
<div className="flex g12"> <div className="flex gap-3">
<div className="flex items-center grow"> <div className="flex items-center grow gap-3">
{props.title || <ZapModalTitle amount={amount} targets={props.targets} zapper={zapper} />} {props.title || <ZapModalTitle amount={amount} targets={props.targets} zapper={zapper} />}
</div> </div>
<CloseButton onClick={onClose} /> <CloseButton onClick={onClose} />

View File

@ -10,7 +10,7 @@ export function useArticles() {
const sub = useMemo(() => { const sub = useMemo(() => {
const rb = new RequestBuilder("articles"); const rb = new RequestBuilder("articles");
if (followList.length > 0) { if (followList.length > 0) {
rb.withFilter().kinds([EventKind.LongFormTextNote]).authors(followList).limit(20); rb.withFilter().kinds([EventKind.LongFormTextNote]).authors(followList);
} }
return rb; return rb;
}, [followList]); }, [followList]);

View File

@ -8,14 +8,14 @@ export function useNotificationsView() {
const publicKey = useLogin(s => s.publicKey); const publicKey = useLogin(s => s.publicKey);
const kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]; const kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt];
const req = useMemo(() => { const req = useMemo(() => {
const rb = new RequestBuilder("notifications");
rb.withOptions({
leaveOpen: true,
});
if (publicKey) { if (publicKey) {
const rb = new RequestBuilder("notifications");
rb.withOptions({
leaveOpen: true,
});
rb.withFilter().kinds(kinds).tag("p", [publicKey]); rb.withFilter().kinds(kinds).tag("p", [publicKey]);
return rb;
} }
return rb;
}, [publicKey]); }, [publicKey]);
return useRequestBuilder(req); return useRequestBuilder(req);
} }

View File

@ -1,4 +1,5 @@
import { NostrLink } from "@snort/system"; import { NostrLink } from "@snort/system";
import { useMemo } from "react";
import useLogin from "./useLogin"; import useLogin from "./useLogin";
@ -6,27 +7,30 @@ import useLogin from "./useLogin";
* Simple hook for adding / removing follows * Simple hook for adding / removing follows
*/ */
export default function useFollowsControls() { 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 { return useMemo(() => {
isFollowing: (pk: string) => { const follows = state.follows;
return state.follows?.includes(pk); return {
}, isFollowing: (pk: string) => {
addFollow: async (pk: Array<string>) => { return follows?.includes(pk);
for (const p of pk) { },
await state.follow(NostrLink.publicKey(p), false); addFollow: async (pk: Array<string>) => {
} for (const p of pk) {
await state.saveContacts(); await state.follow(NostrLink.publicKey(p), false);
}, }
removeFollow: async (pk: Array<string>) => { await state.saveContacts();
for (const p of pk) { },
await state.unfollow(NostrLink.publicKey(p), false); removeFollow: async (pk: Array<string>) => {
} for (const p of pk) {
await state.saveContacts(); await state.unfollow(NostrLink.publicKey(p), false);
}, }
setFollows: async (pk: Array<string>) => { await state.saveContacts();
await state.replaceFollows(pk.map(a => NostrLink.publicKey(a))); },
}, setFollows: async (pk: Array<string>) => {
followList: state.follows ?? [], await state.replaceFollows(pk.map(a => NostrLink.publicKey(a)));
}; },
followList: follows ?? [],
};
}, [v]);
} }

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

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

View File

@ -1,11 +1,15 @@
import { socialGraphInstance, TaggedNostrEvent } from "@snort/system"; import { socialGraphInstance, TaggedNostrEvent } from "@snort/system";
import { useMemo } from "react";
export default function useWoT() { export default function useWoT() {
return { return useMemo(
sortEvents: (events: Array<TaggedNostrEvent>) => () => ({
events.sort( sortEvents: (events: Array<TaggedNostrEvent>) =>
(a, b) => socialGraphInstance.getFollowDistance(a.pubkey) - socialGraphInstance.getFollowDistance(b.pubkey), events.sort(
), (a, b) => socialGraphInstance.getFollowDistance(a.pubkey) - socialGraphInstance.getFollowDistance(b.pubkey),
followDistance: (pk: string) => socialGraphInstance.getFollowDistance(pk), ),
}; followDistance: (pk: string) => socialGraphInstance.getFollowDistance(pk),
}),
[socialGraphInstance.root],
);
} }

View File

@ -1,14 +1,13 @@
import { useUserProfile } from "@snort/system-react";
import classNames from "classnames"; import classNames from "classnames";
import React, { useState } from "react"; import React, { useState } from "react";
import { useIntl } from "react-intl";
import NavLink from "@/Components/Button/NavLink"; import NavLink from "@/Components/Button/NavLink";
import { NoteCreatorButton } from "@/Components/Event/Create/NoteCreatorButton"; import { NoteCreatorButton } from "@/Components/Event/Create/NoteCreatorButton";
import Icon from "@/Components/Icons/Icon"; import Icon from "@/Components/Icons/Icon";
import Avatar from "@/Components/User/Avatar";
import { ProfileLink } from "@/Components/User/ProfileLink";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import useWindowSize from "@/Hooks/useWindowSize";
import ProfileMenu from "./ProfileMenu";
type MenuItem = { type MenuItem = {
label?: string; label?: string;
@ -34,33 +33,21 @@ const MENU_ITEMS: MenuItem[] = [
]; ];
const Footer = () => { const Footer = () => {
const { publicKey, readonly } = useLogin(s => ({ const { readonly } = useLogin(s => ({
publicKey: s.publicKey,
readonly: s.readonly, readonly: s.readonly,
})); }));
const profile = useUserProfile(publicKey); const pageSize = useWindowSize();
const { formatMessage } = useIntl(); const isMobile = pageSize.width <= 768; //max-md
if (!isMobile) return;
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>
);
return ( return (
<footer className="md:hidden fixed bottom-0 z-10 w-full bg-base-200 pb-safe-area bg-background"> <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) => ( {MENU_ITEMS.map((item, index) => (
<FooterNavItem key={index} item={item} readonly={readonly} /> <FooterNavItem key={index} item={item} readonly={readonly} />
))} ))}
{publicKey && (
<ProfileLink <ProfileMenu className="flex justify-center items-center" />
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>
)}
</div> </div>
</footer> </footer>
); );
@ -83,7 +70,7 @@ const FooterNavItem = ({ item, readonly }: { item: MenuItem; readonly: boolean }
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
className={({ isActive }) => 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}-solid`} className="icon-solid" size={24} />
<Icon name={`${item.icon}-outline`} className="icon-outline" size={24} /> <Icon name={`${item.icon}-outline`} className="icon-outline" size={24} />

View File

@ -1,20 +1,19 @@
import { useUserProfile } from "@snort/system-react";
import classNames from "classnames"; import classNames from "classnames";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import NavLink from "@/Components/Button/NavLink"; import NavLink from "@/Components/Button/NavLink";
import { NoteCreatorButton } from "@/Components/Event/Create/NoteCreatorButton"; import { NoteCreatorButton } from "@/Components/Event/Create/NoteCreatorButton";
import Icon from "@/Components/Icons/Icon"; import Icon from "@/Components/Icons/Icon";
import Avatar from "@/Components/User/Avatar";
import { ProfileLink } from "@/Components/User/ProfileLink";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import useWindowSize from "@/Hooks/useWindowSize";
import { HasNotificationsMarker } from "@/Pages/Layout/HasNotificationsMarker"; import { HasNotificationsMarker } from "@/Pages/Layout/HasNotificationsMarker";
import { WalletBalance } from "@/Pages/Layout/WalletBalance"; import { WalletBalance } from "@/Pages/Layout/WalletBalance";
import { subscribeToNotifications } from "@/Utils/Notifications"; import { subscribeToNotifications } from "@/Utils/Notifications";
import { LogoHeader } from "./LogoHeader"; import { LogoHeader } from "./LogoHeader";
import ProfileMenu from "./ProfileMenu";
const MENU_ITEMS = [ const MENU_ITEMS = [
{ {
@ -79,20 +78,17 @@ export default function NavSidebar({ narrow = false }: { narrow?: boolean }) {
publicKey: s.publicKey, publicKey: s.publicKey,
readonly: s.readonly, readonly: s.readonly,
})); }));
const profile = useUserProfile(publicKey);
const navigate = useNavigate(); const navigate = useNavigate();
const { publisher } = useEventPublisher(); const { publisher } = useEventPublisher();
const { formatMessage } = useIntl();
const pageSize = useWindowSize();
const isMobile = pageSize.width <= 768; //max-md
if (isMobile) return;
const className = classNames( const className = classNames(
{ "xl:w-56 xl:gap-2 xl:items-start": !narrow }, { "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", "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",
);
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>
); );
return ( return (
@ -156,21 +152,7 @@ export default function NavSidebar({ narrow = false }: { narrow?: boolean }) {
)} )}
</div> </div>
</div> </div>
{publicKey && ( <ProfileMenu />
<>
<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>
)}
</>
)}
</div> </div>
); );
} }

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

View File

@ -12,12 +12,17 @@ import TrendingHashtags from "@/Components/Trending/TrendingHashtags";
import TrendingNotes from "@/Components/Trending/TrendingPosts"; import TrendingNotes from "@/Components/Trending/TrendingPosts";
import TrendingUsers from "@/Components/Trending/TrendingUsers"; import TrendingUsers from "@/Components/Trending/TrendingUsers";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import useWindowSize from "@/Hooks/useWindowSize";
export default function RightColumn() { export default function RightColumn() {
const { pubkey } = useLogin(s => ({ pubkey: s.publicKey })); const { pubkey } = useLogin(s => ({ pubkey: s.publicKey }));
const hideRightColumnPaths = ["/login", "/new", "/messages"]; const hideRightColumnPaths = ["/login", "/new", "/messages"];
const show = !hideRightColumnPaths.some(path => globalThis.location.pathname.startsWith(path)); 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 const widgets = pubkey
? [ ? [
RightColumnWidget.TaskList, RightColumnWidget.TaskList,

View File

@ -72,12 +72,32 @@ export function ZapsProfileTab({ id }: { id: HexKey }) {
export function FollowersTab({ id }: { id: HexKey }) { export function FollowersTab({ id }: { id: HexKey }) {
const followers = useFollowersFeed(id); 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 }) { export function FollowsTab({ id }: { id: HexKey }) {
const follows = useFollowsFeed(id); 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 }) { export function RelaysTab({ id }: { id: HexKey }) {

View File

@ -10,20 +10,20 @@ export default function AccountsPage() {
const sub = getActiveSubscriptions(LoginStore.allSubscriptions()); const sub = getActiveSubscriptions(LoginStore.allSubscriptions());
return ( return (
<div className="flex flex-col g12"> <div className="flex flex-col gap-2">
<h3> <h3>
<FormattedMessage defaultMessage="Logins" /> <FormattedMessage defaultMessage="Logins" />
</h3> </h3>
{logins.map(a => ( {logins.map(a => (
<div className="card flex" key={a.id}> <div className="" key={a.id}>
<ProfilePreview <ProfilePreview
pubkey={a.pubkey} pubkey={a.pubkey}
options={{ options={{
about: false, about: false,
}} }}
actions={ actions={
<div className="flex-1"> <div className="align-end flex gap-2">
<button className="mb10" onClick={() => LoginStore.switchAccount(a.id)}> <button onClick={() => LoginStore.switchAccount(a.id)}>
<FormattedMessage defaultMessage="Switch" /> <FormattedMessage defaultMessage="Switch" />
</button> </button>
<button onClick={() => LoginStore.removeSession(a.id)}> <button onClick={() => LoginStore.removeSession(a.id)}>

View File

@ -799,7 +799,6 @@ button.tall {
.ctx-menu li { .ctx-menu li {
background: #1e1e1e !important; background: #1e1e1e !important;
padding: 8px 24px !important;
display: grid !important; display: grid !important;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
@ -807,6 +806,7 @@ button.tall {
.ctx-menu:not(.no-icons) li { .ctx-menu:not(.no-icons) li {
grid-template-columns: 2rem auto !important; grid-template-columns: 2rem auto !important;
padding: 8px 24px !important;
} }
.light .ctx-menu li { .light .ctx-menu li {

View File

@ -348,6 +348,9 @@
"6uMqL1": { "6uMqL1": {
"defaultMessage": "Unpaid" "defaultMessage": "Unpaid"
}, },
"6xNr8c": {
"defaultMessage": "Switch accounts"
},
"6xap9L": { "6xap9L": {
"defaultMessage": "Good" "defaultMessage": "Good"
}, },
@ -1611,9 +1614,6 @@
"djLctd": { "djLctd": {
"defaultMessage": "Amount in sats" "defaultMessage": "Amount in sats"
}, },
"djNL6D": {
"defaultMessage": "Read-only"
},
"dmcsBA": { "dmcsBA": {
"defaultMessage": "Classified Listing" "defaultMessage": "Classified Listing"
}, },
@ -1983,6 +1983,9 @@
"mfe8RW": { "mfe8RW": {
"defaultMessage": "Option: {n}" "defaultMessage": "Option: {n}"
}, },
"mmPSWH": {
"defaultMessage": "Read Only"
},
"n1Whvj": { "n1Whvj": {
"defaultMessage": "Switch" "defaultMessage": "Switch"
}, },

View File

@ -19,6 +19,7 @@ export const System = new NostrSystem({
optimizer: hasWasm ? WasmOptimizer : undefined, optimizer: hasWasm ? WasmOptimizer : undefined,
db: SystemDb, db: SystemDb,
buildFollowGraph: true, buildFollowGraph: true,
automaticOutboxModel: true,
}); });
System.on("auth", async (c, r, cb) => { System.on("auth", async (c, r, cb) => {

View File

@ -115,6 +115,7 @@
"6mr8WU": "Followed by", "6mr8WU": "Followed by",
"6pdxsi": "Extra metadata fields and tags", "6pdxsi": "Extra metadata fields and tags",
"6uMqL1": "Unpaid", "6uMqL1": "Unpaid",
"6xNr8c": "Switch accounts",
"6xap9L": "Good", "6xap9L": "Good",
"7+Domh": "Notes", "7+Domh": "Notes",
"712i26": "Proxy uses HODL invoices to forward the payment, which hides the pubkey of your node", "712i26": "Proxy uses HODL invoices to forward the payment, which hides the pubkey of your node",
@ -534,7 +535,6 @@
"ddd3JX": "Popular Hashtags", "ddd3JX": "Popular Hashtags",
"deEeEI": "Register", "deEeEI": "Register",
"djLctd": "Amount in sats", "djLctd": "Amount in sats",
"djNL6D": "Read-only",
"dmcsBA": "Classified Listing", "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}", "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", "e5x8FT": "Kind",
@ -658,6 +658,7 @@
"mKhgP9": "{n,plural,=0{} =1{zapped} other{zapped}}", "mKhgP9": "{n,plural,=0{} =1{zapped} other{zapped}}",
"mOFG3K": "Start", "mOFG3K": "Start",
"mfe8RW": "Option: {n}", "mfe8RW": "Option: {n}",
"mmPSWH": "Read Only",
"n1Whvj": "Switch", "n1Whvj": "Switch",
"n5l7tP": "Time-Based Calendar Event", "n5l7tP": "Time-Based Calendar Event",
"n8k1SG": "{n}MiB", "n8k1SG": "{n}MiB",

View File

@ -1,7 +1,15 @@
import { ID, STR, UID } from "./UniqueIds"; import { ID, STR, UID } from "./UniqueIds";
import { HexKey, NostrEvent } from ".."; 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; root: UID;
followDistanceByUser = new Map<UID, number>(); followDistanceByUser = new Map<UID, number>();
usersByFollowDistance = new Map<number, Set<UID>>(); usersByFollowDistance = new Map<number, Set<UID>>();
@ -10,6 +18,7 @@ export default class SocialGraph {
latestFollowEventTimestamps = new Map<UID, number>(); latestFollowEventTimestamps = new Map<UID, number>();
constructor(root: HexKey) { constructor(root: HexKey) {
super();
this.root = ID(root); this.root = ID(root);
this.followDistanceByUser.set(this.root, 0); this.followDistanceByUser.set(this.root, 0);
this.usersByFollowDistance.set(0, new Set([this.root])); this.usersByFollowDistance.set(0, new Set([this.root]));
@ -20,6 +29,7 @@ export default class SocialGraph {
if (rootId === this.root) { if (rootId === this.root) {
return; return;
} }
const start = unixNowMs();
this.root = rootId; this.root = rootId;
this.followDistanceByUser.clear(); this.followDistanceByUser.clear();
this.usersByFollowDistance.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>) { handleEvent(evs: NostrEvent | Array<NostrEvent>) {

View File

@ -130,7 +130,7 @@ export class DefaultConnectionPool<T extends ConnectionType = Connection>
this.#connectionBuilder = builder; this.#connectionBuilder = builder;
} else { } else {
this.#connectionBuilder = (addr, options, ephemeral) => { 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; return new Connection(addr, options, ephemeral, sync) as unknown as T;
}; };
} }

View File

@ -240,7 +240,6 @@ export class Connection extends EventEmitter<ConnectionTypeEvents> implements Co
return; return;
} }
this.emit("event", msg[1] as string, ev); this.emit("event", msg[1] as string, ev);
// todo: stats events received
break; break;
} }
case "EOSE": { case "EOSE": {

View File

@ -24,7 +24,7 @@ export interface Thread {
export const enum EventType { export const enum EventType {
Regular, Regular,
Replaceable, Replaceable,
ParameterizedReplaceable, Addressable,
} }
export abstract class EventExt { export abstract class EventExt {
@ -164,7 +164,7 @@ export abstract class EventExt {
static getType(kind: number) { static getType(kind: number) {
const legacyReplaceable = [0, 3, 41]; const legacyReplaceable = [0, 3, 41];
if (kind >= 30_000 && kind < 40_000) { if (kind >= 30_000 && kind < 40_000) {
return EventType.ParameterizedReplaceable; return EventType.Addressable;
} else if (kind >= 10_000 && kind < 20_000) { } else if (kind >= 10_000 && kind < 20_000) {
return EventType.Replaceable; return EventType.Replaceable;
} else if (legacyReplaceable.includes(kind)) { } 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) { static isValid(ev: NostrEvent) {
const type = EventExt.getType(ev.kind); const type = EventExt.getType(ev.kind);
if (type === EventType.ParameterizedReplaceable) { if (type === EventType.Addressable) {
if (!findTag(ev, "d")) return false; if (!findTag(ev, "d")) return false;
} }
return ev.sig !== undefined; return ev.sig !== undefined;

View File

@ -100,7 +100,7 @@ export class NoteCollection extends KeyedReplaceableNoteStore {
constructor() { constructor() {
super(e => { super(e => {
switch (EventExt.getType(e.kind)) { switch (EventExt.getType(e.kind)) {
case EventType.ParameterizedReplaceable: case EventType.Addressable:
return `${e.kind}:${e.pubkey}:${findTag(e, "d")}`; return `${e.kind}:${e.pubkey}:${findTag(e, "d")}`;
case EventType.Replaceable: case EventType.Replaceable:
return `${e.kind}:${e.pubkey}`; return `${e.kind}:${e.pubkey}`;

View File

@ -29,6 +29,7 @@ export interface Optimizer {
flatMerge(all: Array<FlatReqFilter>): Array<ReqFilter>; flatMerge(all: Array<FlatReqFilter>): Array<ReqFilter>;
compress(all: Array<ReqFilter>): Array<ReqFilter>; compress(all: Array<ReqFilter>): Array<ReqFilter>;
schnorrVerify(ev: NostrEvent): boolean; schnorrVerify(ev: NostrEvent): boolean;
batchVerify(evs: Array<NostrEvent>): Array<boolean>;
} }
export const DefaultOptimizer = { export const DefaultOptimizer = {

View File

@ -3,14 +3,18 @@ import { EventExt, EventType } from "../event-ext";
import { NoteCollection } from "../note-collection"; import { NoteCollection } from "../note-collection";
import { RangeSync } from "./range-sync"; import { RangeSync } from "./range-sync";
import { NegentropyFlow } from "../negentropy/negentropy-flow"; import { NegentropyFlow } from "../negentropy/negentropy-flow";
import { SystemConfig } from "../system"; import { SystemConfig, SystemInterface } from "../system";
import { findTag } from "../utils";
export interface ConnectionSyncModule { export interface ConnectionSyncModule {
sync: (c: Connection, item: SyncCommand, cb?: () => void) => void; sync: (c: Connection, item: SyncCommand, cb?: () => void) => void;
} }
export class DefaultSyncModule implements ConnectionSyncModule { 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) { sync(c: Connection, item: SyncCommand, cb?: () => void) {
const [_, id, eventSet, ...filters] = item; 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 * Using the latest data, fetch only newer items
* *
@ -66,11 +100,17 @@ export class DefaultSyncModule implements ConnectionSyncModule {
#syncSince(c: Connection, item: SyncCommand, cb?: () => void) { #syncSince(c: Connection, item: SyncCommand, cb?: () => void) {
const [type, id, eventSet, ...filters] = item; const [type, id, eventSet, ...filters] = item;
if (type !== "SYNC") throw new Error("Must be a SYNC command"); 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 broken = this.#breakdownReplaceable(item);
const newFilters = filters.map(a => ({ const latest = eventSet
...a, //.filter(a => !EventExt.isReplaceable(a.kind))
since: latest + 1, .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); c.request(["REQ", id, ...newFilters], cb);
} }

View File

@ -129,10 +129,7 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
if (prevTag && prevTag[1] !== this.#base.id) { if (prevTag && prevTag[1] !== this.#base.id) {
throw new Error("Previous tag does not match our version"); throw new Error("Previous tag does not match our version");
} }
if ( if (EventExt.getType(ev.kind) !== EventType.Replaceable && EventExt.getType(ev.kind) !== EventType.Addressable) {
EventExt.getType(ev.kind) !== EventType.Replaceable &&
EventExt.getType(ev.kind) !== EventType.ParameterizedReplaceable
) {
throw new Error("Not a replacable event kind"); throw new Error("Not a replacable event kind");
} }
if (this.#base.created_at >= ev.created_at) { if (this.#base.created_at >= ev.created_at) {