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] NIP-05 support
- [x] Broadcast events - [x] Broadcast events
- [x] User tipping - [x] User tipping
- [ ] Manage followers ( Contact List ) - [x] Manage followers ( Contact List )
- [ ] Profile management - [ ] Profile management
- [ ] Relay management - [ ] Relay management
- [ ] Image upload - [ ] Image upload
@@ -24,7 +24,7 @@
## Supported NIPs ## 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-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 - [ ] [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 - [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 `client` tag to published events
- add button for creating lightning invoice via WebLN - 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 a valid web share target https://developer.chrome.com/articles/web-share-target/
- make app handle image files - make app handle image files
- block notes based on content - block notes based on content

View File

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

View File

@@ -3,8 +3,8 @@ import { Link } from "react-router-dom";
import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip-19"; import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip-19";
import { getUserDisplayName } from "../helpers/user-metadata"; import { getUserDisplayName } from "../helpers/user-metadata";
import useSubject from "../hooks/use-subject"; import useSubject from "../hooks/use-subject";
import { useUserContacts } from "../hooks/use-user-contacts";
import { useUserMetadata } from "../hooks/use-user-metadata"; import { useUserMetadata } from "../hooks/use-user-metadata";
import followingService from "../services/following";
import identity from "../services/identity"; import identity from "../services/identity";
import { UserAvatar } from "./user-avatar"; import { UserAvatar } from "./user-avatar";
@@ -29,15 +29,15 @@ const FollowingListItem = ({ pubkey }: { pubkey: string }) => {
export const FollowingList = () => { export const FollowingList = () => {
const pubkey = useSubject(identity.pubkey); const pubkey = useSubject(identity.pubkey);
const contacts = useUserContacts(pubkey); const following = useSubject(followingService.following);
if (!contacts) return <SkeletonText />; if (!following) return <SkeletonText />;
return ( return (
<Box overflow="auto" pr="2" pb="4" pt="2"> <Box overflow="auto" pr="2" pb="4" pt="2">
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
{contacts.contacts.map((contact) => ( {following.map((pTag) => (
<FollowingListItem key={contact} pubkey={contact} /> <FollowingListItem key={pTag[1]} pubkey={pTag[1]} />
))} ))}
</Flex> </Flex>
</Box> </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, regexp: /(https?:\/\/)([\da-z\.-]+\.[a-z\.]{2,6})([\/\w\.-]+\.(svg|gif|png|jpg|jpeg|webp|avif))[^\s]*/im,
render: (match, event, trusted) => { render: (match, event, trusted) => {
const ImageComponent = trusted || !settings.blurImages.value ? Image : BlurredImage; 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", name: "Image",
isMedia: true, isMedia: true,
@@ -175,11 +175,7 @@ const embeds: EmbedType[] = [
// Video // Video
{ {
regexp: /(https?:\/\/)([\da-z\.-]+\.[a-z\.]{2,6})([\/\w\.-]+\.(mp4|mkv|webm|mov))[^\s]*/im, regexp: /(https?:\/\/)([\da-z\.-]+\.[a-z\.]{2,6})([\/\w\.-]+\.(mp4|mkv|webm|mov))[^\s]*/im,
render: (match) => ( render: (match) => <video src={match[0]} controls style={{ maxWidth: "30rem" }} />,
<AspectRatio ratio={16 / 9} maxWidth="30rem">
<video src={match[0]} controls />
</AspectRatio>
),
name: "Video", name: "Video",
isMedia: true, isMedia: true,
}, },

View File

@@ -17,10 +17,21 @@ import { UserAvatarLink } from "./user-avatar-link";
const MobileProfileHeader = () => { const MobileProfileHeader = () => {
const pubkey = useSubject(identity.pubkey); const pubkey = useSubject(identity.pubkey);
const readonly = useReadonlyMode();
return ( return (
<Flex justifyContent="space-between" padding="2"> <Flex justifyContent="space-between" padding="2" alignItems="center">
<UserAvatarLink pubkey={pubkey} size="sm" /> <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"> <Flex gap="2">
<ConnectedRelays /> <ConnectedRelays />
<IconButton <IconButton
@@ -86,7 +97,7 @@ const DesktopSideNav = () => {
Logout Logout
</Button> </Button>
{readonly && ( {readonly && (
<Text color="yellow.500" textAlign="center"> <Text color="red.200" textAlign="center">
Readonly Mode Readonly Mode
</Text> </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); 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 { Outlet, useLoaderData, useMatches, useNavigate } from "react-router-dom";
import { useUserMetadata } from "../../hooks/use-user-metadata"; import { useUserMetadata } from "../../hooks/use-user-metadata";
import { UserAvatar } from "../../components/user-avatar"; 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 { useIsMobile } from "../../hooks/use-is-mobile";
import { UserProfileMenu } from "./components/user-profile-menu"; import { UserProfileMenu } from "./components/user-profile-menu";
import { LinkIcon } from "@chakra-ui/icons"; import { LinkIcon } from "@chakra-ui/icons";
@@ -27,6 +27,7 @@ import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
import { KeyIcon, SettingsIcon } from "../../components/icons"; import { KeyIcon, SettingsIcon } from "../../components/icons";
import { CopyIconButton } from "../../components/copy-icon-button"; import { CopyIconButton } from "../../components/copy-icon-button";
import identity from "../../services/identity"; import identity from "../../services/identity";
import { UserFollowButton } from "../../components/user-follow-button";
const tabs = [ const tabs = [
{ label: "Notes", path: "notes" }, { label: "Notes", path: "notes" },
@@ -72,7 +73,7 @@ const UserView = () => {
{metadata?.website && ( {metadata?.website && (
<Text> <Text>
<LinkIcon />{" "} <LinkIcon />{" "}
<Link href={metadata.website} target="_blank" color="blue.500"> <Link href={fixWebsiteUrl(metadata.website)} target="_blank" color="blue.500">
{metadata.website} {metadata.website}
</Link> </Link>
</Text> </Text>
@@ -91,11 +92,7 @@ const UserView = () => {
onClick={() => navigate("/settings")} onClick={() => navigate("/settings")}
/> />
)} )}
{!isSelf && ( {!isSelf && <UserFollowButton pubkey={pubkey} size="sm" />}
<Button colorScheme="brand" size="sm">
Follow
</Button>
)}
</Flex> </Flex>
</Flex> </Flex>
</Flex> </Flex>