diff --git a/README.md b/README.md
index eead89c71..97b88096d 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/components/connected-relays.tsx b/src/components/connected-relays.tsx
index 674f05eca..504783782 100644
--- a/src/components/connected-relays.tsx
+++ b/src/components/connected-relays.tsx
@@ -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 = () => {
{relays.map((relay) => (
-
+
{relay.url}
))}
diff --git a/src/components/following-list.tsx b/src/components/following-list.tsx
index 674218316..c599754df 100644
--- a/src/components/following-list.tsx
+++ b/src/components/following-list.tsx
@@ -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 ;
+ if (!following) return ;
return (
- {contacts.contacts.map((contact) => (
-
+ {following.map((pTag) => (
+
))}
diff --git a/src/components/note/note-contents.tsx b/src/components/note/note-contents.tsx
index d888291fc..2c476b457 100644
--- a/src/components/note/note-contents.tsx
+++ b/src/components/note/note-contents.tsx
@@ -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 ;
+ return ;
},
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) => (
-
-
-
- ),
+ render: (match) => ,
name: "Video",
isMedia: true,
},
diff --git a/src/components/page.tsx b/src/components/page.tsx
index e2b0d892d..5891956b2 100644
--- a/src/components/page.tsx
+++ b/src/components/page.tsx
@@ -17,10 +17,21 @@ import { UserAvatarLink } from "./user-avatar-link";
const MobileProfileHeader = () => {
const pubkey = useSubject(identity.pubkey);
+ const readonly = useReadonlyMode();
return (
-
+
+ {readonly && (
+
+ )}
{
Logout
{readonly && (
-
+
Readonly Mode
)}
diff --git a/src/components/user-follow-button.tsx b/src/components/user-follow-button.tsx
new file mode 100644
index 000000000..b4f966a8f
--- /dev/null
+++ b/src/components/user-follow-button.tsx
@@ -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) => {
+ 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 (
+
+ );
+};
diff --git a/src/helpers/user-metadata.ts b/src/helpers/user-metadata.ts
index 1ade6e2b0..0ff7d7531 100644
--- a/src/helpers/user-metadata.ts
+++ b/src/helpers/user-metadata.ts
@@ -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;
+}
diff --git a/src/services/following.ts b/src/services/following.ts
new file mode 100644
index 000000000..f3130c0fd
--- /dev/null
+++ b/src/services/following.ts
@@ -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;
+
+const following = new BehaviorSubject([]);
+// const relays = new BehaviorSubject({});
+const pendingDraft = new BehaviorSubject(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;
diff --git a/src/views/user/index.tsx b/src/views/user/index.tsx
index 84a0fe079..8f50c370f 100644
--- a/src/views/user/index.tsx
+++ b/src/views/user/index.tsx
@@ -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 && (
{" "}
-
+
{metadata.website}
@@ -91,11 +92,7 @@ const UserView = () => {
onClick={() => navigate("/settings")}
/>
)}
- {!isSelf && (
-
- )}
+ {!isSelf && }