mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-25 11:13:30 +02:00
add following service
This commit is contained in:
@@ -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
|
||||
|
@@ -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>
|
||||
))}
|
||||
|
@@ -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>
|
||||
|
@@ -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,
|
||||
},
|
||||
|
@@ -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>
|
||||
)}
|
||||
|
31
src/components/user-follow-button.tsx
Normal file
31
src/components/user-follow-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
@@ -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
106
src/services/following.ts
Normal 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;
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user