add relay selection to global feed

This commit is contained in:
hzrd149
2023-02-07 17:04:18 -06:00
parent 14851eaafe
commit 2fda4b2e9e
16 changed files with 295 additions and 57 deletions

View File

@@ -29,13 +29,7 @@ export class NostrSubscription {
this.relays = relayUrls.map((url) => relayPool.requestRelay(url)); this.relays = relayUrls.map((url) => relayPool.requestRelay(url));
} }
handleOpen(relay: Relay) { private handleEvent(event: IncomingEvent) {
if (this.query) {
// when the relay connects send the req event
relay.send(["REQ", this.id, this.query]);
}
}
handleEvent(event: IncomingEvent) {
if (this.state === NostrSubscription.OPEN && event.subId === this.id && !this.seenEvents.has(event.body.id)) { if (this.state === NostrSubscription.OPEN && event.subId === this.id && !this.seenEvents.has(event.body.id)) {
this.onEvent.next(event.body); this.onEvent.next(event.body);
this.seenEvents.add(event.body.id); this.seenEvents.add(event.body.id);
@@ -47,12 +41,13 @@ export class NostrSubscription {
} }
} }
cleanup: SubscriptionLike[] = []; private cleanup = new Map<Relay, SubscriptionLike>();
/** listen for event and open events from relays */ /** listen for event and open events from relays */
private subscribeToRelays() { private subscribeToRelays() {
for (const relay of this.relays) { for (const relay of this.relays) {
this.cleanup.push(relay.onEvent.subscribe(this.handleEvent.bind(this))); if (!this.cleanup.has(relay)) {
this.cleanup.push(relay.onOpen.subscribe(this.handleOpen.bind(this))); this.cleanup.set(relay, relay.onEvent.subscribe(this.handleEvent.bind(this)));
}
} }
for (const url of this.relayUrls) { for (const url of this.relayUrls) {
@@ -60,8 +55,9 @@ export class NostrSubscription {
} }
} }
/** listen for event and open events from relays */ /** listen for event and open events from relays */
private unsubscribeToRelays() { private unsubscribeFromRelays() {
this.cleanup.forEach((sub) => sub.unsubscribe()); this.cleanup.forEach((sub) => sub.unsubscribe());
this.cleanup.clear();
for (const url of this.relayUrls) { for (const url of this.relayUrls) {
relayPool.removeClaim(url, this); relayPool.removeClaim(url, this);
@@ -83,7 +79,7 @@ export class NostrSubscription {
return this; return this;
} }
update(query: NostrQuery) { setQuery(query: NostrQuery) {
this.query = query; this.query = query;
if (this.state === NostrSubscription.OPEN) { if (this.state === NostrSubscription.OPEN) {
this.send(["REQ", this.id, this.query]); this.send(["REQ", this.id, this.query]);
@@ -91,13 +87,35 @@ export class NostrSubscription {
return this; return this;
} }
setRelays(relays: string[]) { setRelays(relays: string[]) {
this.unsubscribeToRelays(); this.unsubscribeFromRelays();
const newRelays = relays.map((url) => relayPool.requestRelay(url));
// get new relays for (const relay of this.relays) {
if (!newRelays.includes(relay)) {
// if the subscription is open and the relay is connected
if (this.state === NostrSubscription.OPEN && relay.connected) {
// close the connection to this relay
relay.send(["CLOSE", this.id]);
}
}
}
for (const relay of newRelays) {
if (!this.relays.includes(relay)) {
// if the subscription is open and it has a query
if (this.state === NostrSubscription.OPEN && this.query) {
// open a connection to this relay
relay.send(["REQ", this.id, this.query]);
}
}
}
// set new relays
this.relayUrls = relays; this.relayUrls = relays;
this.relays = relays.map((url) => relayPool.requestRelay(url)); this.relays = newRelays;
this.subscribeToRelays(); if (this.state === NostrSubscription.OPEN) {
this.subscribeToRelays();
}
} }
close() { close() {
if (this.state !== NostrSubscription.OPEN) return this; if (this.state !== NostrSubscription.OPEN) return this;
@@ -109,7 +127,7 @@ export class NostrSubscription {
// forget all seen events // forget all seen events
this.seenEvents.clear(); this.seenEvents.clear();
// unsubscribe from relay messages // unsubscribe from relay messages
this.unsubscribeToRelays(); this.unsubscribeFromRelays();
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.info(`Subscription: "${this.name || this.id}" closed`); console.info(`Subscription: "${this.name || this.id}" closed`);
@@ -117,4 +135,8 @@ export class NostrSubscription {
return this; return this;
} }
forgetEvents() {
// forget all seen events
this.seenEvents.clear();
}
} }

View File

@@ -68,7 +68,7 @@ export class ThreadLoader {
private updateSubscription() { private updateSubscription() {
if (this.rootId.value) { if (this.rootId.value) {
this.subscription.update({ "#e": [this.rootId.value], kinds: [1] }); this.subscription.setQuery({ "#e": [this.rootId.value], kinds: [1] });
if (this.subscription.state !== NostrSubscription.OPEN) { if (this.subscription.state !== NostrSubscription.OPEN) {
this.subscription.open(); this.subscription.open();
} }

View File

@@ -39,7 +39,12 @@ export class TimelineLoader {
if (!query.since) throw new Error('Timeline requires "since" to be set in query'); if (!query.since) throw new Error('Timeline requires "since" to be set in query');
this.query = query; this.query = query;
this.subscription.update(query); this.subscription.setQuery(query);
}
setRelays(relays: string[]) {
this.relays = relays;
this.subscription.setRelays(relays);
} }
private handleEvent(event: NostrEvent) { private handleEvent(event: NostrEvent) {
@@ -78,9 +83,10 @@ export class TimelineLoader {
this.page.next(this.page.value + 1); this.page.next(this.page.value + 1);
} }
reset() { forgetEvents() {
this.events.next([]); this.events.next([]);
this.seenEvents.clear(); this.seenEvents.clear();
this.subscription.forgetEvents();
} }
open() { open() {
this.subscription.open(); this.subscription.open();

View File

@@ -0,0 +1,24 @@
import { Input, InputProps } from "@chakra-ui/react";
import { useAsync } from "react-use";
export type RelayUrlInputProps = Omit<InputProps, "type">;
export const RelayUrlInput = ({ ...props }: RelayUrlInputProps) => {
const { value: relaysJson, loading: loadingRelaysJson } = useAsync(async () =>
fetch("/relays.json").then((res) => res.json() as Promise<{ relays: string[] }>)
);
const relaySuggestions = relaysJson?.relays ?? [];
return (
<>
<Input list="relay-suggestions" type="url" isDisabled={props.isDisabled ?? loadingRelaysJson} {...props} />
<datalist id="relay-suggestions">
{relaySuggestions.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</datalist>
</>
);
};

View File

@@ -17,7 +17,7 @@ export function useSubscription(query: NostrQuery, opts?: Options) {
useDeepCompareEffect(() => { useDeepCompareEffect(() => {
if (sub.current) { if (sub.current) {
sub.current.update(query); sub.current.setQuery(query);
if (opts?.enabled ?? true) sub.current.open(); if (opts?.enabled ?? true) sub.current.open();
else sub.current.close(); else sub.current.close();
} }

View File

@@ -1,25 +1,27 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { useUnmount } from "react-use"; import { useDeepCompareEffect, useUnmount } from "react-use";
import { NostrQueryWithStart, TimelineLoader, TimelineLoaderOptions } from "../classes/timeline-loader"; import { NostrQueryWithStart, TimelineLoader, TimelineLoaderOptions } from "../classes/timeline-loader";
import settings from "../services/settings";
import useSubject from "./use-subject"; import useSubject from "./use-subject";
type Options = TimelineLoaderOptions & { type Options = TimelineLoaderOptions & {
enabled?: boolean; enabled?: boolean;
}; };
export function useTimelineLoader(key: string, query: NostrQueryWithStart, opts?: Options) { export function useTimelineLoader(key: string, relays: string[], query: NostrQueryWithStart, opts?: Options) {
const relays = useSubject(settings.relays);
if (opts && !opts.name) opts.name = key; if (opts && !opts.name) opts.name = key;
const ref = useRef<TimelineLoader | null>(null); const ref = useRef<TimelineLoader | null>(null);
const loader = (ref.current = ref.current || new TimelineLoader(relays, query, opts)); const loader = (ref.current = ref.current || new TimelineLoader(relays, query, opts));
useEffect(() => { useEffect(() => {
loader.reset(); loader.forgetEvents();
loader.setQuery(query); loader.setQuery(query);
}, [key]); }, [key]);
useDeepCompareEffect(() => {
loader.setRelays(relays);
}, [relays]);
const enabled = opts?.enabled ?? true; const enabled = opts?.enabled ?? true;
useEffect(() => { useEffect(() => {
if (enabled) { if (enabled) {

View File

@@ -71,7 +71,7 @@ function flushRequests() {
const query: NostrQuery = { authors: Array.from(pubkeys), kinds: [3] }; const query: NostrQuery = { authors: Array.from(pubkeys), kinds: [3] };
subscription.setRelays(Array.from(relays)); subscription.setRelays(Array.from(relays));
subscription.update(query); subscription.setQuery(query);
if (subscription.state !== NostrSubscription.OPEN) { if (subscription.state !== NostrSubscription.OPEN) {
subscription.open(); subscription.open();
} }

View File

@@ -55,7 +55,7 @@ function flushRequests() {
const query: NostrQuery = { kinds: [3], "#p": Array.from(pubkeys) }; const query: NostrQuery = { kinds: [3], "#p": Array.from(pubkeys) };
subscription.setRelays(Array.from(relays)); subscription.setRelays(Array.from(relays));
subscription.update(query); subscription.setQuery(query);
if (subscription.state !== NostrSubscription.OPEN) { if (subscription.state !== NostrSubscription.OPEN) {
subscription.open(); subscription.open();
} }

View File

@@ -56,7 +56,7 @@ function flushRequests() {
const query: NostrQuery = { authors: Array.from(pubkeys), kinds: [0] }; const query: NostrQuery = { authors: Array.from(pubkeys), kinds: [0] };
subscription.setRelays(Array.from(relays)); subscription.setRelays(Array.from(relays));
subscription.update(query); subscription.setQuery(query);
if (subscription.state !== NostrSubscription.OPEN) { if (subscription.state !== NostrSubscription.OPEN) {
subscription.open(); subscription.open();
} }

View File

@@ -0,0 +1,145 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Button,
Flex,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
ModalProps,
Switch,
} from "@chakra-ui/react";
import { useState } from "react";
import { useList } from "react-use";
import { RelayUrlInput } from "../../../components/relay-url-input";
import { unique } from "../../../helpers/array";
import useSubject from "../../../hooks/use-subject";
import settings from "../../../services/settings";
const CustomRelayForm = ({ onSubmit }: { onSubmit: (url: string) => void }) => {
const [customRelay, setCustomRelay] = useState("");
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit(customRelay);
setCustomRelay("");
}}
>
<Flex gap="2">
<RelayUrlInput
size="sm"
placeholder="wss://relay.example.com"
value={customRelay}
onChange={(e) => setCustomRelay(e.target.value)}
/>
<Button size="sm" type="submit">
Add
</Button>
</Flex>
</form>
);
};
export type FilterValues = {
relays: string[];
};
export type FeedFiltersProps = {
isOpen: boolean;
onClose: ModalProps["onClose"];
values: FilterValues;
onSave: (values: FilterValues) => void;
};
export const FeedFilters = ({ isOpen, onClose, values }: FeedFiltersProps) => {
const defaultRelays = useSubject(settings.relays);
const [selectedRelays, relayActions] = useList(values.relays);
const availableRelays = unique([...defaultRelays, ...selectedRelays]);
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Filters</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Accordion allowToggle>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Relays
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
{availableRelays.map((url) => (
<Box key={url}>
<FormLabel>
<Switch
size="sm"
mr="2"
isChecked={selectedRelays.includes(url)}
onChange={() =>
selectedRelays.includes(url)
? relayActions.removeAt(selectedRelays.indexOf(url))
: relayActions.push(url)
}
/>
{url}
</FormLabel>
</Box>
))}
<Flex gap="2">
<Button size="sm" onClick={() => relayActions.set(defaultRelays)}>
Select All
</Button>
<CustomRelayForm onSubmit={(url) => relayActions.push(url)} />
</Flex>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Add Custom
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
ex ea commodo consequat.
</AccordionPanel>
</AccordionItem>
</Accordion>
</ModalBody>
<ModalFooter>
<Button mr={3} onClick={onClose}>
Cancel
</Button>
<Button colorScheme="blue" variant="solid">
Save
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -9,6 +9,7 @@ import identity from "../../services/identity";
import userContactsService from "../../services/user-contacts"; import userContactsService from "../../services/user-contacts";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isNote } from "../../helpers/nostr-event"; import { isNote } from "../../helpers/nostr-event";
import settings from "../../services/settings";
function useExtendedContacts(pubkey: string) { function useExtendedContacts(pubkey: string) {
const [extendedContacts, setExtendedContacts] = useState<string[]>([]); const [extendedContacts, setExtendedContacts] = useState<string[]>([]);
@@ -39,10 +40,12 @@ function useExtendedContacts(pubkey: string) {
export const DiscoverTab = () => { export const DiscoverTab = () => {
const pubkey = useSubject(identity.pubkey); const pubkey = useSubject(identity.pubkey);
const relays = useSubject(settings.relays);
const contactsOfContacts = useExtendedContacts(pubkey); const contactsOfContacts = useExtendedContacts(pubkey);
const { events, loading, loadMore } = useTimelineLoader( const { events, loading, loadMore } = useTimelineLoader(
`discover`, `discover`,
relays,
{ authors: contactsOfContacts, kinds: [1], since: moment().subtract(1, "hour").unix() }, { authors: contactsOfContacts, kinds: [1], since: moment().subtract(1, "hour").unix() },
{ pageSize: moment.duration(1, "hour").asSeconds(), enabled: contactsOfContacts.length > 0 } { pageSize: moment.duration(1, "hour").asSeconds(), enabled: contactsOfContacts.length > 0 }
); );

View File

@@ -7,9 +7,11 @@ import useSubject from "../../hooks/use-subject";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { useUserContacts } from "../../hooks/use-user-contacts"; import { useUserContacts } from "../../hooks/use-user-contacts";
import identity from "../../services/identity"; import identity from "../../services/identity";
import settings from "../../services/settings";
export const FollowingTab = () => { export const FollowingTab = () => {
const pubkey = useSubject(identity.pubkey); const pubkey = useSubject(identity.pubkey);
const relays = useSubject(settings.relays);
const contacts = useUserContacts(pubkey); const contacts = useUserContacts(pubkey);
const [search, setSearch] = useSearchParams(); const [search, setSearch] = useSearchParams();
const showReplies = search.has("replies"); const showReplies = search.has("replies");
@@ -20,6 +22,7 @@ export const FollowingTab = () => {
const following = contacts?.contacts || []; const following = contacts?.contacts || [];
const { events, loading, loadMore } = useTimelineLoader( const { events, loading, loadMore } = useTimelineLoader(
`following-posts`, `following-posts`,
relays,
{ authors: following, kinds: [1], since: moment().subtract(2, "hour").unix() }, { authors: following, kinds: [1], since: moment().subtract(2, "hour").unix() },
{ pageSize: moment.duration(2, "hour").asSeconds(), enabled: following.length > 0 } { pageSize: moment.duration(2, "hour").asSeconds(), enabled: following.length > 0 }
); );

View File

@@ -1,24 +1,60 @@
import { Button, Flex, Spinner } from "@chakra-ui/react"; import { Button, Flex, FormControl, FormLabel, Select, Spinner, Switch, useDisclosure } from "@chakra-ui/react";
import moment from "moment"; import moment from "moment";
import { useState } from "react";
import { Note } from "../../components/note"; import { Note } from "../../components/note";
import { isNote } from "../../helpers/nostr-event"; import { isNote } from "../../helpers/nostr-event";
import useSubject from "../../hooks/use-subject";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import settings from "../../services/settings";
export const GlobalTab = () => { export const GlobalTab = () => {
const { events, loading, loadMore } = useTimelineLoader( const availableRelays = useSubject(settings.relays);
const [selectedRelay, setSelectedRelay] = useState("");
const { isOpen: showReplies, onToggle } = useDisclosure();
const { events, loading, loadMore, loader } = useTimelineLoader(
`global`, `global`,
selectedRelay ? [selectedRelay] : availableRelays,
{ kinds: [1], since: moment().subtract(5, "minutes").unix() }, { kinds: [1], since: moment().subtract(5, "minutes").unix() },
{ pageSize: moment.duration(5, "minutes").asSeconds() } { pageSize: moment.duration(5, "minutes").asSeconds() }
); );
const timeline = events.filter(isNote); const timeline = showReplies ? events : events.filter(isNote);
return ( return (
<Flex direction="column" overflow="auto" gap="2"> <>
{timeline.map((event) => ( <Flex direction="column" overflow="auto" gap="2">
<Note key={event.id} event={event} /> <Flex gap="2">
))} <Select
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>} placeholder="All Relays"
</Flex> maxWidth="250"
value={selectedRelay}
onChange={(e) => {
setSelectedRelay(e.target.value);
loader.forgetEvents();
}}
>
{availableRelays.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</Select>
<FormControl display="flex" alignItems="center">
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} mr="2" />
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
</FormControl>
</Flex>
{timeline.map((event) => (
<Note key={event.id} event={event} />
))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
) : (
<Button onClick={() => loadMore()}>Load More</Button>
)}
</Flex>
</>
); );
}; };

View File

@@ -28,16 +28,12 @@ import { RelayStatus } from "./relay-status";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import settings from "../../services/settings"; import settings from "../../services/settings";
import { clearData } from "../../services/db"; import { clearData } from "../../services/db";
import { RelayUrlInput } from "../../components/relay-url-input";
export const SettingsView = () => { export const SettingsView = () => {
const relays = useSubject(settings.relays); const relays = useSubject(settings.relays);
const [relayInputValue, setRelayInputValue] = useState(""); const [relayInputValue, setRelayInputValue] = useState("");
const { value: relaysJson, loading: loadingRelaysJson } = useAsync(async () =>
fetch("/relays.json").then((res) => res.json() as Promise<{ relays: string[] }>)
);
const relaySuggestions = relaysJson?.relays.filter((url) => !relays.includes(url)) ?? [];
const { colorMode, setColorMode } = useColorMode(); const { colorMode, setColorMode } = useColorMode();
const handleRemoveRelay = (url: string) => { const handleRemoveRelay = (url: string) => {
@@ -46,8 +42,10 @@ export const SettingsView = () => {
const handleAddRelay = (event: SyntheticEvent<HTMLFormElement>) => { const handleAddRelay = (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
settings.relays.next([...relays, relayInputValue]); if (!relays.includes(relayInputValue)) {
setRelayInputValue(""); settings.relays.next([...relays, relayInputValue]);
setRelayInputValue("");
}
}; };
const [clearing, setClearing] = useState(false); const [clearing, setClearing] = useState(false);
@@ -104,22 +102,12 @@ export const SettingsView = () => {
<FormControl> <FormControl>
<FormLabel htmlFor="relay-url-input">Add Relay</FormLabel> <FormLabel htmlFor="relay-url-input">Add Relay</FormLabel>
<Flex gap="2"> <Flex gap="2">
<Input <RelayUrlInput
id="relay-url-input" id="relay-url-input"
value={relayInputValue} value={relayInputValue}
onChange={(e) => setRelayInputValue(e.target.value)} onChange={(e) => setRelayInputValue(e.target.value)}
required isRequired
list="relay-suggestions"
type="url"
isDisabled={loadingRelaysJson}
/> />
<datalist id="relay-suggestions">
{relaySuggestions.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</datalist>
<Button type="submit">Add</Button> <Button type="submit">Add</Button>
</Flex> </Flex>
</FormControl> </FormControl>

View File

@@ -3,13 +3,17 @@ import moment from "moment";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { Note } from "../../components/note"; import { Note } from "../../components/note";
import { isNote } from "../../helpers/nostr-event"; import { isNote } from "../../helpers/nostr-event";
import useSubject from "../../hooks/use-subject";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import settings from "../../services/settings";
const UserNotesTab = () => { const UserNotesTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string }; const { pubkey } = useOutletContext() as { pubkey: string };
const relays = useSubject(settings.relays);
const { events, loading, loadMore } = useTimelineLoader( const { events, loading, loadMore } = useTimelineLoader(
`${pubkey} notes`, `${pubkey} notes`,
relays,
{ authors: [pubkey], kinds: [1], since: moment().subtract(1, "day").unix() }, { authors: [pubkey], kinds: [1], since: moment().subtract(1, "day").unix() },
{ pageSize: moment.duration(1, "day").asSeconds() } { pageSize: moment.duration(1, "day").asSeconds() }
); );

View File

@@ -3,12 +3,17 @@ import moment from "moment";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { Note } from "../../components/note"; import { Note } from "../../components/note";
import { isReply } from "../../helpers/nostr-event"; import { isReply } from "../../helpers/nostr-event";
import useSubject from "../../hooks/use-subject";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import settings from "../../services/settings";
const UserRepliesTab = () => { const UserRepliesTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string }; const { pubkey } = useOutletContext() as { pubkey: string };
const relays = useSubject(settings.relays);
const { events, loading, loadMore } = useTimelineLoader( const { events, loading, loadMore } = useTimelineLoader(
`${pubkey} replies`, `${pubkey} replies`,
relays,
{ authors: [pubkey], kinds: [1], since: moment().subtract(4, "hours").unix() }, { authors: [pubkey], kinds: [1], since: moment().subtract(4, "hours").unix() },
{ pageSize: moment.duration(1, "day").asSeconds() } { pageSize: moment.duration(1, "day").asSeconds() }
); );