feat: upgrade dm styles
This commit is contained in:
@ -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;
|
||||
}
|
||||
|
Reference in New Issue
Block a user