mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-29 21:13:37 +02:00
add following service
This commit is contained in:
@@ -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
|
||||||
|
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
@@ -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>
|
||||||
|
@@ -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,
|
||||||
},
|
},
|
||||||
|
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
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);
|
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 { 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>
|
||||||
|
Reference in New Issue
Block a user