add following service

This commit is contained in:
hzrd149
2023-02-08 08:37:41 -06:00
parent 2dc1d29f52
commit 5cd5d26831
9 changed files with 171 additions and 25 deletions

View File

@@ -12,7 +12,7 @@
- [x] NIP-05 support
- [x] Broadcast events
- [x] User tipping
- [ ] Manage followers ( Contact List )
- [x] Manage followers ( Contact List )
- [ ] Profile management
- [ ] Relay management
- [ ] Image upload
@@ -24,7 +24,7 @@
## Supported NIPs
- [ ] [NIP-02](https://github.com/nostr-protocol/nips/blob/master/02.md): Contact List and Petnames
- [x] [NIP-02](https://github.com/nostr-protocol/nips/blob/master/02.md): Contact List and Petnames
- [ ] [NIP-03](https://github.com/nostr-protocol/nips/blob/master/03.md): OpenTimestamps Attestations for Events
- [ ] [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md): Encrypted Direct Message
- [x] [NIP-05](https://github.com/nostr-protocol/nips/blob/master/05.md): Mapping Nostr keys to DNS-based internet identifiers
@@ -53,7 +53,6 @@
- add `client` tag to published events
- add button for creating lightning invoice via WebLN
- setup deploy to s3
- make app a valid web share target https://developer.chrome.com/articles/web-share-target/
- make app handle image files
- block notes based on content

View File

@@ -9,7 +9,6 @@ import {
ModalBody,
ModalCloseButton,
Button,
Spacer,
} from "@chakra-ui/react";
import { Relay } from "../services/relays";
import relayPool from "../services/relays/relay-pool";
@@ -49,7 +48,7 @@ export const ConnectedRelays = () => {
<ModalCloseButton />
<ModalBody>
{relays.map((relay) => (
<Text>
<Text key={relay.url}>
<RelayStatus url={relay.url} /> {relay.url}
</Text>
))}

View File

@@ -3,8 +3,8 @@ import { Link } from "react-router-dom";
import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip-19";
import { getUserDisplayName } from "../helpers/user-metadata";
import useSubject from "../hooks/use-subject";
import { useUserContacts } from "../hooks/use-user-contacts";
import { useUserMetadata } from "../hooks/use-user-metadata";
import followingService from "../services/following";
import identity from "../services/identity";
import { UserAvatar } from "./user-avatar";
@@ -29,15 +29,15 @@ const FollowingListItem = ({ pubkey }: { pubkey: string }) => {
export const FollowingList = () => {
const pubkey = useSubject(identity.pubkey);
const contacts = useUserContacts(pubkey);
const following = useSubject(followingService.following);
if (!contacts) return <SkeletonText />;
if (!following) return <SkeletonText />;
return (
<Box overflow="auto" pr="2" pb="4" pt="2">
<Flex direction="column" gap="2">
{contacts.contacts.map((contact) => (
<FollowingListItem key={contact} pubkey={contact} />
{following.map((pTag) => (
<FollowingListItem key={pTag[1]} pubkey={pTag[1]} />
))}
</Flex>
</Box>

View File

@@ -167,7 +167,7 @@ const embeds: EmbedType[] = [
regexp: /(https?:\/\/)([\da-z\.-]+\.[a-z\.]{2,6})([\/\w\.-]+\.(svg|gif|png|jpg|jpeg|webp|avif))[^\s]*/im,
render: (match, event, trusted) => {
const ImageComponent = trusted || !settings.blurImages.value ? Image : BlurredImage;
return <ImageComponent src={match[0]} width="100%" maxWidth="30rem" minHeight="20rem" />;
return <ImageComponent src={match[0]} width="100%" maxWidth="30rem" />;
},
name: "Image",
isMedia: true,
@@ -175,11 +175,7 @@ const embeds: EmbedType[] = [
// Video
{
regexp: /(https?:\/\/)([\da-z\.-]+\.[a-z\.]{2,6})([\/\w\.-]+\.(mp4|mkv|webm|mov))[^\s]*/im,
render: (match) => (
<AspectRatio ratio={16 / 9} maxWidth="30rem">
<video src={match[0]} controls />
</AspectRatio>
),
render: (match) => <video src={match[0]} controls style={{ maxWidth: "30rem" }} />,
name: "Video",
isMedia: true,
},

View File

@@ -17,10 +17,21 @@ import { UserAvatarLink } from "./user-avatar-link";
const MobileProfileHeader = () => {
const pubkey = useSubject(identity.pubkey);
const readonly = useReadonlyMode();
return (
<Flex justifyContent="space-between" padding="2">
<Flex justifyContent="space-between" padding="2" alignItems="center">
<UserAvatarLink pubkey={pubkey} size="sm" />
{readonly && (
<Button
colorScheme="red"
textAlign="center"
variant="link"
onClick={() => confirm("Exit readonly mode?") && identity.logout()}
>
Readonly Mode
</Button>
)}
<Flex gap="2">
<ConnectedRelays />
<IconButton
@@ -86,7 +97,7 @@ const DesktopSideNav = () => {
Logout
</Button>
{readonly && (
<Text color="yellow.500" textAlign="center">
<Text color="red.200" textAlign="center">
Readonly Mode
</Text>
)}

View File

@@ -0,0 +1,31 @@
import { Button, ButtonProps } from "@chakra-ui/react";
import { useReadonlyMode } from "../hooks/use-readonly-mode";
import useSubject from "../hooks/use-subject";
import followingService from "../services/following";
export const UserFollowButton = ({
pubkey,
...props
}: { pubkey: string } & Omit<ButtonProps, "onClick" | "isLoading" | "isDisabled">) => {
const readonly = useReadonlyMode();
const following = useSubject(followingService.following);
const savingDraft = useSubject(followingService.savingDraft);
const isFollowing = following.some((t) => t[1] === pubkey);
const toggleFollow = async () => {
if (isFollowing) {
followingService.removeContact(pubkey);
} else {
followingService.addContact(pubkey);
}
await followingService.savePendingDraft();
};
return (
<Button colorScheme="brand" {...props} isLoading={savingDraft} onClick={toggleFollow} isDisabled={readonly}>
{isFollowing ? "Unfollow" : "Follow"}
</Button>
);
};

View File

@@ -10,3 +10,10 @@ export function getUserDisplayName(metadata: Kind0ParsedContent | undefined, pub
}
return truncatedId(normalizeToBech32(pubkey, Bech32Prefix.Pubkey) ?? pubkey);
}
export function fixWebsiteUrl(website: string) {
if (website.match(/^http?s:\/\//)) {
return website;
}
return "https://" + website;
}

106
src/services/following.ts Normal file
View File

@@ -0,0 +1,106 @@
import moment from "moment";
import { BehaviorSubject, lastValueFrom } from "rxjs";
import { nostrPostAction } from "../classes/nostr-post-action";
import { DraftNostrEvent, PTag } from "../types/nostr-event";
import identity from "./identity";
import settings from "./settings";
import userContactsService, { UserContacts } from "./user-contacts";
export type RelayDirectory = Record<string, { read: boolean; write: boolean }>;
const following = new BehaviorSubject<PTag[]>([]);
// const relays = new BehaviorSubject<RelayDirectory>({});
const pendingDraft = new BehaviorSubject<DraftNostrEvent | null>(null);
const savingDraft = new BehaviorSubject(false);
let sub;
identity.pubkey.subscribe((pubkey) => {
sub = userContactsService.requestContacts(pubkey, settings.relays.value, true).subscribe((userContacts) => {
if (!userContacts) return;
following.next(
userContacts.contacts.map((key) => {
const relay = userContacts.contactRelay[key];
if (relay) return ["p", key, relay];
else return ["p", key];
})
);
// reset the pending list since we just got a new contacts list
pendingDraft.next(null);
});
});
function isFollowing(pubkey: string) {
return following.value.some((t) => t[1] === pubkey);
}
function getDraftEvent(): DraftNostrEvent {
return {
kind: 3,
tags: following.value,
// according to NIP-02 kind 3 events (contact list) can have any content and it should be ignored
// https://github.com/nostr-protocol/nips/blob/master/02.md
// some other clients are using the content to store relays.
content: "",
created_at: moment().unix(),
};
}
async function savePendingDraft() {
const draft = pendingDraft.value;
if (!draft) return;
if (window.nostr) {
savingDraft.next(true);
const event = await window.nostr.signEvent(draft);
const results = nostrPostAction(settings.relays.value, event);
await lastValueFrom(results);
savingDraft.next(false);
// pass new event to contact list service
userContactsService.receiveEvent(event);
}
}
function addContact(pubkey: string, relay?: string) {
const newTag: PTag = relay ? ["p", pubkey, relay] : ["p", pubkey];
if (isFollowing(pubkey)) {
following.next(
following.value.map((t) => {
if (t[1] === pubkey) {
return newTag;
}
return t;
})
);
} else {
following.next([...following.value, newTag]);
}
pendingDraft.next(getDraftEvent());
}
function removeContact(pubkey: string) {
if (isFollowing(pubkey)) {
following.next(following.value.filter((t) => t[1] !== pubkey));
pendingDraft.next(getDraftEvent());
}
}
const followingService = {
following: following,
isFollowing,
savingDraft,
savePendingDraft,
addContact,
removeContact,
};
if (import.meta.env.DEV) {
// @ts-ignore
window.followingService = followingService;
}
export default followingService;

View File

@@ -16,7 +16,7 @@ import {
import { Outlet, useLoaderData, useMatches, useNavigate } from "react-router-dom";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { UserAvatar } from "../../components/user-avatar";
import { getUserDisplayName } from "../../helpers/user-metadata";
import { fixWebsiteUrl, getUserDisplayName } from "../../helpers/user-metadata";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { UserProfileMenu } from "./components/user-profile-menu";
import { LinkIcon } from "@chakra-ui/icons";
@@ -27,6 +27,7 @@ import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
import { KeyIcon, SettingsIcon } from "../../components/icons";
import { CopyIconButton } from "../../components/copy-icon-button";
import identity from "../../services/identity";
import { UserFollowButton } from "../../components/user-follow-button";
const tabs = [
{ label: "Notes", path: "notes" },
@@ -72,7 +73,7 @@ const UserView = () => {
{metadata?.website && (
<Text>
<LinkIcon />{" "}
<Link href={metadata.website} target="_blank" color="blue.500">
<Link href={fixWebsiteUrl(metadata.website)} target="_blank" color="blue.500">
{metadata.website}
</Link>
</Text>
@@ -91,11 +92,7 @@ const UserView = () => {
onClick={() => navigate("/settings")}
/>
)}
{!isSelf && (
<Button colorScheme="brand" size="sm">
Follow
</Button>
)}
{!isSelf && <UserFollowButton pubkey={pubkey} size="sm" />}
</Flex>
</Flex>
</Flex>