This commit is contained in:
hzrd149 2023-08-05 18:26:41 -05:00
parent 035faf1b5f
commit 1c759c0d20
69 changed files with 185 additions and 207 deletions

View File

@ -1,6 +1,6 @@
# noStrudel # noStrudel
> NOTE: This client is still in development and is buggy > NOTE: This client is still in development and will have bugs
## noStrudel is my personal nostr client. ## noStrudel is my personal nostr client.
@ -10,7 +10,7 @@ There are many features missing from this client and I wont get around to implem
Live Instance: [nostrudel.ninja](https://nostrudel.ninja) Live Instance: [nostrudel.ninja](https://nostrudel.ninja)
You can find better clients with more features in the [awesome-nostr](https://github.com/aljazceru/awesome-nostr) repo. You can find better clients with more features on [nostrapps.com](https://www.nostrapps.com/) or in the [awesome-nostr](https://github.com/aljazceru/awesome-nostr) repo.
## Please don't trust my app with your nsec ## Please don't trust my app with your nsec
@ -30,7 +30,7 @@ docker run --rm -p 8080:80 ghcr.io/hzrd149/nostrudel
git clone git@github.com:hzrd149/nostrudel.git git clone git@github.com:hzrd149/nostrudel.git
cd nostrudel cd nostrudel
yarn install yarn install
yarn start yarn dev
``` ```
## Contributing ## Contributing

View File

@ -2,7 +2,7 @@ describe("Embeds", () => {
describe("hashtags", () => { describe("hashtags", () => {
it('should handle uppercase hashtags and ","', () => { it('should handle uppercase hashtags and ","', () => {
cy.visit( cy.visit(
"#/n/nevent1qqsrj5ns6wva3fcghlyx0hp7hhajqtqk3kuckp7xhhscrm4jl7futegpz9mhxue69uhkummnw3e82efwvdhk6qgswaehxw309ahx7um5wgh8w6twv5pkpt8l", "#/n/nevent1qqsrj5ns6wva3fcghlyx0hp7hhajqtqk3kuckp7xhhscrm4jl7futegpz9mhxue69uhkummnw3e82efwvdhk6qgswaehxw309ahx7um5wgh8w6twv5pkpt8l"
); );
cy.findByRole("link", { name: "#Japan" }).should("be.visible"); cy.findByRole("link", { name: "#Japan" }).should("be.visible");
@ -15,18 +15,18 @@ describe("Embeds", () => {
describe("links", () => { describe("links", () => {
it("embed trustless.computer links", () => { it("embed trustless.computer links", () => {
cy.visit( cy.visit(
"#/n/nevent1qqsfn2mv3pe2v7jak4r5wnyengt36t0rx26w04hgysrmtpml8jnlk5cprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2qgawaehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2aq2wry06", "#/n/nevent1qqsfn2mv3pe2v7jak4r5wnyengt36t0rx26w04hgysrmtpml8jnlk5cprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2qgawaehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2aq2wry06"
); );
cy.get('[href="https://trustless.computer/"]').should("be.visible"); cy.get('[href="https://trustless.computer/"]').should("be.visible");
cy.get( cy.get(
'[href="https://mempool.space/tx/461c6f56015c94d74837b68c9d08f4b80e7db7ca1e5ac4c53d9aa8c76b667672"]', '[href="https://mempool.space/tx/461c6f56015c94d74837b68c9d08f4b80e7db7ca1e5ac4c53d9aa8c76b667672"]'
).should("be.visible"); ).should("be.visible");
}); });
it("embeds links", () => { it("embeds links", () => {
cy.visit( cy.visit(
"#/n/nevent1qqsvg6kt4hl79qpp5p673g7ref6r0c5jvp4yys7mmvs4m50t30sy9dgpz9mhxue69uhkummnw3e82efwvdhk6qgjwaehxw309aex2mrp0yhxvdm69e5k7r3xlpe", "#/n/nevent1qqsvg6kt4hl79qpp5p673g7ref6r0c5jvp4yys7mmvs4m50t30sy9dgpz9mhxue69uhkummnw3e82efwvdhk6qgjwaehxw309aex2mrp0yhxvdm69e5k7r3xlpe"
); );
cy.get('[href="https://getalby.com/"]').should("exist"); cy.get('[href="https://getalby.com/"]').should("exist");
@ -38,11 +38,11 @@ describe("Embeds", () => {
it("embeds simplex.chat links", () => { it("embeds simplex.chat links", () => {
cy.visit( cy.visit(
"#/n/nevent1qqsymds0vlpp4f5s0dckjf4qz283pdsen0rmx8lu7ct6hpnxag2hpacpremhxue69uhkummnw3ez6un9d3shjtnwda4k7arpwfhjucm0d5q3qamnwvaz7tmwdaehgu3wwa5kueghxyq76", "#/n/nevent1qqsymds0vlpp4f5s0dckjf4qz283pdsen0rmx8lu7ct6hpnxag2hpacpremhxue69uhkummnw3ez6un9d3shjtnwda4k7arpwfhjucm0d5q3qamnwvaz7tmwdaehgu3wwa5kueghxyq76"
); );
cy.get( cy.get(
'[href="https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FVlHiRmia02CDgga7w-uNb2FQZTZsj3UR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAd2GEWU9Zjrljhw8O4FldcxrqehkDWezXl-cWD-VkeEw%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion"]', '[href="https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FVlHiRmia02CDgga7w-uNb2FQZTZsj3UR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAd2GEWU9Zjrljhw8O4FldcxrqehkDWezXl-cWD-VkeEw%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion"]'
).should("be.visible"); ).should("be.visible");
}); });
}); });
@ -50,7 +50,7 @@ describe("Embeds", () => {
describe("Nostr links", () => { describe("Nostr links", () => {
it("should embed noub1...", () => { it("should embed noub1...", () => {
cy.visit( cy.visit(
"#/n/nevent1qqsd5yw7sntqfc4e7u4aempvgctry2plz653t9gpf97ctk5vc0ftskgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3zamnwvaz7tmwdaehgun4v5hxxmmdfxdj3a", "#/n/nevent1qqsd5yw7sntqfc4e7u4aempvgctry2plz653t9gpf97ctk5vc0ftskgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3zamnwvaz7tmwdaehgun4v5hxxmmdfxdj3a"
); );
cy.contains("Alby team"); cy.contains("Alby team");
@ -69,7 +69,7 @@ describe("Embeds", () => {
describe("youtube", () => { describe("youtube", () => {
it("should embed playlists", () => { it("should embed playlists", () => {
cy.visit( cy.visit(
"#/n/nevent1qqs8w6e63smpr5ccmz4l0w5pvnkp6r7z2fxaadjwu2g74y95pl9xv0cpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqqkgf54", "#/n/nevent1qqs8w6e63smpr5ccmz4l0w5pvnkp6r7z2fxaadjwu2g74y95pl9xv0cpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqqkgf54"
); );
cy.findByTitle(/youtube video player/i).should("be.visible"); cy.findByTitle(/youtube video player/i).should("be.visible");
@ -80,31 +80,31 @@ describe("Embeds", () => {
describe("Music", () => { describe("Music", () => {
it("should handle wavlake links", () => { it("should handle wavlake links", () => {
cy.visit( cy.visit(
"#/n/nevent1qqsve4ud5v8gjds2f2h7exlmjvhqayu4s520pge7frpwe22wezny0pcpp4mhxue69uhkummn9ekx7mqprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2mxs3z0", "#/n/nevent1qqsve4ud5v8gjds2f2h7exlmjvhqayu4s520pge7frpwe22wezny0pcpp4mhxue69uhkummn9ekx7mqprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2mxs3z0"
); );
cy.findByTitle("Wavlake Embed").should("be.visible"); cy.findByTitle("Wavlake Embed").should("be.visible");
}); });
it("should handle spotify links", () => { it("should handle spotify links", () => {
cy.visit( cy.visit(
"#/n/nevent1qqsx0lz7m72qzq499exwhnfszvgwea8tv38x9wkv32yhkmwwmhgs7jgprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk25m3sln", "#/n/nevent1qqsx0lz7m72qzq499exwhnfszvgwea8tv38x9wkv32yhkmwwmhgs7jgprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk25m3sln"
); );
cy.findByTitle("Spotify List Embed").should("exist"); cy.findByTitle("Spotify List Embed").should("exist");
cy.visit( cy.visit(
"#/n/nevent1qqsqxkmz49hydf8ppa9k6x6zrcq7m4evhhlye0j3lcnz8hrl2q6np4spz3mhxue69uhhyetvv9ujuerpd46hxtnfdult02qz", "#/n/nevent1qqsqxkmz49hydf8ppa9k6x6zrcq7m4evhhlye0j3lcnz8hrl2q6np4spz3mhxue69uhhyetvv9ujuerpd46hxtnfdult02qz"
); );
cy.findByTitle("Spotify Embed").should("exist"); cy.findByTitle("Spotify Embed").should("exist");
}); });
it("should handle apple music links", () => { it("should handle apple music links", () => {
cy.visit( cy.visit(
"#/n/nevent1qqs9kqt9d7r4zjpawcyl82x5qsn4hals4wn294dv95knrahs4mggwasprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2whhzvz", "#/n/nevent1qqs9kqt9d7r4zjpawcyl82x5qsn4hals4wn294dv95knrahs4mggwasprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2whhzvz"
); );
cy.findByTitle("Apple Music Embed").should("exist"); cy.findByTitle("Apple Music Embed").should("exist");
cy.visit( cy.visit(
"#/n/nevent1qqszyrz4uug75j4086kj4f8peg3g0v8g9f04zjxplnpq0uxljtthggqprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2aeexmq", "#/n/nevent1qqszyrz4uug75j4086kj4f8peg3g0v8g9f04zjxplnpq0uxljtthggqprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2aeexmq"
); );
cy.findByTitle("Apple Music List Embed").should("exist"); cy.findByTitle("Apple Music List Embed").should("exist");
}); });
@ -118,7 +118,7 @@ describe("Embeds", () => {
describe("Emoji", () => { describe("Emoji", () => {
it("should embed emojis", () => { it("should embed emojis", () => {
cy.visit( cy.visit(
"#/n/nevent1qqsdj7k47uh4z0ypl2m29lvd4ar9zpf6dcy7ls0q6g6qctnxfj5n3pcpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqdyqlpq", "#/n/nevent1qqsdj7k47uh4z0ypl2m29lvd4ar9zpf6dcy7ls0q6g6qctnxfj5n3pcpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqdyqlpq"
); );
cy.findByRole("img", { name: /pepeD/i }).should("be.visible"); cy.findByRole("img", { name: /pepeD/i }).should("be.visible");

View File

@ -50,7 +50,7 @@ describe("Login view", () => {
it("should redirect after login", () => { it("should redirect after login", () => {
cy.visit( cy.visit(
"#/n/nevent1qqs88gdxv36qsjfwr66k7wxuq9r2tg8rsdcnfkcqdg4sc6vlnsma98qpzpmhxue69uhkummnw3ezuamfdejsz9rhwden5te0wfjkccte9ejxzmt4wvhxjmccew89d", "#/n/nevent1qqs88gdxv36qsjfwr66k7wxuq9r2tg8rsdcnfkcqdg4sc6vlnsma98qpzpmhxue69uhkummnw3ezuamfdejsz9rhwden5te0wfjkccte9ejxzmt4wvhxjmccew89d"
); );
cy.findByRole("link", { name: /login/i }).click(); cy.findByRole("link", { name: /login/i }).click();

View File

@ -1,7 +1,7 @@
describe("Profile view", () => { describe("Profile view", () => {
it("should load a rss feed profile", () => { it("should load a rss feed profile", () => {
cy.visit( cy.visit(
"#/u/nprofile1qqsp6hxqjatvxtesgszs8aee0fcjccxa3ef3mzjva4uv2yr5lucp6jcpzemhxue69uhhyumnd3shjtnwdaehgu3wd4hk2s8c5un", "#/u/nprofile1qqsp6hxqjatvxtesgszs8aee0fcjccxa3ef3mzjva4uv2yr5lucp6jcpzemhxue69uhhyumnd3shjtnwdaehgu3wd4hk2s8c5un"
); );
cy.contains("fjsmu"); cy.contains("fjsmu");

View File

@ -2,7 +2,7 @@ describe("No account", () => {
describe("note view", () => { describe("note view", () => {
it("should fetch and render note", () => { it("should fetch and render note", () => {
cy.visit( cy.visit(
"#/n/nevent1qqs84hwdlls703w4yf66qsszxjqfc0xselfxrzr6n4qp40vzdnczragpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5jcwczn", "#/n/nevent1qqs84hwdlls703w4yf66qsszxjqfc0xselfxrzr6n4qp40vzdnczragpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5jcwczn"
); );
cy.get(".chakra-card") cy.get(".chakra-card")

View File

@ -1,7 +1,7 @@
describe("Thread", () => { describe("Thread", () => {
it("should handle quote notes with e tags correctly", () => { it("should handle quote notes with e tags correctly", () => {
cy.visit( cy.visit(
"#/n/nevent1qqsx2lnyuke6vmsrz9fdrd6uwjy0g0e9l6menfgdj5truugkh9qmkkgpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqgc9md6", "#/n/nevent1qqsx2lnyuke6vmsrz9fdrd6uwjy0g0e9l6menfgdj5truugkh9qmkkgpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqgc9md6"
); );
// find first note // find first note

View File

@ -1,4 +1,4 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />

View File

@ -5,6 +5,7 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"start": "vite serve", "start": "vite serve",
"dev": "vite serve",
"build": "tsc --project tsconfig.json && vite build", "build": "tsc --project tsconfig.json && vite build",
"format": "prettier --ignore-path .prettierignore -w .", "format": "prettier --ignore-path .prettierignore -w .",
"e2e": "cypress open", "e2e": "cypress open",

View File

@ -21,7 +21,6 @@ import {
import relayPoolService from "../services/relay-pool"; import relayPoolService from "../services/relay-pool";
import { useInterval } from "react-use"; import { useInterval } from "react-use";
import { RelayStatus } from "./relay-status"; import { RelayStatus } from "./relay-status";
import { useIsMobile } from "../hooks/use-is-mobile";
import { RelayIcon } from "./icons"; import { RelayIcon } from "./icons";
import { Relay } from "../classes/relay"; import { Relay } from "../classes/relay";
import { RelayFavicon } from "./relay-favicon"; import { RelayFavicon } from "./relay-favicon";
@ -29,7 +28,6 @@ import relayScoreboardService from "../services/relay-scoreboard";
import { RelayScoreBreakdown } from "./relay-score-breakdown"; import { RelayScoreBreakdown } from "./relay-score-breakdown";
export const ConnectedRelays = () => { export const ConnectedRelays = () => {
const isMobile = useIsMobile();
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const [relays, setRelays] = useState<Relay[]>(relayPoolService.getRelays()); const [relays, setRelays] = useState<Relay[]>(relayPoolService.getRelays());
const sortedRelays = useMemo(() => relayScoreboardService.getRankedRelays(relays.map((r) => r.url)), [relays]); const sortedRelays = useMemo(() => relayScoreboardService.getRankedRelays(relays.map((r) => r.url)), [relays]);

View File

@ -7,7 +7,7 @@ export function embedEmoji(content: EmbedableContent, note: NostrEvent | DraftNo
regexp: /:([a-zA-Z0-9_]+):/i, regexp: /:([a-zA-Z0-9_]+):/i,
render: (match) => { render: (match) => {
const emojiTag = note.tags.find( const emojiTag = note.tags.find(
(tag) => tag[0] === "emoji" && tag[1].toLowerCase() === match[1].toLowerCase() && tag[2], (tag) => tag[0] === "emoji" && tag[1].toLowerCase() === match[1].toLowerCase() && tag[2]
); );
if (emojiTag) { if (emojiTag) {
return ( return (

View File

@ -53,7 +53,7 @@ export const Note = React.memo(({ event, variant = "outline" }: NoteProps) => {
<ExpandProvider> <ExpandProvider>
<Card variant={variant} ref={ref} data-event-id={event.id}> <Card variant={variant} ref={ref} data-event-id={event.id}>
<CardHeader padding="2"> <CardHeader padding="2">
<Flex flex="1" gap="2" alignItems="center" wrap="wrap"> <Flex flex="1" gap="2" alignItems="center">
<UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} /> <UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" /> <UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon /> <UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />

View File

@ -3,16 +3,16 @@ import { getEventRelays } from "../../services/event-relays";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import { RelayIconStack } from "../relay-icon-stack"; import { RelayIconStack } from "../relay-icon-stack";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { getEventUID } from "../../helpers/nostr/event"; import { getEventUID } from "../../helpers/nostr/event";
import { useBreakpointValue } from "@chakra-ui/react";
export type NoteRelaysProps = { export type NoteRelaysProps = {
event: NostrEvent; event: NostrEvent;
}; };
export const EventRelays = memo(({ event }: NoteRelaysProps) => { export const EventRelays = memo(({ event }: NoteRelaysProps) => {
const isMobile = useIsMobile(); const maxRelays = useBreakpointValue({ base: 3, md: undefined });
const eventRelays = useSubject(getEventRelays(getEventUID(event))); const eventRelays = useSubject(getEventRelays(getEventUID(event)));
return <RelayIconStack relays={eventRelays} direction="row-reverse" maxRelays={isMobile ? 4 : undefined} />; return <RelayIconStack relays={eventRelays} direction="row-reverse" maxRelays={maxRelays} />;
}); });

View File

@ -17,11 +17,10 @@ import { UserAvatarLink } from "../user-avatar-link";
import { UserLink } from "../user-link"; import { UserLink } from "../user-link";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { DislikeIcon, LightningIcon, LikeIcon } from "../icons"; import { DislikeIcon, LightningIcon, LikeIcon } from "../icons";
import { ParsedZap, parseZapEvent } from "../../helpers/zaps"; import { ParsedZap } from "../../helpers/zaps";
import { readablizeSats } from "../../helpers/bolt11"; import { readablizeSats } from "../../helpers/bolt11";
import useEventReactions from "../../hooks/use-event-reactions"; import useEventReactions from "../../hooks/use-event-reactions";
import useEventZaps from "../../hooks/use-event-zaps"; import useEventZaps from "../../hooks/use-event-zaps";
import { useIsMobile } from "../../hooks/use-is-mobile";
function getReactionIcon(content: string) { function getReactionIcon(content: string) {
switch (content) { switch (content) {
@ -48,12 +47,10 @@ const ReactionEvent = React.memo(({ event }: { event: NostrEvent }) => (
)); ));
const ZapEvent = React.memo(({ zap }: { zap: ParsedZap }) => { const ZapEvent = React.memo(({ zap }: { zap: ParsedZap }) => {
const isMobile = useIsMobile();
if (!zap.payment.amount) return null; if (!zap.payment.amount) return null;
return ( return (
<Box borderWidth="1px" borderRadius="lg" py="2" px={isMobile ? "2" : "4"}> <Box borderWidth="1px" borderRadius="lg" py="2" px={["2", "4"]}>
<Flex gap="2" justifyContent="space-between"> <Flex gap="2" justifyContent="space-between">
<Box> <Box>
<UserAvatarLink pubkey={zap.request.pubkey} size="xs" mr="2" /> <UserAvatarLink pubkey={zap.request.pubkey} size="xs" mr="2" />
@ -80,14 +77,13 @@ export default function NoteReactionsModal({
const zaps = useEventZaps(noteId, [], true) ?? []; const zaps = useEventZaps(noteId, [], true) ?? [];
const reactions = useEventReactions(noteId, [], true) ?? []; const reactions = useEventReactions(noteId, [], true) ?? [];
const [selected, setSelected] = useState("zaps"); const [selected, setSelected] = useState("zaps");
const isMobile = useIsMobile();
return ( return (
<Modal isOpen={isOpen} onClose={onClose} size="lg"> <Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay /> <ModalOverlay />
<ModalContent> <ModalContent>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody p={isMobile ? "2" : "4"}> <ModalBody p={["2", "4"]}>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<ButtonGroup> <ButtonGroup>
<Button size="sm" variant={selected === "zaps" ? "solid" : "outline"} onClick={() => setSelected("zaps")}> <Button size="sm" variant={selected === "zaps" ? "solid" : "outline"} onClick={() => setSelected("zaps")}>

View File

@ -67,7 +67,7 @@ export default function PeopleListProvider({ children }: PropsWithChildren) {
list, list,
setList, setList,
}), }),
[list, setList], [list, setList]
); );
return <PeopleListContext.Provider value={context}>{children}</PeopleListContext.Provider>; return <PeopleListContext.Provider value={context}>{children}</PeopleListContext.Provider>;

View File

@ -20,7 +20,6 @@ import { normalizeToHex } from "../../helpers/nip19";
import { getReferences } from "../../helpers/nostr/event"; import { getReferences } from "../../helpers/nostr/event";
import { matchHashtag, mentionNpubOrNote } from "../../helpers/regexp"; import { matchHashtag, mentionNpubOrNote } from "../../helpers/regexp";
import { useWriteRelayUrls } from "../../hooks/use-client-relays"; import { useWriteRelayUrls } from "../../hooks/use-client-relays";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { useSigningContext } from "../../providers/signing-provider"; import { useSigningContext } from "../../providers/signing-provider";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event"; import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { ImageIcon } from "../icons"; import { ImageIcon } from "../icons";
@ -79,7 +78,6 @@ type PostModalProps = {
}; };
export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) => { export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) => {
const isMobile = useIsMobile();
const toast = useToast(); const toast = useToast();
const { requestSignature } = useSigningContext(); const { requestSignature } = useSigningContext();
const writeRelays = useWriteRelayUrls(); const writeRelays = useWriteRelayUrls();
@ -98,7 +96,7 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
const payload = new FormData(); const payload = new FormData();
payload.append("fileToUpload", imageFile); payload.append("fileToUpload", imageFile);
const response = await fetch("https://nostr.build/upload.php", { body: payload, method: "POST" }).then((res) => const response = await fetch("https://nostr.build/upload.php", { body: payload, method: "POST" }).then((res) =>
res.text(), res.text()
); );
const imageUrl = response.match(/https:\/\/nostr\.build\/i\/[\w.]+/)?.[0]; const imageUrl = response.match(/https:\/\/nostr\.build\/i\/[\w.]+/)?.[0];
if (imageUrl) { if (imageUrl) {
@ -193,7 +191,7 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
<Modal isOpen={isOpen} onClose={onClose} size="4xl" closeOnOverlayClick={false}> <Modal isOpen={isOpen} onClose={onClose} size="4xl" closeOnOverlayClick={false}>
<ModalOverlay /> <ModalOverlay />
<ModalContent> <ModalContent>
<ModalBody padding={isMobile ? "2" : "4"}>{renderContent()}</ModalBody> <ModalBody padding={["2", "2", "4"]}>{renderContent()}</ModalBody>
</ModalContent> </ModalContent>
</Modal> </Modal>
); );

View File

@ -28,7 +28,7 @@ function RelayPickerModal({
}: { onSelect: (relay: string) => void } & Omit<ModalProps, "children">) { }: { onSelect: (relay: string) => void } & Omit<ModalProps, "children">) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { value: onlineRelays } = useAsync(async () => const { value: onlineRelays } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>), fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>)
); );
const relayList = unique(onlineRelays ?? []); const relayList = unique(onlineRelays ?? []);
@ -78,7 +78,7 @@ export const RelayUrlInput = forwardRef(
({ onChange, ...props }: Omit<RelayUrlInputProps, "onChange"> & { onChange: (url: string) => void }, ref) => { ({ onChange, ...props }: Omit<RelayUrlInputProps, "onChange"> & { onChange: (url: string) => void }, ref) => {
const { isOpen, onClose, onOpen } = useDisclosure(); const { isOpen, onClose, onOpen } = useDisclosure();
const { value: relaysJson } = useAsync(async () => const { value: relaysJson } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>), fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>)
); );
const relaySuggestions = unique(relaysJson ?? []); const relaySuggestions = unique(relaysJson ?? []);
@ -100,5 +100,5 @@ export const RelayUrlInput = forwardRef(
<RelayPickerModal onClose={onClose} isOpen={isOpen} onSelect={(url) => onChange(url)} size="2xl" /> <RelayPickerModal onClose={onClose} isOpen={isOpen} onSelect={(url) => onChange(url)} size="2xl" />
</> </>
); );
}, }
); );

View File

@ -1,12 +1,21 @@
import { Alert, AlertDescription, AlertIcon, AlertProps, AlertTitle, Button, Spacer, useModal } from "@chakra-ui/react"; import {
import { useIsMobile } from "../hooks/use-is-mobile"; Alert,
AlertDescription,
AlertIcon,
AlertProps,
AlertTitle,
Button,
Spacer,
useBreakpointValue,
useModal,
} from "@chakra-ui/react";
import { useExpand } from "./note/expanded"; import { useExpand } from "./note/expanded";
export default function SensitiveContentWarning({ description }: { description: string } & AlertProps) { export default function SensitiveContentWarning({ description }: { description: string } & AlertProps) {
const isMobile = useIsMobile();
const expand = useExpand(); const expand = useExpand();
const smallScreen = useBreakpointValue({ base: true, md: false });
if (isMobile) { if (smallScreen) {
return ( return (
<Alert <Alert
status="warning" status="warning"

View File

@ -1,10 +1,9 @@
import { useCallback, useRef } from "react"; import { useCallback } from "react";
import { Flex, Grid, SimpleGrid } from "@chakra-ui/react"; import { Flex, SimpleGrid } from "@chakra-ui/react";
import IntersectionObserverProvider from "../../providers/intersection-observer"; import IntersectionObserverProvider from "../../providers/intersection-observer";
import GenericNoteTimeline from "./generic-note-timeline"; import GenericNoteTimeline from "./generic-note-timeline";
import { ImageGalleryProvider } from "../image-gallery"; import { ImageGalleryProvider } from "../image-gallery";
import MediaTimeline from "./media-timeline"; import MediaTimeline from "./media-timeline";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { TimelineLoader } from "../../classes/timeline-loader"; import { TimelineLoader } from "../../classes/timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "./timeline-action-and-status"; import TimelineActionAndStatus from "./timeline-action-and-status";
@ -21,7 +20,7 @@ export function useTimelinePageEventFilter() {
if (view === "images" && !event.content.match(matchImageUrls)) return false; if (view === "images" && !event.content.match(matchImageUrls)) return false;
return true; return true;
}, },
[view], [view]
); );
} }
@ -41,7 +40,7 @@ export default function TimelinePage({ timeline, header }: { timeline: TimelineL
case "images": case "images":
return ( return (
<ImageGalleryProvider> <ImageGalleryProvider>
<SimpleGrid minChildWidth={["full", "15rem"]} gap="4"> <SimpleGrid columns={[1, 2, 2, 3, 4, 5]} gap="4">
<MediaTimeline timeline={timeline} /> <MediaTimeline timeline={timeline} />
</SimpleGrid> </SimpleGrid>
</ImageGalleryProvider> </ImageGalleryProvider>

View File

@ -4,7 +4,6 @@ import useSubject from "../../../hooks/use-subject";
import { matchImageUrls } from "../../../helpers/regexp"; import { matchImageUrls } from "../../../helpers/regexp";
import { ImageGalleryLink, ImageGalleryProvider } from "../../image-gallery"; import { ImageGalleryLink, ImageGalleryProvider } from "../../image-gallery";
import { Box, IconButton } from "@chakra-ui/react"; import { Box, IconButton } from "@chakra-ui/react";
import { useIsMobile } from "../../../hooks/use-is-mobile";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { getSharableNoteId } from "../../../helpers/nip19"; import { getSharableNoteId } from "../../../helpers/nip19";
@ -42,7 +41,6 @@ const ImagePreview = React.memo(({ image }: { image: ImagePreview }) => {
}); });
export default function MediaTimeline({ timeline }: { timeline: TimelineLoader }) { export default function MediaTimeline({ timeline }: { timeline: TimelineLoader }) {
const isMobile = useIsMobile();
const events = useSubject(timeline.timeline); const events = useSubject(timeline.timeline);
const images = useMemo(() => { const images = useMemo(() => {

View File

@ -52,7 +52,7 @@ export function parseStreamEvent(stream: NostrEvent): ParsedStream {
} }
// if the stream has not been updated in a day consider it ended // if the stream has not been updated in a day consider it ended
if (stream.created_at < dayjs().subtract(2, "day").unix()) { if (stream.created_at < dayjs().subtract(1, "week").unix()) {
status = "ended"; status = "ended";
} }

View File

@ -16,7 +16,7 @@ export default function useAppSettings() {
if (e instanceof Error) toast({ description: e.message, status: "error" }); if (e instanceof Error) toast({ description: e.message, status: "error" });
} }
}, },
[settings], [settings]
); );
return { return {

View File

@ -8,7 +8,7 @@ export default function useEventReactions(eventId: string, additionalRelays: str
const subject = useMemo( const subject = useMemo(
() => eventReactionsService.requestReactions(eventId, relays, alwaysFetch), () => eventReactionsService.requestReactions(eventId, relays, alwaysFetch),
[eventId, relays.join("|"), alwaysFetch], [eventId, relays.join("|"), alwaysFetch]
); );
return useSubject(subject); return useSubject(subject);

View File

@ -9,7 +9,7 @@ export default function useEventZaps(eventId: string, additionalRelays: string[]
const subject = useMemo( const subject = useMemo(
() => eventZapsService.requestZaps(eventId, relays, alwaysFetch), () => eventZapsService.requestZaps(eventId, relays, alwaysFetch),
[eventId, relays.join("|"), alwaysFetch], [eventId, relays.join("|"), alwaysFetch]
); );
const events = useSubject(subject) || []; const events = useSubject(subject) || [];

View File

@ -1,14 +0,0 @@
import { useEffect, useMemo, useState } from "react";
export function useIsMobile() {
const match = useMemo(() => window.matchMedia("(max-width: 1000px)"), []);
const [matches, setMatches] = useState(match.matches);
useEffect(() => {
const listener = (e: MediaQueryListEvent) => setMatches(e.matches);
match.addEventListener("change", listener);
return () => match.removeEventListener("change", listener);
}, [match]);
return matches;
}

View File

@ -12,7 +12,7 @@ export function usePaginatedList<T extends unknown>(list: T[], opts?: Options) {
const previous = useCallback(() => setPage((v) => Math.max(v - 1, 0)), [setPage]); const previous = useCallback(() => setPage((v) => Math.max(v - 1, 0)), [setPage]);
const pageItems = useMemo( const pageItems = useMemo(
() => list.slice(pageSize * currentPage, pageSize * currentPage + pageSize), () => list.slice(pageSize * currentPage, pageSize * currentPage + pageSize),
[list, currentPage, pageSize], [list, currentPage, pageSize]
); );
return { return {

View File

@ -22,6 +22,6 @@ export function useTimelineCurserIntersectionCallback(timeline: TimelineLoader)
} }
} }
}, },
[timeline], [timeline]
); );
} }

View File

@ -5,7 +5,7 @@ import useSubject from "./use-subject";
export function useUserContacts(pubkey: string, relays: string[], alwaysRequest = false) { export function useUserContacts(pubkey: string, relays: string[], alwaysRequest = false) {
const observable = useMemo( const observable = useMemo(
() => userContactsService.requestContacts(pubkey, relays, alwaysRequest), () => userContactsService.requestContacts(pubkey, relays, alwaysRequest),
[pubkey, relays.join("|"), alwaysRequest], [pubkey, relays.join("|"), alwaysRequest]
); );
const contacts = useSubject(observable); const contacts = useSubject(observable);

View File

@ -7,7 +7,7 @@ export default function useUserLNURLMetadata(pubkey: string) {
const address = userMetadata?.lud16 || userMetadata?.lud06; const address = userMetadata?.lud16 || userMetadata?.lud06;
const { value: metadata } = useAsync( const { value: metadata } = useAsync(
async () => (address ? lnurlMetadataService.requestMetadata(address) : undefined), async () => (address ? lnurlMetadataService.requestMetadata(address) : undefined),
[address], [address]
); );
return { metadata, address }; return { metadata, address };

View File

@ -8,7 +8,7 @@ export function useUserMetadata(pubkey: string, additionalRelays: string[] = [],
const subject = useMemo( const subject = useMemo(
() => userMetadataService.requestMetadata(pubkey, relays, alwaysRequest), () => userMetadataService.requestMetadata(pubkey, relays, alwaysRequest),
[pubkey, relays, alwaysRequest], [pubkey, relays, alwaysRequest]
); );
const metadata = useSubject(subject); const metadata = useSubject(subject);

View File

@ -7,7 +7,7 @@ export function useUserRelays(pubkey: string, additionalRelays: string[] = [], a
const relays = useReadRelayUrls(additionalRelays); const relays = useReadRelayUrls(additionalRelays);
const subject = useMemo( const subject = useMemo(
() => userRelaysService.requestRelays(pubkey, relays, alwaysRequest), () => userRelaysService.requestRelays(pubkey, relays, alwaysRequest),
[pubkey, relays.join("|"), alwaysRequest], [pubkey, relays.join("|"), alwaysRequest]
); );
const userRelays = useSubject(subject); const userRelays = useSubject(subject);

View File

@ -23,5 +23,5 @@ const root = createRoot(element);
root.render( root.render(
<GlobalProviders> <GlobalProviders>
<App /> <App />
</GlobalProviders>, </GlobalProviders>
); );

View File

@ -38,7 +38,7 @@ const mediaMapper = (item: ImageObject[] | VideoObject[]) => ({
const mediaSorter = ( const mediaSorter = (
a: ImageObject | TwitterImageObject | VideoObject | TwitterPlayerObject, a: ImageObject | TwitterImageObject | VideoObject | TwitterPlayerObject,
b: ImageObject | TwitterImageObject | VideoObject | TwitterPlayerObject, b: ImageObject | TwitterImageObject | VideoObject | TwitterPlayerObject
) => { ) => {
if (!(a.url && b.url)) { if (!(a.url && b.url)) {
return 0; return 0;
@ -114,7 +114,7 @@ export function mediaSetup(ogObject: OgObjectInteral) {
ogObject.ogImageProperty, ogObject.ogImageProperty,
ogObject.ogImageWidth, ogObject.ogImageWidth,
ogObject.ogImageHeight, ogObject.ogImageHeight,
ogObject.ogImageType, ogObject.ogImageType
) )
.map(mediaMapper) .map(mediaMapper)
.filter((value: ImageObject) => value.url !== undefined && value.url !== "") .filter((value: ImageObject) => value.url !== undefined && value.url !== "")
@ -134,7 +134,7 @@ export function mediaSetup(ogObject: OgObjectInteral) {
ogObject.ogVideoProperty, ogObject.ogVideoProperty,
ogObject.ogVideoWidth, ogObject.ogVideoWidth,
ogObject.ogVideoHeight, ogObject.ogVideoHeight,
ogObject.ogVideoType, ogObject.ogVideoType
) )
.map(mediaMapper) .map(mediaMapper)
.filter((value: VideoObject) => value.url !== undefined && value.url !== "") .filter((value: VideoObject) => value.url !== undefined && value.url !== "")
@ -164,7 +164,7 @@ export function mediaSetup(ogObject: OgObjectInteral) {
ogObject.twitterImageProperty, ogObject.twitterImageProperty,
ogObject.twitterImageWidth, ogObject.twitterImageWidth,
ogObject.twitterImageHeight, ogObject.twitterImageHeight,
ogObject.twitterImageAlt, ogObject.twitterImageAlt
) )
.map(mediaMapperTwitterImage) .map(mediaMapperTwitterImage)
.filter((value: TwitterImageObject) => value.url !== undefined && value.url !== "") .filter((value: TwitterImageObject) => value.url !== undefined && value.url !== "")
@ -189,7 +189,7 @@ export function mediaSetup(ogObject: OgObjectInteral) {
ogObject.twitterPlayerProperty, ogObject.twitterPlayerProperty,
ogObject.twitterPlayerWidth, ogObject.twitterPlayerWidth,
ogObject.twitterPlayerHeight, ogObject.twitterPlayerHeight,
ogObject.twitterPlayerStream, ogObject.twitterPlayerStream
) )
.map(mediaMapperTwitterPlayer) .map(mediaMapperTwitterPlayer)
.filter((value: TwitterPlayerObject) => value.url !== undefined && value.url !== "") .filter((value: TwitterPlayerObject) => value.url !== undefined && value.url !== "")

View File

@ -83,7 +83,7 @@ export class QrCode {
minVersion: int = 1, minVersion: int = 1,
maxVersion: int = 40, maxVersion: int = 40,
mask: int = -1, mask: int = -1,
boostEcl: boolean = true, boostEcl: boolean = true
): QrCode { ): QrCode {
if ( if (
!(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION) || !(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION) ||
@ -175,7 +175,7 @@ export class QrCode {
dataCodewords: Readonly<Array<byte>>, dataCodewords: Readonly<Array<byte>>,
msk: int, msk: int
) { ) {
// Check scalar arguments // Check scalar arguments
if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION)
@ -822,7 +822,7 @@ export class QrSegment {
public readonly numChars: int, public readonly numChars: int,
// The data bits of this segment. Accessed through getData(). // The data bits of this segment. Accessed through getData().
private readonly bitData: Array<bit>, private readonly bitData: Array<bit>
) { ) {
if (numChars < 0) throw new RangeError("Invalid argument"); if (numChars < 0) throw new RangeError("Invalid argument");
this.bitData = bitData.slice(); // Make defensive copy this.bitData = bitData.slice(); // Make defensive copy
@ -897,7 +897,7 @@ export class Ecc {
// In the range 0 to 3 (unsigned 2-bit integer). // In the range 0 to 3 (unsigned 2-bit integer).
public readonly ordinal: int, public readonly ordinal: int,
// (Package-private) In the range 0 to 3 (unsigned 2-bit integer). // (Package-private) In the range 0 to 3 (unsigned 2-bit integer).
public readonly formatBits: int, public readonly formatBits: int
) {} ) {}
} }
// } // }
@ -925,7 +925,7 @@ export class Mode {
// The mode indicator bits, which is a uint4 value (range 0 to 15). // The mode indicator bits, which is a uint4 value (range 0 to 15).
public readonly modeBits: int, public readonly modeBits: int,
// Number of character count bits for three different version ranges. // Number of character count bits for three different version ranges.
private readonly numBitsCharCount: [int, int, int], private readonly numBitsCharCount: [int, int, int]
) {} ) {}
/*-- Method --*/ /*-- Method --*/

View File

@ -19,7 +19,7 @@ const IntersectionObserverContext = createContext<{
export type ExtendedIntersectionObserverEntry<T> = { entry: IntersectionObserverEntry; id: T | undefined }; export type ExtendedIntersectionObserverEntry<T> = { entry: IntersectionObserverEntry; id: T | undefined };
export type ExtendedIntersectionObserverCallback<T> = ( export type ExtendedIntersectionObserverCallback<T> = (
entries: ExtendedIntersectionObserverEntry<T>[], entries: ExtendedIntersectionObserverEntry<T>[],
observer: IntersectionObserver, observer: IntersectionObserver
) => void; ) => void;
export function useIntersectionObserver() { export function useIntersectionObserver() {
@ -42,7 +42,7 @@ export function useRegisterIntersectionEntity<T>(ref: MutableRefObject<Element |
export function useIntersectionMapCallback<T>( export function useIntersectionMapCallback<T>(
callback: (map: Map<T, IntersectionObserverEntry>) => void, callback: (map: Map<T, IntersectionObserverEntry>) => void,
watch: DependencyList, watch: DependencyList
) { ) {
const map = useMemo(() => new Map<T, IntersectionObserverEntry>(), []); const map = useMemo(() => new Map<T, IntersectionObserverEntry>(), []);
return useCallback<ExtendedIntersectionObserverCallback<T>>( return useCallback<ExtendedIntersectionObserverCallback<T>>(
@ -53,7 +53,7 @@ export function useIntersectionMapCallback<T>(
callback(map); callback(map);
}, },
[callback, ...watch], [callback, ...watch]
); );
} }
@ -76,11 +76,11 @@ export default function IntersectionObserverProvider<T = undefined>({
entries.map((entry) => { entries.map((entry) => {
return { entry, id: elementIds.get(entry.target) }; return { entry, id: elementIds.get(entry.target) };
}), }),
observer, observer
); );
}, []); }, []);
const [observer, setObserver] = useState<IntersectionObserver>( const [observer, setObserver] = useState<IntersectionObserver>(
() => new IntersectionObserver(handleIntersection, { rootMargin, threshold }), () => new IntersectionObserver(handleIntersection, { rootMargin, threshold })
); );
useMount(() => { useMount(() => {
@ -97,7 +97,7 @@ export default function IntersectionObserverProvider<T = undefined>({
(element: Element, id: T) => { (element: Element, id: T) => {
elementIds.set(element, id); elementIds.set(element, id);
}, },
[elementIds], [elementIds]
); );
const context = useMemo( const context = useMemo(
@ -105,7 +105,7 @@ export default function IntersectionObserverProvider<T = undefined>({
observer, observer,
setElementId, setElementId,
}), }),
[observer, setElementId], [observer, setElementId]
); );
return <IntersectionObserverContext.Provider value={context}>{children}</IntersectionObserverContext.Provider>; return <IntersectionObserverContext.Provider value={context}>{children}</IntersectionObserverContext.Provider>;

View File

@ -20,7 +20,7 @@ export default function PostModalProvider({ children }: PropsWithChildren) {
setDraft(draft); setDraft(draft);
onOpen(); onOpen();
}, },
[setDraft, onOpen], [setDraft, onOpen]
); );
const context = useMemo(() => ({ openModal }), [openModal]); const context = useMemo(() => ({ openModal }), [openModal]);

View File

@ -40,7 +40,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
if (e instanceof Error) toast({ description: e.message, status: "error" }); if (e instanceof Error) toast({ description: e.message, status: "error" });
} }
}, },
[toast, current], [toast, current]
); );
const requestDecrypt = useCallback( const requestDecrypt = useCallback(
async (data: string, pubkey: string) => { async (data: string, pubkey: string) => {
@ -51,7 +51,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
if (e instanceof Error) toast({ description: e.message, status: "error" }); if (e instanceof Error) toast({ description: e.message, status: "error" });
} }
}, },
[toast, current], [toast, current]
); );
const requestEncrypt = useCallback( const requestEncrypt = useCallback(
async (data: string, pubkey: string) => { async (data: string, pubkey: string) => {
@ -62,12 +62,12 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
if (e instanceof Error) toast({ description: e.message, status: "error" }); if (e instanceof Error) toast({ description: e.message, status: "error" });
} }
}, },
[toast, current], [toast, current]
); );
const context = useMemo( const context = useMemo(
() => ({ requestSignature, requestDecrypt, requestEncrypt }), () => ({ requestSignature, requestDecrypt, requestEncrypt }),
[requestSignature, requestDecrypt, requestEncrypt], [requestSignature, requestDecrypt, requestEncrypt]
); );
return <SigningContext.Provider value={context}>{children}</SigningContext.Provider>; return <SigningContext.Provider value={context}>{children}</SigningContext.Provider>;

View File

@ -21,7 +21,7 @@ function handleNewContacts(contacts: UserContacts | undefined) {
const relay = contacts.contactRelay[key]; const relay = contacts.contactRelay[key];
if (relay) return ["p", key, relay]; if (relay) return ["p", key, relay];
else return ["p", key]; else return ["p", key];
}), })
); );
// reset the pending list since we just got a new contacts list // reset the pending list since we just got a new contacts list
@ -98,7 +98,7 @@ function addContact(pubkey: string, relay?: string) {
return newTag; return newTag;
} }
return t; return t;
}), })
); );
} else { } else {
following.next([...pTags, newTag]); following.next([...pTags, newTag]);

View File

@ -23,14 +23,14 @@ class DirectMessagesService {
this.incomingSub = new NostrMultiSubscription( this.incomingSub = new NostrMultiSubscription(
clientRelaysService.getReadUrls(), clientRelaysService.getReadUrls(),
undefined, undefined,
"incoming-direct-messages", "incoming-direct-messages"
); );
this.incomingSub.onEvent.subscribe(this.receiveEvent, this); this.incomingSub.onEvent.subscribe(this.receiveEvent, this);
this.outgoingSub = new NostrMultiSubscription( this.outgoingSub = new NostrMultiSubscription(
clientRelaysService.getReadUrls(), clientRelaysService.getReadUrls(),
undefined, undefined,
"outgoing-direct-messages", "outgoing-direct-messages"
); );
this.outgoingSub.onEvent.subscribe(this.receiveEvent, this); this.outgoingSub.onEvent.subscribe(this.receiveEvent, this);

View File

@ -28,7 +28,7 @@ function getIdentityFromJson(name: string, domain: string, json: IdentityJson):
async function fetchAllIdentities(domain: string) { async function fetchAllIdentities(domain: string) {
const json = await fetchWithCorsFallback(`//${domain}/.well-known/nostr.json`).then( const json = await fetchWithCorsFallback(`//${domain}/.well-known/nostr.json`).then(
(res) => res.json() as Promise<IdentityJson>, (res) => res.json() as Promise<IdentityJson>
); );
await addToCache(domain, json); await addToCache(domain, json);
@ -101,7 +101,7 @@ async function pruneCache() {
const keys = await db.getAllKeysFromIndex( const keys = await db.getAllKeysFromIndex(
"dnsIdentifiers", "dnsIdentifiers",
"updated", "updated",
IDBKeyRange.upperBound(dayjs().subtract(1, "day").unix()), IDBKeyRange.upperBound(dayjs().subtract(1, "day").unix())
); );
for (const pubkey of keys) { for (const pubkey of keys) {

View File

@ -86,7 +86,7 @@ class PubkeyRelayAssignmentService {
if (userRelays.length === 0) userRelays = Array.from(readRelays); if (userRelays.length === 0) userRelays = Array.from(readRelays);
const rankedOptions = Array.from(userRelays).sort( const rankedOptions = Array.from(userRelays).sort(
(a, b) => (relayScores.get(b) ?? 0) - (relayScores.get(a) ?? 0), (a, b) => (relayScores.get(b) ?? 0) - (relayScores.get(a) ?? 0)
); );
assignments[pubkey] = rankedOptions.slice(0, 3); assignments[pubkey] = rankedOptions.slice(0, 3);

View File

@ -17,7 +17,7 @@ async function fetchInfo(relay: string) {
url.protocol = url.protocol === "ws:" ? "http" : "https"; url.protocol = url.protocol === "ws:" ? "http" : "https";
const infoDoc = await fetchWithCorsFallback(url, { headers: { Accept: "application/nostr+json" } }).then( const infoDoc = await fetchWithCorsFallback(url, { headers: { Accept: "application/nostr+json" } }).then(
(res) => res.json() as Promise<RelayInformationDocument>, (res) => res.json() as Promise<RelayInformationDocument>
); );
memoryCache.set(relay, infoDoc); memoryCache.set(relay, infoDoc);

View File

@ -115,7 +115,7 @@ class ReplaceableEventRelayLoader {
`Updating query`, `Updating query`,
Array.from(Object.keys(filters)) Array.from(Object.keys(filters))
.map((kind: string) => `kind ${kind}: ${filters[parseInt(kind)].authors?.length}`) .map((kind: string) => `kind ${kind}: ${filters[parseInt(kind)].authors?.length}`)
.join(", "), .join(", ")
); );
this.subscription.setQuery(query); this.subscription.setQuery(query);
@ -133,7 +133,7 @@ class ReplaceableEventLoaderService {
private events = new SuperMap<Pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>()); private events = new SuperMap<Pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
private loaders = new SuperMap<Relay, ReplaceableEventRelayLoader>( private loaders = new SuperMap<Relay, ReplaceableEventRelayLoader>(
(relay) => new ReplaceableEventRelayLoader(relay, this.log.extend(relay)), (relay) => new ReplaceableEventRelayLoader(relay, this.log.extend(relay))
); );
log = logger.extend("ReplaceableEventLoader"); log = logger.extend("ReplaceableEventLoader");
@ -180,7 +180,7 @@ class ReplaceableEventLoaderService {
const keys = await db.getAllKeysFromIndex( const keys = await db.getAllKeysFromIndex(
"replaceableEvents", "replaceableEvents",
"created", "created",
IDBKeyRange.upperBound(dayjs().subtract(1, "day").unix()), IDBKeyRange.upperBound(dayjs().subtract(1, "day").unix())
); );
this.log(`Pruning ${keys.length} events`); this.log(`Pruning ${keys.length} events`);
@ -243,12 +243,9 @@ replaceableEventLoaderService.pruneCache();
setInterval(() => { setInterval(() => {
replaceableEventLoaderService.update(); replaceableEventLoaderService.update();
}, 1000 * 2); }, 1000 * 2);
setInterval( setInterval(() => {
() => {
replaceableEventLoaderService.pruneCache(); replaceableEventLoaderService.pruneCache();
}, }, 1000 * 60 * 60);
1000 * 60 * 60,
);
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
//@ts-ignore //@ts-ignore

View File

@ -11,7 +11,7 @@ const DTAG = "nostrudel-settings";
class UserAppSettings { class UserAppSettings {
private parsedSubjects = new SuperMap<string, PersistentSubject<AppSettings>>( private parsedSubjects = new SuperMap<string, PersistentSubject<AppSettings>>(
() => new PersistentSubject<AppSettings>(defaultSettings), () => new PersistentSubject<AppSettings>(defaultSettings)
); );
getSubject(pubkey: string) { getSubject(pubkey: string) {
return this.parsedSubjects.get(pubkey); return this.parsedSubjects.get(pubkey);

View File

@ -19,7 +19,7 @@ class SigningService {
private async getKeyMaterial() { private async getKeyMaterial() {
const password = window.prompt( const password = window.prompt(
"Enter local encryption password. This password is used to keep your secret key save.", "Enter local encryption password. This password is used to keep your secret key save."
); );
if (!password) throw new Error("Password required"); if (!password) throw new Error("Password required");
const enc = new TextEncoder(); const enc = new TextEncoder();
@ -38,7 +38,7 @@ class SigningService {
keyMaterial, keyMaterial,
{ name: "AES-GCM", length: 256 }, { name: "AES-GCM", length: 256 },
true, true,
["encrypt", "decrypt"], ["encrypt", "decrypt"]
); );
} }

View File

@ -31,15 +31,12 @@ function parseContacts(event: NostrEvent): UserContacts {
const relays = normalizeRelayConfigs(relayJsonToRelayConfig(relayJson)); const relays = normalizeRelayConfigs(relayJsonToRelayConfig(relayJson));
const pubkeys = event.tags.filter(isPTag).map((tag) => tag[1]); const pubkeys = event.tags.filter(isPTag).map((tag) => tag[1]);
const contactRelay = event.tags.filter(isPTag).reduce( const contactRelay = event.tags.filter(isPTag).reduce((dir, tag) => {
(dir, tag) => {
if (tag[2]) { if (tag[2]) {
dir[tag[1]] = tag[2]; dir[tag[1]] = tag[2];
} }
return dir; return dir;
}, }, {} as Record<string, string>);
{} as Record<string, string>,
);
return { return {
pubkey: event.pubkey, pubkey: event.pubkey,
@ -63,7 +60,7 @@ class UserContactsService {
Kind.Contacts, Kind.Contacts,
pubkey, pubkey,
undefined, undefined,
alwaysRequest, alwaysRequest
); );
sub.connectWithHandler(requestSub, (event, next) => next(parseContacts(event))); sub.connectWithHandler(requestSub, (event, next) => next(parseContacts(event)));

View File

@ -32,7 +32,7 @@ class UserMetadataService {
Kind.Metadata, Kind.Metadata,
pubkey, pubkey,
undefined, undefined,
alwaysRequest, alwaysRequest
); );
sub.connectWithHandler(requestSub, (event, next) => next(parseKind0Event(event))); sub.connectWithHandler(requestSub, (event, next) => next(parseKind0Event(event)));
return sub; return sub;

View File

@ -63,7 +63,7 @@ class UserTrustedStatsService {
async fetchUserStats(pubkey: string) { async fetchUserStats(pubkey: string) {
try { try {
const stats = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`).then( const stats = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`).then(
(res) => res.json() as Promise<{ stats: Record<string, NostrBandProfileStats> }>, (res) => res.json() as Promise<{ stats: Record<string, NostrBandProfileStats> }>
); );
if (stats?.stats[pubkey]) { if (stats?.stats[pubkey]) {

View File

@ -57,13 +57,13 @@ function HashTagPage() {
if (!showReplies && isReply(event)) return false; if (!showReplies && isReply(event)) return false;
return timelinePageEventFilter(event); return timelinePageEventFilter(event);
}, },
[showReplies], [showReplies]
); );
const timeline = useTimelineLoader( const timeline = useTimelineLoader(
`${hashtag}-hashtag`, `${hashtag}-hashtag`,
readRelays, readRelays,
{ kinds: [1], "#t": [hashtag] }, { kinds: [1], "#t": [hashtag] },
{ eventFilter }, { eventFilter }
); );
useRelaysChanged(readRelays, () => timeline.reset()); useRelaysChanged(readRelays, () => timeline.reset());

View File

@ -29,7 +29,7 @@ function FollowingTabBody() {
if (!showReplies && isReply(event)) return false; if (!showReplies && isReply(event)) return false;
return timelinePageEventFilter(event); return timelinePageEventFilter(event);
}, },
[showReplies, timelinePageEventFilter], [showReplies, timelinePageEventFilter]
); );
const following = contacts?.contacts || []; const following = contacts?.contacts || [];
@ -37,7 +37,7 @@ function FollowingTabBody() {
`${truncatedId(account.pubkey)}-following`, `${truncatedId(account.pubkey)}-following`,
readRelays, readRelays,
{ authors: following, kinds: [Kind.Text, Kind.Repost, 2] }, { authors: following, kinds: [Kind.Text, Kind.Repost, 2] },
{ enabled: following.length > 0, eventFilter }, { enabled: following.length > 0, eventFilter }
); );
const header = ( const header = (

View File

@ -24,7 +24,7 @@ function GlobalPage() {
if (!showReplies && isReply(event)) return false; if (!showReplies && isReply(event)) return false;
return timelineEventFilter(event); return timelineEventFilter(event);
}, },
[showReplies, timelineEventFilter], [showReplies, timelineEventFilter]
); );
const timeline = useTimelineLoader(`global`, readRelays, { kinds: [1] }, { eventFilter }); const timeline = useTimelineLoader(`global`, readRelays, { kinds: [1] }, { eventFilter });
useRelaysChanged(readRelays, () => timeline.reset()); useRelaysChanged(readRelays, () => timeline.reset());

View File

@ -51,7 +51,7 @@ export default function LoginNip05View() {
setLoading(false); setLoading(false);
}, },
1000, 1000,
[nip05, setPubkey, setRelays, setLoading], [nip05, setPubkey, setRelays, setLoading]
); );
const handleSubmit: React.FormEventHandler<HTMLDivElement> = (e) => { const handleSubmit: React.FormEventHandler<HTMLDivElement> = (e) => {

View File

@ -62,7 +62,7 @@ export default function LoginNsecView() {
setError(true); setError(true);
} }
}, },
[setInputValue, setHexKey, setNpub, setError], [setInputValue, setHexKey, setNpub, setError]
); );
const handleSubmit: React.FormEventHandler<HTMLDivElement> = async (e) => { const handleSubmit: React.FormEventHandler<HTMLDivElement> = async (e) => {

View File

@ -104,7 +104,7 @@ export default function MapView() {
const hash = ngeohash.encode(center.lat, center.lng, 5); const hash = ngeohash.encode(center.lat, center.lng, 5);
setSearchParams({ hash }, { replace: true }); setSearchParams({ hash }, { replace: true });
}, 1000), }, 1000)
); );
setMap(map); setMap(map);
@ -122,7 +122,7 @@ export default function MapView() {
"geo-events", "geo-events",
readRelays, readRelays,
{ "#g": cells, kinds: [Kind.Text] }, { "#g": cells, kinds: [Kind.Text] },
{ enabled: cells.length > 0 }, { enabled: cells.length > 0 }
); );
const setCellsFromMap = useCallback(() => { const setCellsFromMap = useCallback(() => {
@ -133,7 +133,7 @@ export default function MapView() {
bbox.getWest(), bbox.getWest(),
bbox.getNorth(), bbox.getNorth(),
bbox.getEast(), bbox.getEast(),
getPrecision(map.getZoom()), getPrecision(map.getZoom())
); );
setCells(hashes); setCells(hashes);

View File

@ -1,14 +1,13 @@
import { useState } from "react";
import { Button, Card, CardBody, Flex, IconButton, Textarea } from "@chakra-ui/react"; import { Button, Card, CardBody, Flex, IconButton, Textarea } from "@chakra-ui/react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Kind } from "nostr-tools"; import { Kind } from "nostr-tools";
import { useRef, useState } from "react";
import { Link, Navigate, useParams } from "react-router-dom"; import { Link, Navigate, useParams } from "react-router-dom";
import { nostrPostAction } from "../../classes/nostr-post-action"; import { nostrPostAction } from "../../classes/nostr-post-action";
import { ArrowLeftSIcon } from "../../components/icons"; import { ArrowLeftSIcon } from "../../components/icons";
import { UserAvatar } from "../../components/user-avatar"; import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link"; import { UserLink } from "../../components/user-link";
import { normalizeToHex } from "../../helpers/nip19"; import { normalizeToHex } from "../../helpers/nip19";
import { useIsMobile } from "../../hooks/use-is-mobile";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import { useSigningContext } from "../../providers/signing-provider"; import { useSigningContext } from "../../providers/signing-provider";
import clientRelaysService from "../../services/client-relays"; import clientRelaysService from "../../services/client-relays";
@ -24,7 +23,6 @@ import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status"; import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
function DirectMessageChatPage({ pubkey }: { pubkey: string }) { function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
const isMobile = useIsMobile();
const account = useCurrentAccount()!; const account = useCurrentAccount()!;
const { requestEncrypt, requestSignature } = useSigningContext(); const { requestEncrypt, requestSignature } = useSigningContext();
const [content, setContent] = useState<string>(""); const [content, setContent] = useState<string>("");
@ -76,9 +74,9 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
icon={<ArrowLeftSIcon />} icon={<ArrowLeftSIcon />}
aria-label="Back" aria-label="Back"
to="/dm" to="/dm"
size={isMobile ? "sm" : "md"} size={["sm", "md"]}
/> />
<UserAvatar pubkey={pubkey} size={isMobile ? "sm" : "md"} /> <UserAvatar pubkey={pubkey} size={["sm", "md"]} />
<UserLink pubkey={pubkey} /> <UserLink pubkey={pubkey} />
</CardBody> </CardBody>
</Card> </Card>

View File

@ -23,7 +23,6 @@ import useSubject from "../../hooks/use-subject";
import { useUserMetadata } from "../../hooks/use-user-metadata"; import { useUserMetadata } from "../../hooks/use-user-metadata";
import directMessagesService from "../../services/direct-messages"; import directMessagesService from "../../services/direct-messages";
import { ExternalLinkIcon } from "../../components/icons"; import { ExternalLinkIcon } from "../../components/icons";
import { useIsMobile } from "../../hooks/use-is-mobile";
import RequireCurrentAccount from "../../providers/require-current-account"; import RequireCurrentAccount from "../../providers/require-current-account";
function ContactCard({ pubkey }: { pubkey: string }) { function ContactCard({ pubkey }: { pubkey: string }) {
@ -47,7 +46,6 @@ function ContactCard({ pubkey }: { pubkey: string }) {
} }
function DirectMessagesPage() { function DirectMessagesPage() {
const isMobile = useIsMobile();
const [from, setFrom] = useState(dayjs().subtract(2, "days")); const [from, setFrom] = useState(dayjs().subtract(2, "days"));
const conversations = useSubject(directMessagesService.conversations); const conversations = useSubject(directMessagesService.conversations);
@ -97,7 +95,7 @@ function DirectMessagesPage() {
<Flex direction="column" gap="2" overflowX="hidden" overflowY="auto" height="100%" pt="2" pb="8"> <Flex direction="column" gap="2" overflowX="hidden" overflowY="auto" height="100%" pt="2" pb="8">
<Alert status="info" flexShrink={0}> <Alert status="info" flexShrink={0}>
<AlertIcon /> <AlertIcon />
<Flex direction={isMobile ? "column" : "row"}> <Flex direction={{ base: "column", lg: "row" }}>
<AlertTitle>Give Blowater a try</AlertTitle> <AlertTitle>Give Blowater a try</AlertTitle>
<AlertDescription> <AlertDescription>
<Text> <Text>

View File

@ -3,7 +3,6 @@ import { useState } from "react";
import { ArrowDownSIcon, ArrowUpSIcon } from "../../components/icons"; import { ArrowDownSIcon, ArrowUpSIcon } from "../../components/icons";
import { Note } from "../../components/note"; import { Note } from "../../components/note";
import { countReplies, ThreadItem as ThreadItemData } from "../../helpers/thread"; import { countReplies, ThreadItem as ThreadItemData } from "../../helpers/thread";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { TrustProvider } from "../../providers/trust"; import { TrustProvider } from "../../providers/trust";
export type ThreadItemProps = { export type ThreadItemProps = {
@ -13,7 +12,6 @@ export type ThreadItemProps = {
}; };
export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps) => { export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps) => {
const isMobile = useIsMobile();
const [showReplies, setShowReplies] = useState(initShowReplies ?? post.replies.length === 1); const [showReplies, setShowReplies] = useState(initShowReplies ?? post.replies.length === 1);
const toggle = () => setShowReplies((v) => !v); const toggle = () => setShowReplies((v) => !v);
@ -31,7 +29,7 @@ export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps)
{showReplies ? <ArrowDownSIcon /> : <ArrowUpSIcon />} {showReplies ? <ArrowDownSIcon /> : <ArrowUpSIcon />}
</Button> </Button>
{showReplies && ( {showReplies && (
<Flex direction="column" gap="2" pl={isMobile ? 2 : 4} borderLeftColor="gray.500" borderLeftWidth="1px"> <Flex direction="column" gap="2" pl={[2, 2, 4]} borderLeftColor="gray.500" borderLeftWidth="1px">
{post.replies.map((child) => ( {post.replies.map((child) => (
<ThreadPost key={child.event.id} post={child} focusId={focusId} /> <ThreadPost key={child.event.id} post={child} focusId={focusId} />
))} ))}

View File

@ -206,7 +206,7 @@ export const ProfileEditView = () => {
nip05: metadata?.nip05, nip05: metadata?.nip05,
lightningAddress: metadata?.lud16 || metadata?.lud06, lightningAddress: metadata?.lud16 || metadata?.lud06,
}), }),
[metadata], [metadata]
); );
const handleSubmit = async (data: FormData) => { const handleSubmit = async (data: FormData) => {

View File

@ -25,7 +25,7 @@ export default function RelaysView() {
.map((r) => r.url) .map((r) => r.url)
.filter(safeRelayUrl); .filter(safeRelayUrl);
const { value: onlineRelays = [] } = useAsync(async () => const { value: onlineRelays = [] } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>), fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>)
); );
const filteredRelays = useMemo(() => { const filteredRelays = useMemo(() => {
@ -51,9 +51,9 @@ export default function RelaysView() {
Add Custom Add Custom
</Button> </Button>
</Flex> </Flex>
<SimpleGrid minChildWidth="25rem" spacing="2"> <SimpleGrid columns={[1, 1, 1, 2, 3]} spacing="2">
{filteredRelays.map((url) => ( {filteredRelays.map((url) => (
<RelayCard key={url} url={url} variant="outline" maxW="xl" /> <RelayCard key={url} url={url} variant="outline" />
))} ))}
</SimpleGrid> </SimpleGrid>
@ -61,9 +61,9 @@ export default function RelaysView() {
<> <>
<Divider /> <Divider />
<Heading size="lg">Discovered Relays</Heading> <Heading size="lg">Discovered Relays</Heading>
<SimpleGrid minChildWidth="25rem" spacing="2"> <SimpleGrid columns={[1, 1, 1, 2, 3]} spacing="2">
{discoveredRelays.map((url) => ( {discoveredRelays.map((url) => (
<RelayCard key={url} url={url} variant="outline" maxW="xl" /> <RelayCard key={url} url={url} variant="outline" />
))} ))}
</SimpleGrid> </SimpleGrid>
</> </>

View File

@ -105,7 +105,11 @@ function RelayPage({ relay }: { relay: string }) {
<RelayJoinAction url={relay} /> <RelayJoinAction url={relay} />
</Flex> </Flex>
<RelayMetadata url={relay} /> <RelayMetadata url={relay} />
<Flex gap="2">{info?.supported_nips?.map((nip) => <NipTag key={nip} nip={nip} />)}</Flex> <Flex gap="2">
{info?.supported_nips?.map((nip) => (
<NipTag key={nip} nip={nip} />
))}
</Flex>
<Tabs display="flex" flexDirection="column" flexGrow="1" isLazy colorScheme="brand"> <Tabs display="flex" flexDirection="column" flexGrow="1" isLazy colorScheme="brand">
<TabList overflowX="auto" overflowY="hidden" flexShrink={0}> <TabList overflowX="auto" overflowY="hidden" flexShrink={0}>
<Tab>Reviews</Tab> <Tab>Reviews</Tab>

View File

@ -87,7 +87,7 @@ function SearchResults({ search }: { search: string }) {
`search`, `search`,
searchRelays, searchRelays,
{ search: search || "", kinds: [Kind.Metadata] }, { search: search || "", kinds: [Kind.Metadata] },
{ enabled: !!search }, { enabled: !!search }
); );
const events = useSubject(timeline?.timeline) ?? []; const events = useSubject(timeline?.timeline) ?? [];
@ -96,7 +96,7 @@ function SearchResults({ search }: { search: string }) {
return ( return (
<IntersectionObserverProvider callback={callback}> <IntersectionObserverProvider callback={callback}>
<SimpleGrid minChildWidth="30rem" spacing="2"> <SimpleGrid columns={{ base: 1, xl: 2 }} spacing="2">
{events.map((event) => ( {events.map((event) => (
<ProfileResult key={event.id} event={event} /> <ProfileResult key={event.id} event={event} />
))} ))}
@ -148,17 +148,19 @@ export function SearchPage() {
}; };
return ( return (
<Flex direction="column" py="2" gap="2"> <Flex direction="column" py="2" px={["2", "2", 0]} gap="2">
<QrScannerModal isOpen={qrScannerModal.isOpen} onClose={qrScannerModal.onClose} onData={handleSearchText} /> <QrScannerModal isOpen={qrScannerModal.isOpen} onClose={qrScannerModal.onClose} onData={handleSearchText} />
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Flex gap="2"> <Flex gap="2" wrap="wrap">
<Flex gap="2" grow={1}>
<IconButton onClick={qrScannerModal.onOpen} icon={<QrCodeIcon />} aria-label="Qr Scanner" /> <IconButton onClick={qrScannerModal.onOpen} icon={<QrCodeIcon />} aria-label="Qr Scanner" />
{!!navigator.clipboard.readText && ( {!!navigator.clipboard.readText && (
<IconButton onClick={readClipboard} icon={<ClipboardIcon />} aria-label="Read clipboard" /> <IconButton onClick={readClipboard} icon={<ClipboardIcon />} aria-label="Read clipboard" />
)} )}
<Input type="search" value={searchInput} onChange={(e) => setSearchInput(e.target.value)} /> <Input type="search" value={searchInput} onChange={(e) => setSearchInput(e.target.value)} />
<Button type="submit">Search</Button> <Button type="submit">Search</Button>
</Flex>
<RelaySelectionButton /> <RelaySelectionButton />
</Flex> </Flex>
</form> </form>

View File

@ -27,7 +27,7 @@ function StreamsPage() {
} catch (e) {} } catch (e) {}
return false; return false;
}, },
[filterStatus], [filterStatus]
); );
const { people } = usePeopleListContext(); const { people } = usePeopleListContext();
@ -49,18 +49,18 @@ function StreamsPage() {
return ( return (
<Flex p="2" gap="2" overflow="hidden" direction="column"> <Flex p="2" gap="2" overflow="hidden" direction="column">
<Flex gap="2"> <Flex gap="2" wrap="wrap">
<PeopleListSelection maxW="sm" /> <PeopleListSelection w={["full", "xs"]} />
<Select maxW="sm" value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)}> <Select w={["full", "xs"]} value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)}>
<option value="live">Live</option> <option value="live">Live</option>
<option value="ended">Ended</option> <option value="ended">Ended</option>
</Select> </Select>
<RelaySelectionButton ml="auto" /> <RelaySelectionButton ml="auto" />
</Flex> </Flex>
<IntersectionObserverProvider callback={callback}> <IntersectionObserverProvider callback={callback}>
<SimpleGrid minChildWidth={["full", "20rem"]} spacing="2"> <SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
{streams.map((stream) => ( {streams.map((stream) => (
<StreamCard key={stream.event.id} stream={stream} maxW="lg" /> <StreamCard key={stream.event.id} stream={stream} />
))} ))}
</SimpleGrid> </SimpleGrid>
<TimelineActionAndStatus timeline={timeline} /> <TimelineActionAndStatus timeline={timeline} />

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useScroll } from "react-use"; import { useScroll } from "react-use";
import { Box, Button, ButtonGroup, Flex, Heading, Spacer, Spinner, Text } from "@chakra-ui/react"; import { Box, Button, ButtonGroup, Flex, Heading, Spacer, Spinner, Text, useBreakpointValue } from "@chakra-ui/react";
import { useParams, Navigate, useSearchParams, useNavigate } from "react-router-dom"; import { useParams, Navigate, useSearchParams, useNavigate } from "react-router-dom";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { Global, css } from "@emotion/react"; import { Global, css } from "@emotion/react";
@ -12,7 +12,6 @@ import { LiveVideoPlayer } from "../../../components/live-video-player";
import StreamChat, { ChatDisplayMode } from "./stream-chat"; import StreamChat, { ChatDisplayMode } from "./stream-chat";
import { UserAvatarLink } from "../../../components/user-avatar-link"; import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link"; import { UserLink } from "../../../components/user-link";
import { useIsMobile } from "../../../hooks/use-is-mobile";
import StreamSummaryContent from "../components/stream-summary-content"; import StreamSummaryContent from "../components/stream-summary-content";
import { ArrowDownSIcon, ArrowUpSIcon, ExternalLinkIcon } from "../../../components/icons"; import { ArrowDownSIcon, ArrowUpSIcon, ExternalLinkIcon } from "../../../components/icons";
import useSetColorMode from "../../../hooks/use-set-color-mode"; import useSetColorMode from "../../../hooks/use-set-color-mode";
@ -24,7 +23,7 @@ import RelaySelectionButton from "../../../components/relay-selection/relay-sele
import RelaySelectionProvider from "../../../providers/relay-selection-provider"; import RelaySelectionProvider from "../../../providers/relay-selection-provider";
function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode?: ChatDisplayMode }) { function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode?: ChatDisplayMode }) {
const isMobile = useIsMobile(); const vertical = useBreakpointValue({ base: true, lg: false });
const scrollBox = useRef<HTMLDivElement | null>(null); const scrollBox = useRef<HTMLDivElement | null>(null);
const scrollState = useScroll(scrollBox); const scrollState = useScroll(scrollBox);
const navigate = useNavigate(); const navigate = useNavigate();
@ -47,8 +46,8 @@ function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode
return ( return (
<ButtonGroup> <ButtonGroup>
{isMobile && toggleButton} {vertical && toggleButton}
{!isMobile && ( {!vertical && (
<CopyIconButton <CopyIconButton
text={location.href + "?displayMode=log&colorMode=dark"} text={location.href + "?displayMode=log&colorMode=dark"}
aria-label="Copy chat log URL" aria-label="Copy chat log URL"
@ -78,9 +77,9 @@ function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode
h="full" h="full"
overflowX="hidden" overflowX="hidden"
overflowY="auto" overflowY="auto"
direction={isMobile ? "column" : "row"} direction={vertical ? "column" : "row"}
p={isMobile || !!displayMode ? 0 : "2"} p={vertical || !!displayMode ? 0 : "2"}
gap={isMobile ? 0 : "4"} gap={vertical ? 0 : "4"}
ref={scrollBox} ref={scrollBox}
> >
{displayMode && ( {displayMode && (
@ -93,14 +92,14 @@ function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode
/> />
)} )}
{!displayMode && ( {!displayMode && (
<Flex gap={isMobile ? "2" : "4"} direction="column" flexGrow={isMobile ? 0 : 1}> <Flex gap={vertical ? "2" : "4"} direction="column" flexGrow={vertical ? 0 : 1}>
<LiveVideoPlayer <LiveVideoPlayer
stream={stream.streaming || stream.recording} stream={stream.streaming || stream.recording}
autoPlay={!!stream.streaming} autoPlay={!!stream.streaming}
poster={stream.image} poster={stream.image}
maxH="100vh" maxH="100vh"
/> />
<Flex gap={isMobile ? "2" : "4"} alignItems="center" p={isMobile ? "2" : 0}> <Flex gap={vertical ? "2" : "4"} alignItems="center" p={vertical ? "2" : 0}>
<UserAvatarLink pubkey={stream.host} noProxy /> <UserAvatarLink pubkey={stream.host} noProxy />
<Box> <Box>
<Heading size="md"> <Heading size="md">
@ -113,15 +112,15 @@ function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode
<RelaySelectionButton /> <RelaySelectionButton />
<Button onClick={() => navigate(-1)}>Back</Button> <Button onClick={() => navigate(-1)}>Back</Button>
</Flex> </Flex>
<StreamSummaryContent stream={stream} px={isMobile ? "2" : 0} /> <StreamSummaryContent stream={stream} px={vertical ? "2" : 0} />
</Flex> </Flex>
)} )}
<StreamChat <StreamChat
stream={stream} stream={stream}
flexGrow={1} flexGrow={1}
maxW={isMobile || !!displayMode ? undefined : "lg"} maxW={vertical || !!displayMode ? undefined : "lg"}
maxH="100vh" maxH="100vh"
minH={isMobile ? "100vh" : undefined} minH={vertical ? "100vh" : undefined}
flexShrink={0} flexShrink={0}
actions={renderActions()} actions={renderActions()}
displayMode={displayMode} displayMode={displayMode}
@ -152,7 +151,7 @@ export default function StreamView() {
parsed.data.kind, parsed.data.kind,
parsed.data.pubkey, parsed.data.pubkey,
parsed.data.identifier, parsed.data.identifier,
true, true
); );
} catch (e) { } catch (e) {
console.log(e); console.log(e);

View File

@ -71,7 +71,7 @@ export default function StreamChat({
const muteList = useUserMuteList(account?.pubkey); const muteList = useUserMuteList(account?.pubkey);
const mutedPubkeys = useMemo( const mutedPubkeys = useMemo(
() => [...(hostMuteList?.tags ?? []), ...(muteList?.tags ?? [])].filter(isPTag).map((t) => t[1] as string), () => [...(hostMuteList?.tags ?? []), ...(muteList?.tags ?? [])].filter(isPTag).map((t) => t[1] as string),
[hostMuteList, muteList], [hostMuteList, muteList]
); );
const eventFilter = useCallback((event: NostrEvent) => !mutedPubkeys.includes(event.pubkey), [mutedPubkeys]); const eventFilter = useCallback((event: NostrEvent) => !mutedPubkeys.includes(event.pubkey), [mutedPubkeys]);
@ -82,7 +82,7 @@ export default function StreamChat({
"#a": [getATag(stream)], "#a": [getATag(stream)],
kinds: [STREAM_CHAT_MESSAGE_KIND, Kind.Zap], kinds: [STREAM_CHAT_MESSAGE_KIND, Kind.Zap],
}, },
{ eventFilter }, { eventFilter }
); );
const events = useSubject(timeline.timeline).sort((a, b) => b.created_at - a.created_at); const events = useSubject(timeline.timeline).sort((a, b) => b.created_at - a.created_at);
@ -153,7 +153,7 @@ export default function StreamChat({
<ChatMessage key={event.id} event={event} stream={stream} /> <ChatMessage key={event.id} event={event} stream={stream} />
) : ( ) : (
<ZapMessage key={event.id} zap={event} stream={stream} /> <ZapMessage key={event.id} zap={event} stream={stream} />
), )
)} )}
</Flex> </Flex>
{!isChatLog && ( {!isChatLog && (

View File

@ -1,4 +1,4 @@
import { Flex, Heading, IconButton, Spacer } from "@chakra-ui/react"; import { Flex, Heading, IconButton, Spacer, useBreakpointValue } from "@chakra-ui/react";
import { useNavigate, Link as RouterLink } from "react-router-dom"; import { useNavigate, Link as RouterLink } from "react-router-dom";
import { ChatIcon, EditIcon } from "../../../components/icons"; import { ChatIcon, EditIcon } from "../../../components/icons";
import { UserAvatar } from "../../../components/user-avatar"; import { UserAvatar } from "../../../components/user-avatar";
@ -8,7 +8,6 @@ import { UserTipButton } from "../../../components/user-tip-button";
import { Bech32Prefix, normalizeToBech32 } from "../../../helpers/nip19"; import { Bech32Prefix, normalizeToBech32 } from "../../../helpers/nip19";
import { getUserDisplayName } from "../../../helpers/user-metadata"; import { getUserDisplayName } from "../../../helpers/user-metadata";
import { useCurrentAccount } from "../../../hooks/use-current-account"; import { useCurrentAccount } from "../../../hooks/use-current-account";
import { useIsMobile } from "../../../hooks/use-is-mobile";
import { useUserMetadata } from "../../../hooks/use-user-metadata"; import { useUserMetadata } from "../../../hooks/use-user-metadata";
import { UserProfileMenu } from "./user-profile-menu"; import { UserProfileMenu } from "./user-profile-menu";
@ -19,7 +18,6 @@ export default function Header({
pubkey: string; pubkey: string;
showRelaySelectionModal: () => void; showRelaySelectionModal: () => void;
}) { }) {
const isMobile = useIsMobile();
const navigate = useNavigate(); const navigate = useNavigate();
const metadata = useUserMetadata(pubkey); const metadata = useUserMetadata(pubkey);
const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey); const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey);
@ -27,6 +25,8 @@ export default function Header({
const account = useCurrentAccount(); const account = useCurrentAccount();
const isSelf = pubkey === account?.pubkey; const isSelf = pubkey === account?.pubkey;
const showFullNip05 = useBreakpointValue({ base: false, md: true });
return ( return (
<Flex direction="column" gap="2" px="2" pt="2"> <Flex direction="column" gap="2" px="2" pt="2">
<Flex gap="2" alignItems="center"> <Flex gap="2" alignItems="center">
@ -34,7 +34,7 @@ export default function Header({
<Heading size="md" isTruncated> <Heading size="md" isTruncated>
{getUserDisplayName(metadata, pubkey)} {getUserDisplayName(metadata, pubkey)}
</Heading> </Heading>
<UserDnsIdentityIcon pubkey={pubkey} onlyIcon={isMobile} /> <UserDnsIdentityIcon pubkey={pubkey} onlyIcon={showFullNip05} />
<Spacer /> <Spacer />
{isSelf && ( {isSelf && (
<IconButton <IconButton

View File

@ -47,7 +47,7 @@ export default function UserFollowersTab() {
return ( return (
<IntersectionObserverProvider callback={callback}> <IntersectionObserverProvider callback={callback}>
<SimpleGrid minChildWidth={["full", "4in"]} spacing="2" py="2"> <SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2" py="2">
{followers.map((event) => ( {followers.map((event) => (
<FollowerItem key={event.pubkey} event={event} /> <FollowerItem key={event.pubkey} event={event} />
))} ))}

View File

@ -17,7 +17,7 @@ export default function UserFollowingTab() {
if (!contacts) return <Spinner />; if (!contacts) return <Spinner />;
return ( return (
<SimpleGrid minChildWidth={["full", "4in"]} spacing="2" py="2"> <SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2" py="2">
{people.map((pubkey) => ( {people.map((pubkey) => (
<UserCard key={pubkey} pubkey={pubkey} relay={contacts?.contactRelay[pubkey]} /> <UserCard key={pubkey} pubkey={pubkey} relay={contacts?.contactRelay[pubkey]} />
))} ))}

View File

@ -25,7 +25,7 @@ export default function UserNotesTab() {
if (hideReposts && isRepost(event)) return false; if (hideReposts && isRepost(event)) return false;
return timelineEventFilter(event); return timelineEventFilter(event);
}, },
[showReplies, hideReposts, timelineEventFilter], [showReplies, hideReposts, timelineEventFilter]
); );
const timeline = useTimelineLoader( const timeline = useTimelineLoader(
truncatedId(pubkey) + "-notes", truncatedId(pubkey) + "-notes",
@ -34,7 +34,7 @@ export default function UserNotesTab() {
authors: [pubkey], authors: [pubkey],
kinds: [Kind.Text, Kind.Repost, STREAM_KIND, 2], kinds: [Kind.Text, Kind.Repost, STREAM_KIND, 2],
}, },
{ eventFilter }, { eventFilter }
); );
const header = ( const header = (

View File

@ -31,9 +31,9 @@ export default function UserStreamsTab() {
return ( return (
<Flex p="2" gap="2" overflow="hidden" direction="column"> <Flex p="2" gap="2" overflow="hidden" direction="column">
<IntersectionObserverProvider<string> callback={callback}> <IntersectionObserverProvider<string> callback={callback}>
<SimpleGrid minChildWidth="20rem" spacing="2"> <SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
{streams.map((stream) => ( {streams.map((stream) => (
<StreamCard key={stream.event.id} stream={stream} maxW="lg" /> <StreamCard key={stream.event.id} stream={stream} />
))} ))}
</SimpleGrid> </SimpleGrid>
<TimelineActionAndStatus timeline={timeline} /> <TimelineActionAndStatus timeline={timeline} />

View File

@ -87,14 +87,14 @@ const UserZapsTab = () => {
} }
return true; return true;
}, },
[filter], [filter]
); );
const timeline = useTimelineLoader( const timeline = useTimelineLoader(
`${truncatedId(pubkey)}-zaps`, `${truncatedId(pubkey)}-zaps`,
relays, relays,
{ "#p": [pubkey], kinds: [9735] }, { "#p": [pubkey], kinds: [9735] },
{ eventFilter }, { eventFilter }
); );
const events = useSubject(timeline.timeline); const events = useSubject(timeline.timeline);