Nostr Messenger PT4

This commit is contained in:
Dustin Brett 2023-09-07 22:08:28 -07:00
parent 682cc7ebbd
commit 9c6b381dc1
13 changed files with 250 additions and 62 deletions

View File

@ -95,6 +95,7 @@
}
}
],
"jsx-a11y/no-autofocus": "off",
"no-console": ["error", { "allow": ["info", "error"] }],
"no-constant-binary-expression": "error",
"no-implicit-coercion": "error",

View File

@ -1,13 +1,14 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import {
decryptMessage,
shortTimeStamp,
} from "components/apps/Messenger/functions";
import { MILLISECONDS_IN_MINUTE } from "utils/constants";
import { type Event } from "nostr-tools";
import { nip19, type Event } from "nostr-tools";
import { useNostrProfile } from "components/apps/Messenger/hooks";
import { Avatar } from "components/apps/Messenger/Icons";
import Button from "styles/common/Button";
import { useMenu } from "contexts/menu";
type ContactProps = {
lastEvent: Event;
@ -34,6 +35,18 @@ const Contact: FC<ContactProps> = ({
const [timeStamp, setTimeStamp] = useState("");
const { picture, userName } = useNostrProfile(pubkey);
const unreadClass = unreadEvent ? "unread" : undefined;
const { contextMenu } = useMenu();
const { onContextMenuCapture } = useMemo(
() =>
contextMenu?.(() => [
{
action: () =>
navigator.clipboard?.writeText(nip19.npubEncode(pubkey)),
label: "Copy npub address",
},
]),
[contextMenu, pubkey]
);
useEffect(() => {
if (content) {
@ -57,7 +70,7 @@ const Contact: FC<ContactProps> = ({
}, [created_at, lastEvent]);
return (
<li className={unreadClass}>
<li className={unreadClass} onContextMenuCapture={onContextMenuCapture}>
<Button onClick={onClick}>
<figure>
{picture ? <img alt={userName} src={picture} /> : <Avatar />}

View File

@ -26,3 +26,18 @@ export const Send = memo(() => (
<path d="m16.692 12.474-13.186.786c-.314 0-.47.157-.47.314l-1.884 6.441c-.314.786-.162 1.875.627 2.505.631.47 1.727.58 2.355.323l17.58-8.798c.942-.47 1.413-1.413 1.256-2.356a2.496 2.496 0 0 0-1.255-1.571L4.134 1.163c-.785-.263-1.727-.157-2.355.315-.784.628-.941 1.57-.627 2.513l1.883 6.441c0 .157.314.314.471.314l13.186.786s.47 0 .47.471-.47.471-.47.471Z" />
</svg>
));
export const Back = memo(() => (
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<path
d="M244 400 100 256l144-144M120 256h292"
style={{
fill: "none",
stroke: "currentColor",
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: "50px",
}}
/>
</svg>
));

View File

@ -1,9 +1,10 @@
import { useNostrProfile } from "components/apps/Messenger/hooks";
import { useMemo } from "react";
import Button from "styles/common/Button";
import { Back } from "components/apps/FileExplorer/NavigationIcons";
import StyledProfileBanner from "components/apps/Messenger/StyledProfileBanner";
import { Avatar, Write } from "components/apps/Messenger/Icons";
import { Avatar, Back, Write } from "components/apps/Messenger/Icons";
import { UNKNOWN_PUBLIC_KEY } from "components/apps/Messenger/constants";
import { haltEvent } from "utils/functions";
const GRADIENT = "linear-gradient(rgba(0, 0, 0, 0.10), rgba(0, 0, 0, 0.5))";
const STYLING =
@ -22,8 +23,14 @@ const ProfileBanner: FC<ProfileBannerProps> = ({
selectedRecipientKey,
publicKey,
}) => {
const { banner, picture, userName } = useNostrProfile(
selectedRecipientKey || publicKey
const {
banner,
picture,
userName = "...",
} = useNostrProfile(
selectedRecipientKey === UNKNOWN_PUBLIC_KEY
? ""
: selectedRecipientKey || publicKey
);
const style = useMemo(
() =>
@ -32,16 +39,10 @@ const ProfileBanner: FC<ProfileBannerProps> = ({
);
return (
<StyledProfileBanner style={style}>
{selectedRecipientKey ? (
<Button onClick={goHome}>
<Back />
</Button>
) : (
<Button className="write" onClick={newChat}>
<Write />
</Button>
)}
<StyledProfileBanner onContextMenuCapture={haltEvent} style={style}>
<Button onClick={selectedRecipientKey ? goHome : newChat}>
{selectedRecipientKey ? <Back /> : <Write />}
</Button>
<figure>
{picture ? <img alt={userName} src={picture} /> : <Avatar />}
<figcaption>{userName}</figcaption>

View File

@ -4,6 +4,8 @@ import Button from "styles/common/Button";
import { useNostr } from "nostr-react";
import { createMessageEvent } from "components/apps/Messenger/functions";
import { Send } from "components/apps/Messenger/Icons";
import { haltEvent } from "utils/functions";
import { UNKNOWN_PUBLIC_KEY } from "./constants";
type SendMessageProps = { publicKey: string; recipientPublicKey: string };
@ -13,6 +15,7 @@ const SendMessage: FC<SendMessageProps> = ({
}) => {
const { publish } = useNostr();
const inputRef = useRef<HTMLInputElement>(null);
const isUnknownKey = recipientPublicKey === UNKNOWN_PUBLIC_KEY;
const sendMessage = useCallback(async () => {
const message = inputRef.current?.value;
@ -27,13 +30,19 @@ const SendMessage: FC<SendMessageProps> = ({
<StyledSendMessage>
<input
ref={inputRef}
disabled={isUnknownKey}
onKeyDown={({ key }) => {
if (key === "Enter") sendMessage();
}}
placeholder="Type a message..."
type="text"
autoFocus
/>
<Button onClick={sendMessage}>
<Button
disabled={isUnknownKey}
onClick={sendMessage}
onContextMenuCapture={haltEvent}
>
<Send />
</Button>
</StyledSendMessage>

View File

@ -11,10 +11,15 @@ const StyledContacts = styled.ol`
li {
border-radius: 10px;
color: #fff;
cursor: pointer;
margin: 8px;
padding: 8px;
position: relative;
button {
cursor: pointer;
}
&:hover {
background-color: #3a3b3c;
}
@ -25,6 +30,7 @@ const StyledContacts = styled.ol`
}
figure {
cursor: pointer;
display: flex;
gap: 12px;
width: calc(100% - 15px);
@ -38,10 +44,12 @@ const StyledContacts = styled.ol`
max-width: 56px;
min-height: 56px;
min-width: 56px;
pointer-events: none;
width: 56px;
}
figcaption {
cursor: pointer;
display: flex;
flex-direction: column;
gap: 3px;
@ -51,18 +59,21 @@ const StyledContacts = styled.ol`
> span {
color: #e4e6eb;
cursor: pointer;
font-size: 17px;
font-weight: 600;
}
> div {
color: #b0b3b8;
cursor: pointer;
display: flex;
font-size: 14px;
gap: 3px;
width: 100%;
div:first-child {
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -75,6 +86,7 @@ const StyledContacts = styled.ol`
div:last-child {
color: #8b8d92;
cursor: pointer;
padding-right: 10px;
}
}

View File

@ -1,6 +1,7 @@
import styled from "styled-components";
const StyledProfileBanner = styled.div`
background: linear-gradient(rgba(255, 255, 255, 10%), rgba(0, 0, 0, 50%));
border-bottom: 1px solid rgb(57, 58, 59);
color: #fff;
display: flex;
@ -31,27 +32,28 @@ const StyledProfileBanner = styled.div`
min-width: 38px;
width: 38px;
}
figcaption {
padding-top: 1px;
}
}
button {
height: 24px;
width: 24px;
cursor: pointer;
height: 30px;
padding-top: 3px;
width: 30px;
svg:first-child {
background-color: rgb(0, 0, 0, 50%);
border-radius: 5px;
color: #fff;
fill: #fff;
height: 24px;
outline: 4px solid rgb(0, 0, 0, 50%);
pointer-events: none;
width: 24px;
}
&.write {
height: 30px;
width: 30px;
svg {
height: 30px;
width: 30px;
}
}
}
`;

View File

@ -0,0 +1,13 @@
import styled from "styled-components";
const StyledTo = styled.div`
input {
background-color: #242526;
border-bottom: 1px solid rgb(57, 58, 59);
color: #fff;
padding: 15px;
width: 100%;
}
`;
export default StyledTo;

View File

@ -0,0 +1,25 @@
import StyledTo from "components/apps/Messenger/StyledTo";
type ToProps = { setRecipientKey: (key: string) => boolean };
const To: FC<ToProps> = ({ setRecipientKey }) => (
<StyledTo>
<input
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!setRecipientKey(event.currentTarget.value)
) {
// eslint-disable-next-line no-param-reassign
event.currentTarget.value = "";
}
}}
placeholder="Type a Nostr address (npub/nprofile/hex)"
spellCheck={false}
type="text"
autoFocus
/>
</StyledTo>
);
export default To;

View File

@ -1,4 +1,10 @@
export const BASE_RW_RELAYS = ["wss://nos.lol/"];
export const BASE_RW_RELAYS = [
"wss://relayable.org",
"wss://ca.relayable.org",
"wss://la.relayable.org",
"wss://au.relayable.org",
"wss://he.relayable.org",
];
export const DM_KIND = 4;
@ -6,3 +12,5 @@ export const PRIVATE_KEY_IDB_NAME = "nostr_private_key";
export const PUBLIC_KEY_IDB_NAME = "nostr_public_key";
export const NOTIFICATION_SOUND = "/Program Files/Messenger/notification.mp3";
export const UNKNOWN_PUBLIC_KEY = "?";

View File

@ -20,6 +20,7 @@ import {
} from "components/apps/Messenger/constants";
import { MILLISECONDS_IN_SECOND } from "utils/constants";
import { dateToUnix } from "nostr-react";
import type { ProfilePointer } from "nostr-tools/lib/nip19";
export const getRelayUrls = async (
publicKey: string,
@ -41,9 +42,13 @@ export const getRelayUrls = async (
export const toHexKey = (key: string): string => {
if (key.startsWith("npub") || key.startsWith("nsec")) {
const { data } = nip19.decode(key);
try {
const { data } = nip19.decode(key);
if (typeof data === "string") return data;
if (typeof data === "string") return data;
} catch {
return key;
}
}
return key;
@ -202,7 +207,34 @@ export const dataToProfile = (
display_name ||
name ||
username ||
(npub || nip19.npubEncode(publicKey)).slice(0, 12),
(
npub ||
(publicKey.startsWith("npub") ? publicKey : nip19.npubEncode(publicKey))
).slice(0, 12),
website,
};
};
export const getPublicHexFromNostrAddress = (key: string): string => {
const nprofile = key.startsWith("nprofile");
const nsec = key.startsWith("nsec");
if (nprofile || nsec || key.startsWith("npub")) {
try {
const { data } = nip19.decode(key) || {};
const hex = nprofile
? (data as ProfilePointer)?.pubkey
: (data as string);
return nsec ? getPublicKey(hex) : hex;
} catch {
return "";
}
}
try {
return toHexKey(nip19.npubEncode(key));
} catch {
return "";
}
};

View File

@ -31,17 +31,16 @@ export const useNostrProfile = (publicKey: string): NostrProfile => {
);
const [profile, setProfile] = useState<NostrProfile>({} as NostrProfile);
const { onEvent } = useNostrEvents({
enabled: !cachedProfile,
enabled: !cachedProfile && !!publicKey,
filter: {
authors: [publicKey],
kinds: [0],
},
});
useEffect(
() => setProfile(cachedProfile || dataToProfile(publicKey)),
[cachedProfile, publicKey]
);
useEffect(() => {
setProfile(publicKey ? cachedProfile || dataToProfile(publicKey) : {});
}, [cachedProfile, publicKey]);
onEvent(({ content }) => {
try {

View File

@ -1,7 +1,10 @@
import type { ComponentProcessProps } from "components/system/Apps/RenderComponent";
import { NostrProvider } from "nostr-react";
import { useEffect, useState } from "react";
import { getRelayUrls } from "components/apps/Messenger/functions";
import { useCallback, useEffect, useRef, useState } from "react";
import {
getRelayUrls,
getPublicHexFromNostrAddress,
} from "components/apps/Messenger/functions";
import StyledMessenger from "components/apps/Messenger/StyledMessenger";
import Contact from "components/apps/Messenger/Contact";
import SendMessage from "components/apps/Messenger/SendMessage";
@ -14,35 +17,92 @@ import {
import StyledContacts from "components/apps/Messenger/StyledContacts";
import ProfileBanner from "components/apps/Messenger/ProfileBanner";
import ChatLog from "components/apps/Messenger/ChatLog";
import To from "components/apps/Messenger/To";
import { UNKNOWN_PUBLIC_KEY } from "components/apps/Messenger/constants";
import type { Event } from "nostr-tools";
import { haltEvent } from "utils/functions";
const NostrChat: FC<{
id: string;
type NostrChatProps = {
loginTime: number;
processId: string;
publicKey: string;
wellKnownNames: Record<string, string>;
}> = ({ id, loginTime, publicKey, wellKnownNames }) => {
};
const NostrChat: FC<NostrChatProps> = ({
processId,
loginTime,
publicKey,
wellKnownNames,
}) => {
const [seenEventIds, setSeenEventIds] = useState<string[]>([]);
const [selectedRecipientKey, setSelectedRecipientKey] = useState<string>("");
const changeRecipient = useCallback(
(recipientKey: string, currentEvents?: Event[]) =>
setSelectedRecipientKey((currenRecipientKey: string) => {
if ((currenRecipientKey || recipientKey) && currentEvents) {
setSeenEventIds((currentSeenEventIds) => [
...new Set([
...currentEvents
.filter(
({ created_at, pubkey }) =>
[recipientKey, currenRecipientKey].includes(pubkey) &&
created_at > loginTime
)
.map(({ id }) => id),
...currentSeenEventIds,
]),
]);
}
return recipientKey;
}),
[loginTime]
);
const { contactKeys, events, lastEvents, unreadEvents } = useNostrContacts(
publicKey,
wellKnownNames,
loginTime,
seenEventIds
);
const setRecipientKey = useCallback(
(recipientKey: string): boolean => {
const hexKey = getPublicHexFromNostrAddress(recipientKey);
useUnreadStatus(id, unreadEvents.length);
if (hexKey) changeRecipient(hexKey);
return Boolean(hexKey);
},
[changeRecipient]
);
useUnreadStatus(processId, unreadEvents.length);
useEffect(() => {
if (unreadEvents && selectedRecipientKey) {
unreadEvents
.filter(({ pubkey }) => pubkey === selectedRecipientKey)
.forEach(({ id }) =>
setSeenEventIds((currentSeenEventIds) => [
...new Set([id, ...currentSeenEventIds]),
])
);
}
}, [selectedRecipientKey, unreadEvents]);
return (
<StyledMessenger>
<ProfileBanner
goHome={() => setSelectedRecipientKey("")}
// TODO: Show chat with "To: ..."
newChat={() => setSelectedRecipientKey("")}
goHome={() => changeRecipient("", events)}
newChat={() => changeRecipient(UNKNOWN_PUBLIC_KEY)}
publicKey={publicKey}
selectedRecipientKey={selectedRecipientKey}
/>
{selectedRecipientKey ? (
<>
{selectedRecipientKey === UNKNOWN_PUBLIC_KEY && (
<To setRecipientKey={setRecipientKey} />
)}
<ChatLog
events={events}
publicKey={publicKey}
@ -54,20 +114,15 @@ const NostrChat: FC<{
/>
</>
) : (
<StyledContacts>
{contactKeys.map((pubkey) => (
<StyledContacts onContextMenu={haltEvent}>
{contactKeys.map((contactKey) => (
<Contact
key={pubkey}
lastEvent={lastEvents[pubkey]}
onClick={() => {
setSeenEventIds((currentSeenEventIds) => [
...new Set([lastEvents[pubkey]?.id, ...currentSeenEventIds]),
]);
setSelectedRecipientKey(pubkey);
}}
pubkey={pubkey}
key={contactKey}
lastEvent={lastEvents[contactKey]}
onClick={() => changeRecipient(contactKey, events)}
pubkey={contactKey}
publicKey={publicKey}
unreadEvent={unreadEvents.includes(lastEvents[pubkey])}
unreadEvent={unreadEvents.includes(lastEvents[contactKey])}
/>
))}
</StyledContacts>
@ -79,11 +134,14 @@ const NostrChat: FC<{
const Messenger: FC<ComponentProcessProps> = ({ id }) => {
const [loginTime, setLoginTime] = useState<number>(0);
const [relayUrls, setRelayUrls] = useState<string[] | undefined>();
const initStarted = useRef(false);
const { names, relays } = useNip05();
const publicKey = usePublicKey();
useEffect(() => {
if (!publicKey || !relays) return;
if (initStarted.current || !publicKey || !relays) return;
initStarted.current = true;
getRelayUrls(publicKey, relays).then((foundRelays) => {
setRelayUrls(foundRelays);
@ -94,8 +152,8 @@ const Messenger: FC<ComponentProcessProps> = ({ id }) => {
return publicKey && relayUrls ? (
<NostrProvider relayUrls={relayUrls}>
<NostrChat
id={id}
loginTime={loginTime}
processId={id}
publicKey={publicKey}
wellKnownNames={names}
/>