diff --git a/packages/app/src/Element/Event/NoteTime.tsx b/packages/app/src/Element/Event/NoteTime.tsx index 87b813fb..893b2852 100644 --- a/packages/app/src/Element/Event/NoteTime.tsx +++ b/packages/app/src/Element/Event/NoteTime.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { useEffect, useState } from "react"; +import { FormattedMessage } from "react-intl"; export interface NoteTimeProps { from: number; @@ -15,8 +15,8 @@ export default function NoteTime(props: NoteTimeProps) { const { from, fallback } = props; const absoluteTime = new Intl.DateTimeFormat(undefined, { - dateStyle: 'medium', - timeStyle: 'long', + dateStyle: "medium", + timeStyle: "long", }).format(from); const isoDate = new Date(from).toISOString(); @@ -35,14 +35,14 @@ export default function NoteTime(props: NoteTimeProps) { } else { if (fromDate.getFullYear() === currentTime.getFullYear()) { return fromDate.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', + month: "short", + day: "numeric", }); } else { return fromDate.toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', + year: "numeric", + month: "short", + day: "numeric", }); } } diff --git a/packages/app/src/Pages/MessagesPage.css b/packages/app/src/Pages/Messages/MessagesPage.css similarity index 100% rename from packages/app/src/Pages/MessagesPage.css rename to packages/app/src/Pages/Messages/MessagesPage.css diff --git a/packages/app/src/Pages/Messages/MessagesPage.tsx b/packages/app/src/Pages/Messages/MessagesPage.tsx new file mode 100644 index 00000000..7d407534 --- /dev/null +++ b/packages/app/src/Pages/Messages/MessagesPage.tsx @@ -0,0 +1,124 @@ +import "./MessagesPage.css"; + +import React, { useEffect, useMemo, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useNavigate, useParams } from "react-router-dom"; + +import UnreadCount from "@/Element/UnreadCount"; +import ProfileImage from "@/Element/User/ProfileImage"; +import { parseId } from "@/SnortUtils"; +import NoteToSelf from "@/Element/User/NoteToSelf"; +import useLogin from "@/Hooks/useLogin"; +import usePageWidth from "@/Hooks/usePageWidth"; +import NoteTime from "@/Element/Event/NoteTime"; +import DmWindow from "@/Element/Chat/DmWindow"; +import { Chat, ChatType, useChatSystem } from "@/chat"; +import { ChatParticipantProfile } from "@/Element/Chat/ChatParticipant"; +import classNames from "classnames"; +import ProfileDmActions from "@/Pages/Messages/ProfileDmActions"; +import NewChatWindow from "@/Pages/Messages/NewChatWindow"; + +const TwoCol = 768; +const ThreeCol = 1500; + +export default function MessagesPage() { + const login = useLogin(); + const { formatMessage } = useIntl(); + const navigate = useNavigate(); + const { id } = useParams(); + const [chat, setChat] = useState(); + const pageWidth = usePageWidth(); + + useEffect(() => { + const parsedId = parseId(id ?? ""); + setChat(id ? parsedId : undefined); + }, [id]); + const chats = useChatSystem(); + + const unreadCount = useMemo(() => chats.reduce((p, c) => p + c.unread, 0), [chats]); + + function openChat(e: React.MouseEvent, type: ChatType, id: string) { + e.stopPropagation(); + e.preventDefault(); + navigate(`/messages/${encodeURIComponent(id)}`); + } + + function noteToSelf(chat: Chat) { + return ( +
openChat(e, chat.type, chat.id)}> + +
+ ); + } + + function conversationIdent(cx: Chat) { + if (cx.participants.length === 1) { + return ; + } else { + return ( +
+ {cx.participants.map(v => ( + + ))} + {cx.title ?? } +
+ ); + } + } + + function conversation(cx: Chat) { + if (!login.publicKey) return null; + const participants = cx.participants.map(a => a.id); + if (participants.length === 1 && participants[0] === login.publicKey) return noteToSelf(cx); + + const isActive = cx.id === chat; + return ( +
openChat(e, cx.type, cx.id)}> + {conversationIdent(cx)} +
+ + + + {cx.unread > 0 && } +
+
+ ); + } + + return ( +
+ {(pageWidth >= TwoCol || !chat) && ( +
+
+ + +
+ {chats + .sort((a, b) => { + const aSelf = a.participants.length === 1 && a.participants[0].id === login.publicKey; + const bSelf = b.participants.length === 1 && b.participants[0].id === login.publicKey; + if (aSelf || bSelf) { + return aSelf ? -1 : 1; + } + return b.lastMessage > a.lastMessage ? 1 : -1; + }) + .map(conversation)} +
+ )} + {chat ? : pageWidth >= TwoCol &&
} + {pageWidth >= ThreeCol && chat && ( +
+ +
+ )} +
+ ); +} diff --git a/packages/app/src/Pages/Messages/NewChatWindow.tsx b/packages/app/src/Pages/Messages/NewChatWindow.tsx new file mode 100644 index 00000000..0ec37574 --- /dev/null +++ b/packages/app/src/Pages/Messages/NewChatWindow.tsx @@ -0,0 +1,145 @@ +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useUserSearch } from "@snort/system-react"; +import useLogin from "@/Hooks/useLogin"; +import useEventPublisher from "@/Hooks/useEventPublisher"; +import { appendDedupe, debounce } from "@/SnortUtils"; +import { ChatType, createChatLink } from "@/chat"; +import Icon from "@/Icons/Icon"; +import Modal from "@/Element/Modal"; +import { FormattedMessage } from "react-intl"; +import ProfileImage from "@/Element/User/ProfileImage"; +import ProfilePreview from "@/Element/User/ProfilePreview"; +import { Nip28ChatSystem } from "@/chat/nip28"; +import { LoginSession, LoginStore } from "@/Login"; +import { decodeTLV, EventKind } from "@snort/system"; +import Nip28ChatProfile from "@/Pages/Messages/Nip28ChatProfile"; + +export default function NewChatWindow() { + const [show, setShow] = useState(false); + const [newChat, setNewChat] = useState>([]); + const [results, setResults] = useState>([]); + const [term, setSearchTerm] = useState(""); + const navigate = useNavigate(); + const search = useUserSearch(); + const login = useLogin(); + const { system, publisher } = useEventPublisher(); + + useEffect(() => { + setNewChat([]); + setSearchTerm(""); + setResults(login.follows.item); + }, [show]); + + useEffect(() => { + return debounce(500, () => { + if (term) { + search(term).then(setResults); + } else { + setResults(login.follows.item); + } + }); + }, [term]); + + function togglePubkey(a: string) { + setNewChat(c => (c.includes(a) ? c.filter(v => v !== a) : appendDedupe(c, [a]))); + } + + function startChat() { + setShow(false); + if (newChat.length === 1) { + navigate(createChatLink(ChatType.DirectMessage, newChat[0])); + } else { + navigate(createChatLink(ChatType.PrivateGroupChat, ...newChat)); + } + } + + return ( + <> + + {show && ( + setShow(false)} className="new-chat-modal"> +
+
+

+ +

+ +
+
+

+ +

+ setSearchTerm(e.target.value)} + /> +
+
+ {newChat.map(a => ( + togglePubkey(a)} + /> + ))} +
+
+

+ +

+
+ {results.map(a => { + return ( + } + onClick={() => togglePubkey(a)} + className={newChat.includes(a) ? "active" : undefined} + /> + ); + })} + {results.length === 1 && ( + { + setShow(false); + const chats = appendDedupe(login.extraChats, [Nip28ChatSystem.chatId(id)]); + LoginStore.updateSession({ + ...login, + extraChats: chats, + } as LoginSession); + const evList = await publisher?.generic(eb => { + eb.kind(EventKind.PublicChatsList); + chats.forEach(c => { + if (c.startsWith("chat281")) { + eb.tag(["e", decodeTLV(c)[0].value as string]); + } + }); + return eb; + }); + if (evList) { + await system.BroadcastEvent(evList); + } + navigate(createChatLink(ChatType.PublicGroupChat, id)); + }} + /> + )} +
+
+
+
+ )} + + ); +} diff --git a/packages/app/src/Pages/Messages/Nip28ChatProfile.tsx b/packages/app/src/Pages/Messages/Nip28ChatProfile.tsx new file mode 100644 index 00000000..a11c4b3f --- /dev/null +++ b/packages/app/src/Pages/Messages/Nip28ChatProfile.tsx @@ -0,0 +1,20 @@ +import { useEventFeed } from "@snort/system-react"; +import { NostrLink, UserMetadata } from "@snort/system"; +import ProfilePreview from "@/Element/User/ProfilePreview"; +import React from "react"; + +export default function Nip28ChatProfile({ id, onClick }: { id: string; onClick: (id: string) => void }) { + const channel = useEventFeed(new NostrLink(CONFIG.eventLinkPrefix, id, 40)); + if (channel?.data) { + const meta = JSON.parse(channel.data.content) as UserMetadata; + return ( + } + onClick={() => onClick(id)} + /> + ); + } +} diff --git a/packages/app/src/Pages/Messages/ProfileDmActions.tsx b/packages/app/src/Pages/Messages/ProfileDmActions.tsx new file mode 100644 index 00000000..48283a7f --- /dev/null +++ b/packages/app/src/Pages/Messages/ProfileDmActions.tsx @@ -0,0 +1,52 @@ +import { decodeTLV, TLVEntryType } from "@snort/system"; +import { useUserProfile } from "@snort/system-react"; +import useModeration from "@/Hooks/useModeration"; +import Avatar from "@/Element/User/Avatar"; +import { getDisplayName } from "@/SnortUtils"; +import Text from "@/Element/Text"; +import Icon from "@/Icons/Icon"; +import { FormattedMessage } from "react-intl"; +import React from "react"; + +export default function ProfileDmActions({ id }: { id: string }) { + const authors = decodeTLV(id) + .filter(a => a.type === TLVEntryType.Author) + .map(a => a.value as string); + const pubkey = authors[0]; + const profile = useUserProfile(pubkey); + const { block, unblock, isBlocked } = useModeration(); + + function truncAbout(s?: string) { + if ((s?.length ?? 0) > 200) { + return `${s?.slice(0, 200)}...`; + } + return s; + } + + const blocked = isBlocked(pubkey); + return ( + <> + +

{getDisplayName(profile, pubkey)}

+

+ +

+ +
(blocked ? unblock(pubkey) : block(pubkey))}> + + {blocked ? ( + + ) : ( + + )} +
+ + ); +} diff --git a/packages/app/src/Pages/MessagesPage.tsx b/packages/app/src/Pages/MessagesPage.tsx deleted file mode 100644 index 0ca77ad7..00000000 --- a/packages/app/src/Pages/MessagesPage.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import "./MessagesPage.css"; - -import React, { useEffect, useMemo, useState } from "react"; -import { FormattedMessage, useIntl } from "react-intl"; -import { useNavigate, useParams } from "react-router-dom"; -import { EventKind, NostrLink, TLVEntryType, UserMetadata, decodeTLV } from "@snort/system"; -import { useEventFeed, useUserProfile, useUserSearch } from "@snort/system-react"; - -import UnreadCount from "@/Element/UnreadCount"; -import ProfileImage from "@/Element/User/ProfileImage"; -import { appendDedupe, debounce, parseId, getDisplayName } from "@/SnortUtils"; -import NoteToSelf from "@/Element/User/NoteToSelf"; -import useModeration from "@/Hooks/useModeration"; -import useLogin from "@/Hooks/useLogin"; -import usePageWidth from "@/Hooks/usePageWidth"; -import NoteTime from "@/Element/Event/NoteTime"; -import DmWindow from "@/Element/Chat/DmWindow"; -import Avatar from "@/Element/User/Avatar"; -import Icon from "@/Icons/Icon"; -import Text from "@/Element/Text"; -import { Chat, ChatType, createChatLink, useChatSystem } from "@/chat"; -import Modal from "@/Element/Modal"; -import ProfilePreview from "@/Element/User/ProfilePreview"; -import { LoginSession, LoginStore } from "@/Login"; -import { Nip28ChatSystem } from "@/chat/nip28"; -import { ChatParticipantProfile } from "@/Element/Chat/ChatParticipant"; -import classNames from "classnames"; -import useEventPublisher from "@/Hooks/useEventPublisher"; - -const TwoCol = 768; -const ThreeCol = 1500; - -export default function MessagesPage() { - const login = useLogin(); - const { formatMessage } = useIntl(); - const navigate = useNavigate(); - const { id } = useParams(); - const [chat, setChat] = useState(); - const pageWidth = usePageWidth(); - - useEffect(() => { - const parsedId = parseId(id ?? ""); - setChat(id ? parsedId : undefined); - }, [id]); - const chats = useChatSystem(); - - const unreadCount = useMemo(() => chats.reduce((p, c) => p + c.unread, 0), [chats]); - - function openChat(e: React.MouseEvent, type: ChatType, id: string) { - e.stopPropagation(); - e.preventDefault(); - navigate(`/messages/${encodeURIComponent(id)}`); - } - - function noteToSelf(chat: Chat) { - return ( -
openChat(e, chat.type, chat.id)}> - -
- ); - } - - function conversationIdent(cx: Chat) { - if (cx.participants.length === 1) { - return ; - } else { - return ( -
- {cx.participants.map(v => ( - - ))} - {cx.title ?? } -
- ); - } - } - - function conversation(cx: Chat) { - if (!login.publicKey) return null; - const participants = cx.participants.map(a => a.id); - if (participants.length === 1 && participants[0] === login.publicKey) return noteToSelf(cx); - - const isActive = cx.id === chat; - return ( -
openChat(e, cx.type, cx.id)}> - {conversationIdent(cx)} -
- - - - {cx.unread > 0 && } -
-
- ); - } - - return ( -
- {(pageWidth >= TwoCol || !chat) && ( -
-
- - -
- {chats - .sort((a, b) => { - const aSelf = a.participants.length === 1 && a.participants[0].id === login.publicKey; - const bSelf = b.participants.length === 1 && b.participants[0].id === login.publicKey; - if (aSelf || bSelf) { - return aSelf ? -1 : 1; - } - return b.lastMessage > a.lastMessage ? 1 : -1; - }) - .map(conversation)} -
- )} - {chat ? : pageWidth >= TwoCol &&
} - {pageWidth >= ThreeCol && chat && ( -
- -
- )} -
- ); -} - -function ProfileDmActions({ id }: { id: string }) { - const authors = decodeTLV(id) - .filter(a => a.type === TLVEntryType.Author) - .map(a => a.value as string); - const pubkey = authors[0]; - const profile = useUserProfile(pubkey); - const { block, unblock, isBlocked } = useModeration(); - - function truncAbout(s?: string) { - if ((s?.length ?? 0) > 200) { - return `${s?.slice(0, 200)}...`; - } - return s; - } - - const blocked = isBlocked(pubkey); - return ( - <> - -

{getDisplayName(profile, pubkey)}

-

- -

- -
(blocked ? unblock(pubkey) : block(pubkey))}> - - {blocked ? ( - - ) : ( - - )} -
- - ); -} - -function NewChatWindow() { - const [show, setShow] = useState(false); - const [newChat, setNewChat] = useState>([]); - const [results, setResults] = useState>([]); - const [term, setSearchTerm] = useState(""); - const navigate = useNavigate(); - const search = useUserSearch(); - const login = useLogin(); - const { system, publisher } = useEventPublisher(); - - useEffect(() => { - setNewChat([]); - setSearchTerm(""); - setResults(login.follows.item); - }, [show]); - - useEffect(() => { - return debounce(500, () => { - if (term) { - search(term).then(setResults); - } else { - setResults(login.follows.item); - } - }); - }, [term]); - - function togglePubkey(a: string) { - setNewChat(c => (c.includes(a) ? c.filter(v => v !== a) : appendDedupe(c, [a]))); - } - - function startChat() { - setShow(false); - if (newChat.length === 1) { - navigate(createChatLink(ChatType.DirectMessage, newChat[0])); - } else { - navigate(createChatLink(ChatType.PrivateGroupChat, ...newChat)); - } - } - - return ( - <> - - {show && ( - setShow(false)} className="new-chat-modal"> -
-
-

- -

- -
-
-

- -

- setSearchTerm(e.target.value)} - /> -
-
- {newChat.map(a => ( - togglePubkey(a)} - /> - ))} -
-
-

- -

-
- {results.map(a => { - return ( - } - onClick={() => togglePubkey(a)} - className={newChat.includes(a) ? "active" : undefined} - /> - ); - })} - {results.length === 1 && ( - { - setShow(false); - const chats = appendDedupe(login.extraChats, [Nip28ChatSystem.chatId(id)]); - LoginStore.updateSession({ - ...login, - extraChats: chats, - } as LoginSession); - const evList = await publisher?.generic(eb => { - eb.kind(EventKind.PublicChatsList); - chats.forEach(c => { - if (c.startsWith("chat281")) { - eb.tag(["e", decodeTLV(c)[0].value as string]); - } - }); - return eb; - }); - if (evList) { - await system.BroadcastEvent(evList); - } - navigate(createChatLink(ChatType.PublicGroupChat, id)); - }} - /> - )} -
-
-
-
- )} - - ); -} - -export function Nip28ChatProfile({ id, onClick }: { id: string; onClick: (id: string) => void }) { - const channel = useEventFeed(new NostrLink(CONFIG.eventLinkPrefix, id, 40)); - if (channel?.data) { - const meta = JSON.parse(channel.data.content) as UserMetadata; - return ( - } - onClick={() => onClick(id)} - /> - ); - } -} diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index ad77c991..1eee4e60 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -35,7 +35,7 @@ import NotificationsPage from "@/Pages/Notifications"; import SettingsPage, { SettingsRoutes } from "@/Pages/SettingsPage"; import ErrorPage from "@/Pages/ErrorPage"; import NostrAddressPage from "@/Pages/NostrAddressPage"; -import MessagesPage from "@/Pages/MessagesPage"; +import MessagesPage from "@/Pages/Messages/MessagesPage"; import DonatePage from "@/Pages/DonatePage"; import SearchPage from "@/Pages/SearchPage"; import HelpPage from "@/Pages/HelpPage";