fully support nip05

This commit is contained in:
Ren Amamiya
2023-06-30 16:36:03 +07:00
parent 1ba7f823cb
commit 332dbf608d
17 changed files with 250 additions and 245 deletions

View File

@@ -20,7 +20,7 @@ export function ChatMessageForm({
const tags = [["p", receiverPubkey]];
// publish message
publish({ content: message, kind: 4, tags });
await publish({ content: message, kind: 4, tags });
// reset state
setValue("");

View File

@@ -1,21 +1,17 @@
import { User } from "@app/auth/components/user";
import { Dialog, Transition } from "@headlessui/react";
import { getPlebs } from "@libs/storage";
import { CancelIcon, PlusIcon } from "@shared/icons";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useQuery } from "@tanstack/react-query";
import { nip19 } from "nostr-tools";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useState } from "react";
import { useNavigate } from "react-router-dom";
export function NewMessageModal() {
const navigate = useNavigate();
const { status, data }: any = useQuery(["plebs"], async () => {
return await getPlebs();
});
const [isOpen, setIsOpen] = useState(false);
const { status, account } = useAccount();
const follows = account ? JSON.parse(account.follows) : [];
const closeModal = () => {
setIsOpen(false);
};
@@ -24,8 +20,7 @@ export function NewMessageModal() {
setIsOpen(true);
};
const openChat = (npub: string) => {
const pubkey = nip19.decode(npub).data;
const openChat = (pubkey: string) => {
closeModal();
navigate(`/app/chat/${pubkey}`);
};
@@ -99,31 +94,16 @@ export function NewMessageModal() {
{status === "loading" ? (
<p>Loading...</p>
) : (
data.map((pleb) => (
follows.map((follow) => (
<div
key={pleb.npub}
key={follow}
className="group flex items-center justify-between px-4 py-3 hover:bg-zinc-800"
>
<div className="flex items-center gap-2">
<img
alt={pleb.npub}
src={pleb.image || DEFAULT_AVATAR}
className="w-9 h-9 shrink-0 object-cover rounded"
/>
<div className="inline-flex flex-col gap-1">
<h3 className="leading-none max-w-[15rem] line-clamp-1 font-medium text-zinc-100">
{pleb.displayName || pleb.name}
</h3>
<span className="leading-none max-w-[10rem] line-clamp-1 text-sm text-zinc-400">
{pleb.nip05 ||
pleb.npub.substring(0, 16).concat("...")}
</span>
</div>
</div>
<User pubkey={follow} />
<div>
<button
type="button"
onClick={() => openChat(pleb.npub)}
onClick={() => openChat(follow)}
className="inline-flex text-sm w-max px-3 py-1.5 rounded border-t border-zinc-600/50 bg-zinc-700 hover:bg-fuchsia-500 transform translate-x-20 group-hover:translate-x-0 transition-transform ease-in-out duration-150"
>
Chat

View File

@@ -2,18 +2,11 @@ import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { nip19 } from "nostr-tools";
import { useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";
export function ChatSidebar({ pubkey }: { pubkey: string }) {
const navigate = useNavigate();
const { user } = useProfile(pubkey);
const viewProfile = () => {
const pubkey = nip19.decode(user.npub).data;
navigate(`/app/user/${pubkey}`);
};
return (
<div className="px-3 py-2">
<div className="flex flex-col gap-3">
@@ -36,13 +29,12 @@ export function ChatSidebar({ pubkey }: { pubkey: string }) {
</div>
<div>
<p className="leading-tight">{user?.bio || user?.about}</p>
<button
type="button"
onClick={() => viewProfile()}
<Link
to={`/app/user/${pubkey}`}
className="mt-3 inline-flex w-full h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800 text-sm text-zinc-300 hover:text-zinc-100 font-medium"
>
View full profile
</button>
</Link>
</div>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import {
createNote,
getChannels,
getLastLogin,
updateLastLogin,
} from "@libs/storage";
import { NDKFilter } from "@nostr-dev-kit/ndk";
import { LoaderIcon, LumeIcon } from "@shared/icons";
@@ -142,6 +143,7 @@ export function Root() {
const chats = await fetchChats();
// const channels = await fetchChannelMessages();
if (chats) {
await updateLastLogin(dateToUnix());
navigate("/app/space", { replace: true });
}
}

View File

@@ -11,7 +11,6 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useRef } from "react";
const ITEM_PER_PAGE = 10;
const TIME = Math.floor(Date.now() / 1000);
export function FeedBlock({ params }: { params: any }) {
const queryClient = useQueryClient();
@@ -21,7 +20,6 @@ export function FeedBlock({ params }: { params: any }) {
queryFn: async ({ pageParam = 0 }) => {
return await getNotesByAuthors(
params.content,
TIME,
ITEM_PER_PAGE,
pageParam,
);

View File

@@ -9,7 +9,6 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useRef } from "react";
const ITEM_PER_PAGE = 10;
const TIME = Math.floor(Date.now() / 1000);
export function FollowingBlock({ block }: { block: number }) {
// subscribe for live update
@@ -29,7 +28,7 @@ export function FollowingBlock({ block }: { block: number }) {
}: any = useInfiniteQuery({
queryKey: ["newsfeed-circle"],
queryFn: async ({ pageParam = 0 }) => {
return await getNotes(TIME, ITEM_PER_PAGE, pageParam);
return await getNotes(ITEM_PER_PAGE, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
@@ -64,7 +63,7 @@ export function FollowingBlock({ block }: { block: number }) {
const refreshFirstPage = () => {
// refetch
refetch({ refetchPage: (page, index) => index === 0 });
refetch({ refetchPage: (_, index: number) => index === 0 });
// scroll to top
rowVirtualizer.scrollToIndex(1);
// stop notify

View File

@@ -1,4 +1,3 @@
import { createReplyNote } from "./storage";
import NDK, {
NDKConstructorParams,
NDKEvent,
@@ -15,21 +14,10 @@ export async function initNDK(relays?: string[]): Promise<NDK> {
const opts: NDKConstructorParams = {};
const defaultRelays = new Set(relays || FULL_RELAYS);
/*
for (const relay of defaultRelays) {
const url = new URL(relay);
url.protocol = url.protocol = url.protocol.replace("wss", "https");
const res = await fetch(url.href, { method: "HEAD", timeout: 5 });
if (!res.ok) {
defaultRelays.delete(relay);
}
}
*/
opts.explicitRelayUrls = [...defaultRelays];
const ndk = new NDK(opts);
await ndk.connect();
await ndk.connect(500);
return ndk;
}
@@ -57,15 +45,10 @@ export async function prefetchEvents(
}
export function usePublish() {
const { account } = useAccount();
const ndk = useContext(RelayContext);
const { account } = useAccount();
if (!ndk.signer) {
const signer = new NDKPrivateKeySigner(account?.privkey);
ndk.signer = signer;
}
const publish = ({
const publish = async ({
content,
kind,
tags,
@@ -73,8 +56,9 @@ export function usePublish() {
content: string;
kind: NDKKind;
tags: string[][];
}): NDKEvent => {
}): Promise<NDKEvent> => {
const event = new NDKEvent(ndk);
const signer = new NDKPrivateKeySigner(account.privkey);
event.content = content;
event.kind = kind;
@@ -82,7 +66,8 @@ export function usePublish() {
event.pubkey = account.pubkey;
event.tags = tags;
event.publish();
await event.sign(signer);
await event.publish();
return event;
};

View File

@@ -1,6 +1,4 @@
import { NDKTag, NDKUserProfile } from "@nostr-dev-kit/ndk";
import { getParentID } from "@utils/transform";
import { nip19 } from "nostr-tools";
import Database from "tauri-plugin-sql-api";
let db: null | Database = null;
@@ -73,55 +71,6 @@ export async function updateAccount(
);
}
// get all plebs
export async function getPlebs() {
const db = await connect();
return await db.select("SELECT * FROM plebs ORDER BY created_at DESC;");
}
// get pleb by pubkey
export async function getPleb(npub: string) {
const db = await connect();
const result = await db.select(`SELECT * FROM plebs WHERE npub = "${npub}";`);
if (result) {
return result[0];
} else {
return null;
}
}
// create pleb
export async function createPleb(key: string, data: NDKUserProfile) {
const db = await connect();
const now = Math.floor(Date.now() / 1000);
let npub: string;
if (key.substring(0, 4) === "npub") {
npub = key;
} else {
npub = nip19.npubEncode(key);
}
return await db.execute(
"INSERT OR REPLACE INTO plebs (npub, name, displayName, image, banner, bio, nip05, lud06, lud16, about, zapService, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
[
npub,
data.name,
data.displayName,
data.image,
data.banner,
data.bio,
data.nip05,
data.lud06,
data.lud16,
data.about,
data.zapService,
now,
],
);
}
// count total notes
export async function countTotalChannels() {
const db = await connect();
@@ -139,14 +88,14 @@ export async function countTotalNotes() {
}
// get all notes
export async function getNotes(time: number, limit: number, offset: number) {
export async function getNotes(limit: number, offset: number) {
const db = await connect();
const totalNotes = await countTotalNotes();
const nextCursor = offset + limit;
const notes: any = { data: null, nextCursor: 0 };
const query: any = await db.select(
`SELECT * FROM notes WHERE created_at <= "${time}" AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`,
`SELECT * FROM notes WHERE kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`,
);
notes["data"] = query;
@@ -169,7 +118,6 @@ export async function getNotesByPubkey(pubkey: string) {
// get all notes by authors
export async function getNotesByAuthors(
authors: string,
time: number,
limit: number,
offset: number,
) {
@@ -181,7 +129,7 @@ export async function getNotesByAuthors(
const notes: any = { data: null, nextCursor: 0 };
const query: any = await db.select(
`SELECT * FROM notes WHERE created_at <= "${time}" AND pubkey IN (${finalArray}) AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`,
`SELECT * FROM notes WHERE pubkey IN (${finalArray}) AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`,
);
notes["data"] = query;

View File

@@ -97,7 +97,7 @@ export function Post() {
const refID = getRef();
const submit = () => {
const submit = async () => {
let tags: string[][] = [];
let kind: number;
@@ -130,7 +130,7 @@ export function Post() {
const serializedContent = serialize(content);
// publish message
publish({ content: serializedContent, kind, tags });
await publish({ content: serializedContent, kind, tags });
// close modal
toggle(false);

View File

@@ -1,38 +1,51 @@
import { Dialog, Transition } from "@headlessui/react";
import { usePublish } from "@libs/ndk";
import { getPleb } from "@libs/storage";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { AvatarUploader } from "@shared/avatarUploader";
import { BannerUploader } from "@shared/bannerUploader";
import { CancelIcon, LoaderIcon } from "@shared/icons";
import {
CancelIcon,
CheckCircleIcon,
LoaderIcon,
UnverifiedIcon,
} from "@shared/icons";
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useQueryClient } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/api/http";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
export function EditProfileModal() {
const queryClient = useQueryClient();
const publish = usePublish();
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState(DEFAULT_AVATAR);
const [banner, setBanner] = useState(null);
const [banner, setBanner] = useState("");
const [nip05, setNIP05] = useState({ verified: false, text: "" });
const { account } = useAccount();
const {
register,
handleSubmit,
reset,
formState: { isValid },
setError,
formState: { isValid, errors },
} = useForm({
defaultValues: async () => {
const res = await getPleb(account.npub);
if (res.picture) {
const res: any = queryClient.getQueryData(["user", account.pubkey]);
if (res.image) {
setPicture(res.image);
}
if (res.banner) {
setBanner(res.banner);
}
if (res.nip05) {
setNIP05((prev) => ({ ...prev, text: res.nip05 }));
}
return res;
},
});
@@ -45,24 +58,72 @@ export function EditProfileModal() {
setIsOpen(true);
};
const onSubmit = (data: any) => {
const verifyNIP05 = async (data: string) => {
if (data) {
const url = data.split("@");
const username = url[0];
const service = url[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${username}`;
const res: any = await fetch(verifyURL, {
method: "GET",
timeout: 30,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
});
if (!res.ok) return false;
if (res.data.names[username] === account.pubkey) {
setNIP05((prev) => ({ ...prev, verified: true }));
return true;
} else {
return false;
}
}
};
const onSubmit = async (data: any) => {
// start loading
setLoading(true);
// publish
const event = publish({
content: JSON.stringify({
...data,
display_name: data.name,
bio: data.about,
image: data.picture,
}),
kind: 0,
tags: [],
});
let event: NDKEvent;
if (event) {
const content = {
...data,
username: data.name,
display_name: data.name,
bio: data.about,
image: data.picture,
};
if (data.nip05) {
const verify = await verifyNIP05(data.nip05);
if (verify) {
event = await publish({
content: JSON.stringify({ ...content, nip05: data.nip05 }),
kind: 0,
tags: [],
});
} else {
setNIP05((prev) => ({ ...prev, verified: false }));
setError("nip05", {
type: "manual",
message: "Can't verify your Lume ID / NIP-05, please check again",
});
}
} else {
event = await publish({
content: JSON.stringify(content),
kind: 0,
tags: [],
});
}
if (event.id) {
setTimeout(() => {
// invalid cache
queryClient.invalidateQueries(["user", account.pubkey]);
// reset form
reset();
// reset state
@@ -71,9 +132,17 @@ export function EditProfileModal() {
setPicture(DEFAULT_AVATAR);
setBanner(null);
}, 1200);
} else {
setLoading(false);
}
};
useEffect(() => {
if (!nip05.verified && /\S+@\S+\.\S+/.test(nip05.text)) {
verifyNIP05(nip05.text);
}
}, [nip05.text]);
return (
<>
<button
@@ -179,6 +248,39 @@ export function EditProfileModal() {
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Lume ID / NIP-05
</label>
<div className="relative">
<input
{...register("nip05", {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
<div className="absolute top-1/2 right-2 transform -translate-y-1/2">
{nip05.verified ? (
<span className="inline-flex items-center gap-1 rounded h-6 px-2 bg-green-500 text-sm font-medium">
<CheckCircleIcon className="w-4 h-4 text-white" />
Verified
</span>
) : (
<span className="inline-flex items-center gap-1 rounded h-6 px-2 bg-red-500 text-sm font-medium">
<UnverifiedIcon className="w-4 h-4 text-white" />
Unverified
</span>
)}
</div>
{errors.nip05 && (
<p className="mt-1 text-sm text-red-400">
{errors.nip05.message.toString()}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Bio

View File

@@ -38,4 +38,5 @@ export * from "./empty";
export * from "./cmd";
export * from "./verticalDots";
export * from "./signal";
export * from "./unverified";
// @endindex

View File

@@ -0,0 +1,23 @@
import { SVGProps } from "react";
export function UnverifiedIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12zm7.53-3.53a.75.75 0 00-1.06 1.06L10.94 12l-2.47 2.47a.75.75 0 101.06 1.06L12 13.06l2.47 2.47a.75.75 0 101.06-1.06L13.06 12l2.47-2.47a.75.75 0 00-1.06-1.06L12 10.94 9.53 8.47z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -1,38 +1,28 @@
import { createPleb, getPleb } from "@libs/storage";
import { RelayContext } from "@shared/relayProvider";
import { useQuery } from "@tanstack/react-query";
import { nip19 } from "nostr-tools";
import { useContext } from "react";
export function useProfile(id: string) {
export function useProfile(pubkey: string) {
const ndk = useContext(RelayContext);
const {
status,
data: user,
error,
isFetching,
} = useQuery(["user", id], async () => {
let npub: string;
if (id.substring(0, 4) === "npub") {
npub = id;
} else {
npub = nip19.npubEncode(id);
}
const current = Math.floor(Date.now() / 1000);
const result = await getPleb(npub);
if (result && parseInt(result.created_at) + 86400 >= current) {
return result;
} else {
const user = ndk.getUser({ npub });
} = useQuery(
["user", pubkey],
async () => {
const user = ndk.getUser({ hexpubkey: pubkey });
await user.fetchProfile();
await createPleb(id, user.profile);
return user.profile;
}
});
},
{
staleTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
return { status, user, error, isFetching };
}

View File

@@ -24,6 +24,9 @@ export function useSocial() {
},
{
enabled: account ? true : false,
refetchOnReconnect: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);