feat: upgrade dm styles
This commit is contained in:
parent
fdcf77ad55
commit
663c2ea433
@ -151,6 +151,8 @@
|
||||
<symbol id="edit" viewBox="0 0 23 23" fill="none">
|
||||
<path d="M10 3.99998H5.8C4.11984 3.99998 3.27976 3.99998 2.63803 4.32696C2.07354 4.61458 1.6146 5.07353 1.32698 5.63801C1 6.27975 1 7.11983 1 8.79998V17.2C1 18.8801 1 19.7202 1.32698 20.362C1.6146 20.9264 2.07354 21.3854 2.63803 21.673C3.27976 22 4.11984 22 5.8 22H14.2C15.8802 22 16.7202 22 17.362 21.673C17.9265 21.3854 18.3854 20.9264 18.673 20.362C19 19.7202 19 18.8801 19 17.2V13M6.99997 16H8.67452C9.1637 16 9.40829 16 9.63846 15.9447C9.84254 15.8957 10.0376 15.8149 10.2166 15.7053C10.4184 15.5816 10.5914 15.4086 10.9373 15.0627L20.5 5.49998C21.3284 4.67156 21.3284 3.32841 20.5 2.49998C19.6716 1.67156 18.3284 1.67155 17.5 2.49998L7.93723 12.0627C7.59133 12.4086 7.41838 12.5816 7.29469 12.7834C7.18504 12.9624 7.10423 13.1574 7.05523 13.3615C6.99997 13.5917 6.99997 13.8363 6.99997 14.3255V16Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="arrow-right" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M1.16663 6.99935H12.8333M12.8333 6.99935L6.99996 1.16602M12.8333 6.99935L6.99996 12.8327" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 39 KiB |
@ -10,6 +10,7 @@
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://nostrnests.com https://embed.wavlake.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src *; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' 'wasm-unsafe-eval' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://imgproxy.snort.social" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/nostrich_512.png" />
|
||||
|
@ -1,23 +1,37 @@
|
||||
.dm {
|
||||
padding: 8px;
|
||||
background-color: var(--gray);
|
||||
margin-bottom: 5px;
|
||||
border-radius: 5px;
|
||||
width: fit-content;
|
||||
margin-top: 16px;
|
||||
min-width: 100px;
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
min-height: 40px;
|
||||
white-space: pre-wrap;
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
.dm > div:first-child {
|
||||
.dm a {
|
||||
color: var(--font-color) !important;
|
||||
}
|
||||
|
||||
.dm > div:last-child {
|
||||
color: var(--gray-light);
|
||||
font-size: small;
|
||||
margin-bottom: 3px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.dm.other > div:first-child {
|
||||
padding: 12px 16px;
|
||||
background: var(--gray-secondary);
|
||||
border-radius: 16px 16px 16px 0px;
|
||||
}
|
||||
|
||||
.dm.me {
|
||||
align-self: flex-end;
|
||||
background-color: var(--gray-secondary);
|
||||
}
|
||||
|
||||
.dm.me > div:first-child {
|
||||
padding: 12px 16px;
|
||||
background: var(--dm-gradient);
|
||||
border-radius: 16px 16px 0px 16px;
|
||||
}
|
||||
|
||||
.dm.me > div:last-child {
|
||||
text-align: end;
|
||||
}
|
||||
|
@ -45,13 +45,13 @@ export default function DM(props: DMProps) {
|
||||
}, [inView, props.data]);
|
||||
|
||||
return (
|
||||
<div className={`flex dm f-col${isMe ? " me" : ""}`} ref={ref}>
|
||||
<div className={isMe ? "dm me" : "dm other"} ref={ref}>
|
||||
<div>
|
||||
<Text content={content} tags={[]} creator={otherPubkey} />
|
||||
</div>
|
||||
<div>
|
||||
<NoteTime from={props.data.created_at * 1000} fallback={formatMessage(messages.JustNow)} />
|
||||
</div>
|
||||
<div className="w-max">
|
||||
<Text content={content} tags={[]} creator={otherPubkey} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
19
packages/app/src/Element/DmWindow.css
Normal file
19
packages/app/src/Element/DmWindow.css
Normal file
@ -0,0 +1,19 @@
|
||||
.dm-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dm-window > div:nth-child(2) {
|
||||
overflow-y: auto;
|
||||
padding: 0 10px 10px 10px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.dm-window > div:nth-child(3) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-color);
|
||||
gap: 10px;
|
||||
padding: 5px 10px;
|
||||
}
|
84
packages/app/src/Element/DmWindow.tsx
Normal file
84
packages/app/src/Element/DmWindow.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import "./DmWindow.css";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TaggedRawEvent } from "@snort/nostr";
|
||||
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import DM from "Element/DM";
|
||||
import { dmsForLogin, dmsInChat, isToSelf } from "Pages/MessagesPage";
|
||||
import NoteToSelf from "Element/NoteToSelf";
|
||||
import { useDmCache } from "Hooks/useDmsCache";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import WriteDm from "Element/WriteDm";
|
||||
import { unwrap } from "Util";
|
||||
|
||||
export default function DmWindow({ id }: { id: string }) {
|
||||
const pubKey = useLogin().publicKey;
|
||||
const dmListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function resize(chatList: HTMLDivElement) {
|
||||
const scrollWrap = unwrap(chatList.parentElement);
|
||||
const h = scrollWrap.scrollHeight;
|
||||
const s = scrollWrap.clientHeight + scrollWrap.scrollTop;
|
||||
const pos = Math.abs(h - s);
|
||||
const atBottom = pos === 0;
|
||||
//console.debug("Resize", h, s, pos, atBottom);
|
||||
if (atBottom) {
|
||||
scrollWrap.scrollTo(0, scrollWrap.scrollHeight);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (dmListRef.current) {
|
||||
const scrollWrap = dmListRef.current;
|
||||
const chatList = unwrap(scrollWrap.parentElement);
|
||||
chatList.onscroll = () => {
|
||||
resize(dmListRef.current as HTMLDivElement);
|
||||
};
|
||||
new ResizeObserver(() => resize(dmListRef.current as HTMLDivElement)).observe(scrollWrap);
|
||||
return () => {
|
||||
chatList.onscroll = null;
|
||||
new ResizeObserver(() => resize(dmListRef.current as HTMLDivElement)).unobserve(scrollWrap);
|
||||
};
|
||||
}
|
||||
}, [dmListRef]);
|
||||
|
||||
return (
|
||||
<div className="dm-window">
|
||||
<div>
|
||||
{(id === pubKey && <NoteToSelf className="f-grow mb-10" pubkey={id} />) || (
|
||||
<ProfileImage pubkey={id} className="f-grow mb10" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex f-col" ref={dmListRef}>
|
||||
<DmChatSelected chatPubKey={id} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<WriteDm chatPubKey={id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DmChatSelected({ chatPubKey }: { chatPubKey: string }) {
|
||||
const dms = useDmCache();
|
||||
const { publicKey: myPubKey } = useLogin();
|
||||
const sortedDms = useMemo(() => {
|
||||
if (myPubKey) {
|
||||
const myDms = dmsForLogin(dms, myPubKey);
|
||||
// filter dms in this chat, or dms to self
|
||||
const thisDms = myPubKey === chatPubKey ? myDms.filter(d => isToSelf(d, myPubKey)) : myDms;
|
||||
return [...dmsInChat(thisDms, chatPubKey)].sort((a, b) => a.created_at - b.created_at);
|
||||
}
|
||||
return [];
|
||||
}, [dms, myPubKey, chatPubKey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{sortedDms.map(a => (
|
||||
<DM data={a as TaggedRawEvent} key={a.id} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
@ -68,7 +68,7 @@ const Nip05 = ({ nip05, pubkey, verifyNip = true }: Nip05Params) => {
|
||||
const { isVerified, couldNotVerify } = useIsVerified(pubkey, nip05, !verifyNip);
|
||||
|
||||
return (
|
||||
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={ev => ev.stopPropagation()}>
|
||||
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`}>
|
||||
{!isDefaultUser && isVerified && <span className="nick">{`${name}@`}</span>}
|
||||
{isVerified && (
|
||||
<>
|
||||
|
@ -28,7 +28,6 @@ export default function NoteTime(props: NoteTimeProps) {
|
||||
year: "2-digit",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
weekday: "short",
|
||||
});
|
||||
} else if (absAgo > HourInMs) {
|
||||
return `${fromDate.getHours().toString().padStart(2, "0")}:${fromDate.getMinutes().toString().padStart(2, "0")}`;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import "./ProfileImage.css";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { HexKey, NostrPrefix } from "@snort/nostr";
|
||||
|
||||
import { useUserProfile } from "Hooks/useUserProfile";
|
||||
@ -38,8 +38,18 @@ export default function ProfileImage({
|
||||
return overrideUsername ?? getDisplayName(user, pubkey);
|
||||
}, [user, pubkey, overrideUsername]);
|
||||
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
if (link === "") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className={`pfp${className ? ` ${className}` : ""}`} to={link === undefined ? profileLink(pubkey) : link}>
|
||||
<Link
|
||||
className={`pfp${className ? ` ${className}` : ""}`}
|
||||
to={link === undefined ? profileLink(pubkey) : link}
|
||||
onClick={handleClick}
|
||||
replace={true}>
|
||||
<div className="avatar-wrapper">
|
||||
<Avatar user={user} />
|
||||
</div>
|
||||
|
@ -1,13 +1,10 @@
|
||||
import "./Text.css";
|
||||
import { useMemo } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { visit, SKIP } from "unist-util-visit";
|
||||
import * as unist from "unist";
|
||||
import { HexKey, NostrPrefix } from "@snort/nostr";
|
||||
|
||||
import { MentionRegex, InvoiceRegex, HashtagRegex, CashuRegex } from "Const";
|
||||
import { eventLink, hexToBech32, splitByUrl, unwrap, validateNostrLink } from "Util";
|
||||
import { eventLink, hexToBech32, splitByUrl, validateNostrLink } from "Util";
|
||||
import Invoice from "Element/Invoice";
|
||||
import Hashtag from "Element/Hashtag";
|
||||
import Mention from "Element/Mention";
|
||||
@ -159,19 +156,6 @@ export default function Text({ content, tags, creator, disableMedia, depth }: Te
|
||||
.flat();
|
||||
}
|
||||
|
||||
function transformLi(frag: TextFragment) {
|
||||
const fragments = transformText(frag);
|
||||
return <li>{fragments}</li>;
|
||||
}
|
||||
|
||||
function transformParagraph(frag: TextFragment) {
|
||||
const fragments = transformText(frag);
|
||||
if (fragments.every(f => typeof f === "string")) {
|
||||
return <p>{fragments}</p>;
|
||||
}
|
||||
return <>{fragments}</>;
|
||||
}
|
||||
|
||||
function transformText(frag: TextFragment) {
|
||||
let fragments = extractMentions(frag);
|
||||
fragments = extractLinks(fragments);
|
||||
@ -181,41 +165,8 @@ export default function Text({ content, tags, creator, disableMedia, depth }: Te
|
||||
return fragments;
|
||||
}
|
||||
|
||||
const components = {
|
||||
p: (x: { children?: React.ReactNode[] }) => transformParagraph({ body: x.children ?? [], tags }),
|
||||
a: (x: { href?: string }) => <HyperText link={x.href ?? ""} creator={creator} />,
|
||||
li: (x: { children?: Fragment[] }) => transformLi({ body: x.children ?? [], tags }),
|
||||
};
|
||||
|
||||
interface Node extends unist.Node<unist.Data> {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const disableMarkdownLinks = () => (tree: Node) => {
|
||||
visit(tree, (node, index, parent) => {
|
||||
if (
|
||||
parent &&
|
||||
typeof index === "number" &&
|
||||
(node.type === "link" ||
|
||||
node.type === "linkReference" ||
|
||||
node.type === "image" ||
|
||||
node.type === "imageReference" ||
|
||||
node.type === "definition")
|
||||
) {
|
||||
node.type = "text";
|
||||
const position = unwrap(node.position);
|
||||
node.value = content.slice(position.start.offset, position.end.offset).replace(/\)$/, " )");
|
||||
return SKIP;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const element = useMemo(() => {
|
||||
return (
|
||||
<ReactMarkdown className="text" components={components} remarkPlugins={[disableMarkdownLinks]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
return <div className="text">{transformText({ body: [content], tags })}</div>;
|
||||
}, [content]);
|
||||
|
||||
return <div dir="auto">{element}</div>;
|
||||
|
@ -3,7 +3,7 @@
|
||||
font-size: var(--font-size-small);
|
||||
display: inline-block;
|
||||
background-color: var(--gray-secondary);
|
||||
padding: 2px 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
margin: 2px 5px;
|
||||
@ -17,3 +17,7 @@
|
||||
.pill:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.light .pill.unread {
|
||||
color: white;
|
||||
}
|
||||
|
102
packages/app/src/Element/WriteDm.tsx
Normal file
102
packages/app/src/Element/WriteDm.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { encodeTLV, NostrPrefix, RawEvent } from "@snort/nostr";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import Icon from "Icons/Icon";
|
||||
import Spinner from "Icons/Spinner";
|
||||
import { useState } from "react";
|
||||
import useFileUpload from "Upload";
|
||||
import { openFile } from "Util";
|
||||
import Textarea from "./Textarea";
|
||||
|
||||
export default function WriteDm({ chatPubKey }: { chatPubKey: string }) {
|
||||
const [msg, setMsg] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [otherEvents, setOtherEvents] = useState<Array<RawEvent>>([]);
|
||||
const [error, setError] = useState("");
|
||||
const publisher = useEventPublisher();
|
||||
const uploader = useFileUpload();
|
||||
|
||||
async function attachFile() {
|
||||
try {
|
||||
const file = await openFile();
|
||||
if (file) {
|
||||
uploadFile(file);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(file: File | Blob) {
|
||||
setUploading(true);
|
||||
try {
|
||||
if (file) {
|
||||
const rx = await uploader.upload(file, file.name);
|
||||
if (rx.header) {
|
||||
const link = `nostr:${encodeTLV(rx.header.id, NostrPrefix.Event, undefined, rx.header.kind)}`;
|
||||
setMsg(`${msg ? `${msg}\n` : ""}${link}`);
|
||||
setOtherEvents([...otherEvents, rx.header]);
|
||||
} else if (rx.url) {
|
||||
setMsg(`${msg ? `${msg}\n` : ""}${rx.url}`);
|
||||
} else if (rx?.error) {
|
||||
setError(rx.error);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendDm() {
|
||||
if (msg && publisher) {
|
||||
setSending(true);
|
||||
const ev = await publisher.sendDm(msg, chatPubKey);
|
||||
publisher.broadcast(ev);
|
||||
setMsg("");
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
if (!sending) {
|
||||
setMsg(e.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
async function onEnter(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
const isEnter = e.code === "Enter";
|
||||
if (isEnter && !e.shiftKey) {
|
||||
await sendDm();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className="btn-rnd" onClick={() => attachFile()}>
|
||||
{uploading ? <Spinner width={20} /> : <Icon name="attachment" size={20} />}
|
||||
</button>
|
||||
<div className="w-max">
|
||||
<Textarea
|
||||
autoFocus={true}
|
||||
className=""
|
||||
value={msg}
|
||||
onChange={e => onChange(e)}
|
||||
onKeyDown={e => onEnter(e)}
|
||||
onFocus={() => {
|
||||
// ignored
|
||||
}}
|
||||
/>
|
||||
{error && <b className="error">{error}</b>}
|
||||
</div>
|
||||
<button className="btn-rnd" onClick={() => sendDm()}>
|
||||
{sending ? <Spinner width={20} /> : <Icon name="arrow-right" size={20} />}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
|
||||
export default function usePageWidth() {
|
||||
const ref = useRef<HTMLDivElement | null>(document.querySelector(".page"));
|
||||
const ref = useRef<HTMLDivElement | null>(document.querySelector("#root"));
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -1,36 +1,3 @@
|
||||
.dm-list {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
--header-height: 72px;
|
||||
--profile-container-height: 58px;
|
||||
--write-dm-input-height: 86px;
|
||||
height: calc(100vh - var(--header-height) - var(--profile-container-height) - var(--write-dm-input-height));
|
||||
}
|
||||
|
||||
.dm-list > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 10px;
|
||||
scroll-padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.write-dm {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
background-color: var(--gray);
|
||||
width: inherit;
|
||||
border-radius: 5px 5px 0 0;
|
||||
}
|
||||
|
||||
.write-dm .inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 5px;
|
||||
}
|
||||
.write-dm textarea {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.write-dm-spacer {
|
||||
margin-bottom: 80px;
|
||||
.chat-page {
|
||||
height: calc(100vh - 57px);
|
||||
}
|
||||
|
@ -1,86 +1,15 @@
|
||||
import "./ChatPage.css";
|
||||
import { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import DmWindow from "Element/DmWindow";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { TaggedRawEvent } from "@snort/nostr";
|
||||
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import { bech32ToHex } from "Util";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import DM from "Element/DM";
|
||||
import { dmsForLogin, dmsInChat, isToSelf } from "Pages/MessagesPage";
|
||||
import NoteToSelf from "Element/NoteToSelf";
|
||||
import { useDmCache } from "Hooks/useDmsCache";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
|
||||
type RouterParams = {
|
||||
id: string;
|
||||
};
|
||||
import "./ChatPage.css";
|
||||
|
||||
export default function ChatPage() {
|
||||
const params = useParams<RouterParams>();
|
||||
const publisher = useEventPublisher();
|
||||
const id = bech32ToHex(params.id ?? "");
|
||||
const pubKey = useLogin().publicKey;
|
||||
const [content, setContent] = useState<string>();
|
||||
const dmListRef = useRef<HTMLDivElement>(null);
|
||||
const dms = useDmCache();
|
||||
|
||||
const sortedDms = useMemo(() => {
|
||||
if (pubKey) {
|
||||
const myDms = dmsForLogin(dms, pubKey);
|
||||
// filter dms in this chat, or dms to self
|
||||
const thisDms = id === pubKey ? myDms.filter(d => isToSelf(d, pubKey)) : myDms;
|
||||
return [...dmsInChat(thisDms, id)].sort((a, b) => a.created_at - b.created_at);
|
||||
}
|
||||
return [];
|
||||
}, [dms, pubKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dmListRef.current) {
|
||||
dmListRef.current.scroll(0, dmListRef.current.scrollHeight);
|
||||
}
|
||||
}, [dmListRef.current?.scrollHeight]);
|
||||
|
||||
async function sendDm() {
|
||||
if (content && publisher) {
|
||||
const ev = await publisher.sendDm(content, id);
|
||||
publisher.broadcast(ev);
|
||||
setContent("");
|
||||
}
|
||||
}
|
||||
|
||||
async function onEnter(e: KeyboardEvent) {
|
||||
const isEnter = e.code === "Enter";
|
||||
if (isEnter && !e.shiftKey) {
|
||||
await sendDm();
|
||||
}
|
||||
}
|
||||
const { id } = useParams();
|
||||
|
||||
return (
|
||||
<>
|
||||
{(id === pubKey && <NoteToSelf className="f-grow mb-10" pubkey={id} />) || (
|
||||
<ProfileImage pubkey={id} className="f-grow mb10" />
|
||||
)}
|
||||
<div className="dm-list" ref={dmListRef}>
|
||||
<div>
|
||||
{sortedDms.map(a => (
|
||||
<DM data={a as TaggedRawEvent} key={a.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="write-dm">
|
||||
<div className="inner">
|
||||
<textarea
|
||||
className="f-grow mr10"
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
onKeyDown={e => onEnter(e)}></textarea>
|
||||
<button type="button" onClick={() => sendDm()}>
|
||||
<FormattedMessage defaultMessage="Send" description="Send DM button" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div className="chat-page">
|
||||
<DmWindow id={bech32ToHex(id ?? "")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -57,7 +57,8 @@ export default function Layout() {
|
||||
}, [location]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname.startsWith("/login")) {
|
||||
const widePage = ["/login", "/messages"];
|
||||
if (widePage.some(a => location.pathname.startsWith(a))) {
|
||||
setPageClass("");
|
||||
} else {
|
||||
setPageClass("page");
|
||||
@ -162,7 +163,7 @@ export default function Layout() {
|
||||
|
||||
{!shouldHideNoteCreator && (
|
||||
<>
|
||||
<button className="note-create-button" type="button" onClick={handleNoteCreatorButtonClick}>
|
||||
<button className="note-create-button" onClick={handleNoteCreatorButtonClick}>
|
||||
<Icon name="plus" size={16} />
|
||||
</button>
|
||||
<NoteCreator />
|
||||
|
39
packages/app/src/Pages/MessagesPage.css
Normal file
39
packages/app/src/Pages/MessagesPage.css
Normal file
@ -0,0 +1,39 @@
|
||||
.dm-page {
|
||||
display: grid;
|
||||
grid-template-columns: 350px auto;
|
||||
height: calc(100vh - 57px);
|
||||
/* 100vh - header - padding */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* These should match what is in code too */
|
||||
@media (max-width: 768px) {
|
||||
.dm-page {
|
||||
grid-template-columns: 100vw;
|
||||
}
|
||||
.dm-page > div:nth-child(1) {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1500px) {
|
||||
.dm-page {
|
||||
grid-template-columns: 350px auto 350px;
|
||||
}
|
||||
}
|
||||
|
||||
/* User list */
|
||||
.dm-page > div:nth-child(1) {
|
||||
overflow-y: auto;
|
||||
margin: 0 10px;
|
||||
padding: 0 10px 0 0;
|
||||
}
|
||||
|
||||
/* Chat window */
|
||||
.dm-page > div:nth-child(2) {
|
||||
height: calc(100vh - 57px);
|
||||
}
|
||||
|
||||
/* Profile pannel */
|
||||
.dm-page > div:nth-child(3) {
|
||||
margin: 0 10px;
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey, RawEvent } from "@snort/nostr";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { HexKey, RawEvent, NostrPrefix } from "@snort/nostr";
|
||||
|
||||
import UnreadCount from "Element/UnreadCount";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
@ -9,9 +10,16 @@ import NoteToSelf from "Element/NoteToSelf";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import { useDmCache } from "Hooks/useDmsCache";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import usePageWidth from "Hooks/usePageWidth";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import DmWindow from "Element/DmWindow";
|
||||
|
||||
import "./MessagesPage.css";
|
||||
import messages from "./messages";
|
||||
|
||||
const TwoCol = 768;
|
||||
const ThreeCol = 1500;
|
||||
|
||||
type DmChat = {
|
||||
pubkey: HexKey;
|
||||
unreadMessages: number;
|
||||
@ -21,7 +29,11 @@ type DmChat = {
|
||||
export default function MessagesPage() {
|
||||
const login = useLogin();
|
||||
const { isMuted } = useModeration();
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const dms = useDmCache();
|
||||
const [chat, setChat] = useState<string>();
|
||||
const pageWidth = usePageWidth();
|
||||
|
||||
const chats = useMemo(() => {
|
||||
if (login.publicKey) {
|
||||
@ -35,25 +47,39 @@ export default function MessagesPage() {
|
||||
|
||||
const unreadCount = useMemo(() => chats.reduce((p, c) => p + c.unreadMessages, 0), [chats]);
|
||||
|
||||
function openChat(e: React.MouseEvent<HTMLDivElement>, pubkey: string) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (pageWidth < TwoCol) {
|
||||
navigate(`/messages/${hexToBech32(NostrPrefix.PublicKey, pubkey)}`);
|
||||
} else {
|
||||
setChat(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
function noteToSelf(chat: DmChat) {
|
||||
return (
|
||||
<div className="flex mb10" key={chat.pubkey}>
|
||||
<NoteToSelf
|
||||
clickable={true}
|
||||
className="f-grow"
|
||||
link={`/messages/${hexToBech32("npub", chat.pubkey)}`}
|
||||
pubkey={chat.pubkey}
|
||||
/>
|
||||
<div className="flex mb10" key={chat.pubkey} onClick={e => openChat(e, chat.pubkey)}>
|
||||
<NoteToSelf clickable={true} className="f-grow" link="" pubkey={chat.pubkey} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function person(chat: DmChat) {
|
||||
if (!login.publicKey) return null;
|
||||
if (chat.pubkey === login.publicKey) return noteToSelf(chat);
|
||||
return (
|
||||
<div className="flex mb10" key={chat.pubkey}>
|
||||
<ProfileImage pubkey={chat.pubkey} className="f-grow" link={`/messages/${hexToBech32("npub", chat.pubkey)}`} />
|
||||
<UnreadCount unread={chat.unreadMessages} />
|
||||
<div className="flex mb10" key={chat.pubkey} onClick={e => openChat(e, chat.pubkey)}>
|
||||
<ProfileImage pubkey={chat.pubkey} className="f-grow" link="" />
|
||||
<div className="nowrap">
|
||||
<small>
|
||||
<NoteTime
|
||||
from={newestMessage(dms, login.publicKey, chat.pubkey) * 1000}
|
||||
fallback={formatMessage({ defaultMessage: "Just now" })}
|
||||
/>
|
||||
</small>
|
||||
{chat.unreadMessages > 0 && <UnreadCount unread={chat.unreadMessages} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -65,24 +91,28 @@ export default function MessagesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<div className="flex">
|
||||
<h3 className="f-grow">
|
||||
<FormattedMessage {...messages.Messages} />
|
||||
</h3>
|
||||
<button disabled={unreadCount <= 0} type="button" onClick={() => markAllRead()}>
|
||||
<FormattedMessage {...messages.MarkAllRead} />
|
||||
</button>
|
||||
<div className="dm-page">
|
||||
<div>
|
||||
<div className="flex">
|
||||
<h3 className="f-grow">
|
||||
<FormattedMessage {...messages.Messages} />
|
||||
</h3>
|
||||
<button disabled={unreadCount <= 0} type="button" onClick={() => markAllRead()}>
|
||||
<FormattedMessage {...messages.MarkAllRead} />
|
||||
</button>
|
||||
</div>
|
||||
{chats
|
||||
.sort((a, b) => {
|
||||
return a.pubkey === login.publicKey
|
||||
? -1
|
||||
: b.pubkey === login.publicKey
|
||||
? 1
|
||||
: b.newestMessage - a.newestMessage;
|
||||
})
|
||||
.map(person)}
|
||||
</div>
|
||||
{chats
|
||||
.sort((a, b) => {
|
||||
return a.pubkey === login.publicKey
|
||||
? -1
|
||||
: b.pubkey === login.publicKey
|
||||
? 1
|
||||
: b.newestMessage - a.newestMessage;
|
||||
})
|
||||
.map(person)}
|
||||
{pageWidth >= TwoCol && chat && <DmWindow id={chat} />}
|
||||
{pageWidth >= ThreeCol && <div></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -126,7 +156,7 @@ function unreadDms(dms: RawEvent[], myPubKey: HexKey, pk: HexKey) {
|
||||
return dmsInChat(dms, pk).filter(a => a.created_at >= lastRead && a.pubkey !== myPubKey).length;
|
||||
}
|
||||
|
||||
function newestMessage(dms: RawEvent[], myPubKey: HexKey, pk: HexKey) {
|
||||
function newestMessage(dms: readonly RawEvent[], myPubKey: HexKey, pk: HexKey) {
|
||||
if (pk === myPubKey) {
|
||||
return dmsInChat(
|
||||
dms.filter(d => isToSelf(d, myPubKey)),
|
||||
|
@ -23,7 +23,8 @@
|
||||
--gray-dark: #2b2b2b;
|
||||
--gray-superdark: #1a1a1a;
|
||||
--gray-gradient: linear-gradient(to bottom right, var(--gray-superlight), var(--gray), var(--gray-light));
|
||||
--snort-gradient: linear-gradient(90deg, #a178ff 0%, #ff6baf 108.33%);
|
||||
--snort-gradient: linear-gradient(90deg, #a178ff 0%, #ff6baf 100%);
|
||||
--dm-gradient: linear-gradient(90deg, #5722d2 0%, #db1771 100%);
|
||||
--invoice-gradient: linear-gradient(
|
||||
45deg,
|
||||
var(--note-bg) 50%,
|
||||
@ -65,8 +66,9 @@ html.light {
|
||||
--gray-dark: #2b2b2b;
|
||||
--gray-superdark: #eee;
|
||||
|
||||
--invoice-gradient: linear-gradient(45deg, var(--note-bg) 50%, rgb(247, 183, 51, 0.2), rgb(252, 74, 26, 0.2));
|
||||
--paid-invoice-gradient: linear-gradient(45deg, var(--note-bg) 50%, rgb(247, 183, 51, 0.6), rgb(252, 74, 26, 0.6));
|
||||
--dm-gradient: var(--gray);
|
||||
--invoice-gradient: linear-gradient(45deg, var(--note-bg) 50%, #f7b73333, #fc4a1a33);
|
||||
--paid-invoice-gradient: linear-gradient(45deg, var(--note-bg) 50%, #f7b73399, #fc4a1a99);
|
||||
}
|
||||
|
||||
body {
|
||||
@ -89,6 +91,9 @@ code {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
body #root > div:not(.page) header {
|
||||
padding: 2px 10px;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.page {
|
||||
width: 586px;
|
||||
@ -258,22 +263,13 @@ button.icon:hover {
|
||||
}
|
||||
|
||||
.btn-rnd {
|
||||
border: none;
|
||||
border-radius: 100%;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 520px) {
|
||||
.btn-rnd {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
@ -358,6 +354,11 @@ input:disabled {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
.f-col-end {
|
||||
flex-direction: column;
|
||||
align-items: flex-end !important;
|
||||
}
|
||||
|
||||
.f-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@ -615,3 +616,8 @@ button.tall {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.rta__textarea {
|
||||
/* Fix width calculation to account for 12px padding on input */
|
||||
width: calc(100% - 24px) !important;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user