Merge branch 'next'

This commit is contained in:
hzrd149 2023-12-04 18:02:35 -06:00
commit 7000b2d5ad
268 changed files with 7441 additions and 2406 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add option to hide usernames

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add Torrent create view

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for default bookmark list

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add decrypt all button to DMs

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Change "Copy Share Link" to use njump.me

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Replace "Copy Note Id" with "Copy Embed Code"

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add colors to notifications view

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add simple torrents view

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add Channels view

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Use nevent instead of note1 in urls

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add local relay cache option

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for Nostr Signing Device

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Rebuild notifications view

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Rebuild tools view

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show reposts in note details modal

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Cache decrypted events

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add comments to torrents

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Blur videos from strangers

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Rebuild thread loading

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show list links on muted by view

View File

@ -1,3 +1,3 @@
{
"cSpell.words": ["Bech", "Chakra", "Msat", "nostr", "noStrudel", "Npub", "pubkeys", "Sats", "webln"]
"cSpell.words": ["Bech", "Chakra", "lnurl", "Msat", "nostr", "noStrudel", "Npub", "pubkeys", "Sats", "webln"]
}

View File

@ -22,7 +22,9 @@
"@chakra-ui/styled-system": "^2.9.1",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@getalby/bitcoin-connect-react": "^2.3.1",
"@getalby/bitcoin-connect-react": "^2.4.2",
"@noble/hashes": "^1.3.2",
"@noble/secp256k1": "^1.7.0",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"bech32": "^2.0.0",
"cheerio": "^1.0.0-rc.12",
@ -43,7 +45,6 @@
"match-sorter": "^6.3.1",
"nanoid": "^5.0.2",
"ngeohash": "^0.6.3",
"noble-secp256k1": "^1.2.14",
"nostr-tools": "^1.17.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -61,12 +62,14 @@
"three": "^0.157.0",
"three-spritetext": "^1.8.1",
"webln": "^0.3.2",
"webtorrent": "^2.1.29",
"yet-another-react-lightbox": "^3.12.1"
},
"devDependencies": {
"@changesets/cli": "^2.26.2",
"@types/chroma-js": "^2.4.1",
"@types/debug": "^4.1.8",
"@types/dom-serial": "^1.0.6",
"@types/identicon.js": "^2.3.1",
"@types/leaflet": "^1.9.3",
"@types/leaflet.locatecontrol": "^0.74.1",
@ -76,12 +79,15 @@
"@types/react-dom": "^18.2.7",
"@types/three": "^0.157.2",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
"@types/webtorrent": "^0.109.7",
"@vitejs/plugin-react": "^4.0.4",
"camelcase": "^8.0.0",
"prettier": "^3.0.2",
"typescript": "^5.1.6",
"vite": "^4.4.9",
"vite-plugin-pwa": "^0.16.4"
"typescript": "^5.3.2",
"vite": "^5.0.2",
"vite-plugin-pwa": "^0.17.2",
"workbox-build": "^7.0.0",
"workbox-window": "^7.0.0"
},
"resolutions": {
"@types/react": "^18.2.22",

View File

@ -14,7 +14,7 @@ import SettingsView from "./views/settings";
import NostrLinkView from "./views/link";
import ProfileView from "./views/profile";
import HashTagView from "./views/hashtag";
import NoteView from "./views/note";
import ThreadView from "./views/note";
import NotificationsView from "./views/notifications";
import DirectMessagesView from "./views/messages";
import DirectMessageChatView from "./views/messages/chat";
@ -39,6 +39,7 @@ import UserListsTab from "./views/user/lists";
import UserGoalsTab from "./views/user/goals";
import MutedByView from "./views/user/muted-by";
import UserArticlesTab from "./views/user/articles";
const UserTorrentsTab = lazy(() => import("./views/user/torrents"));
import ListsView from "./views/lists";
import ListDetailsView from "./views/lists/list-details";
@ -88,6 +89,14 @@ const StreamView = lazy(() => import("./views/streams/stream"));
const SearchView = lazy(() => import("./views/search"));
const MapView = lazy(() => import("./views/map"));
const ChannelsHomeView = lazy(() => import("./views/channels"));
const ChannelView = lazy(() => import("./views/channels/channel"));
const TorrentsView = lazy(() => import("./views/torrents"));
const TorrentDetailsView = lazy(() => import("./views/torrents/torrent"));
const TorrentPreviewView = lazy(() => import("./views/torrents/preview"));
const NewTorrentView = lazy(() => import("./views/torrents/new"));
const overrideReactTextareaAutocompleteStyles = css`
.rta__autocomplete {
z-index: var(--chakra-zIndices-popover);
@ -205,11 +214,12 @@ const router = createHashRouter([
{ path: "reports", element: <UserReportsTab /> },
{ path: "muted-by", element: <MutedByView /> },
{ path: "dms", element: <UserDMsTab /> },
{ path: "torrents", element: <UserTorrentsTab /> },
],
},
{
path: "/n/:id",
element: <NoteView />,
element: <ThreadView />,
},
{ path: "settings", element: <SettingsView /> },
{
@ -223,8 +233,11 @@ const router = createHashRouter([
{ path: "r/:relay", element: <RelayView /> },
{ path: "notifications", element: <NotificationsView /> },
{ path: "search", element: <SearchView /> },
{ path: "dm", element: <DirectMessagesView /> },
{ path: "dm/:key", element: <DirectMessageChatView /> },
{
path: "dm",
element: <DirectMessagesView />,
children: [{ path: ":pubkey", element: <DirectMessageChatView /> }],
},
{ path: "profile", element: <ProfileView /> },
{
path: "tools",
@ -274,6 +287,25 @@ const router = createHashRouter([
},
],
},
{
path: "torrents/:id/preview",
element: <TorrentPreviewView />,
},
{
path: "torrents",
children: [
{ path: "", element: <TorrentsView /> },
{ path: "new", element: <NewTorrentView /> },
{ path: ":id", element: <TorrentDetailsView /> },
],
},
{
path: "channels",
children: [
{ path: "", element: <ChannelsHomeView /> },
{ path: ":id", element: <ChannelView /> },
],
},
{
path: "goals",
children: [

View File

@ -1,10 +1,16 @@
import { nanoid } from "nanoid";
import stringify from "json-stringify-deterministic";
import { Subject } from "./subject";
import { NostrEvent } from "../types/nostr-event";
import { NostrOutgoingMessage, NostrRequestFilter } from "../types/nostr-query";
import { NostrOutgoingRequest, NostrRequestFilter, RelayQueryMap } from "../types/nostr-query";
import Relay, { IncomingEvent } from "./relay";
import relayPoolService from "../services/relay-pool";
function isFilterEqual(a: NostrRequestFilter, b: NostrRequestFilter) {
return stringify(a) === stringify(b);
}
export default class NostrMultiSubscription {
static INIT = "initial";
static OPEN = "open";
@ -12,120 +18,120 @@ export default class NostrMultiSubscription {
id: string;
name?: string;
query?: NostrRequestFilter;
relayUrls: string[];
relays: Relay[];
queryMap: RelayQueryMap = {};
relays: Relay[] = [];
state = NostrMultiSubscription.INIT;
onEvent = new Subject<NostrEvent>();
seenEvents = new Set<string>();
constructor(relayUrls: string[], query?: NostrRequestFilter, name?: string) {
constructor(name?: string) {
this.id = nanoid();
this.query = query;
this.name = name;
this.relayUrls = relayUrls;
this.relays = relayUrls.map((url) => relayPoolService.requestRelay(url));
}
private handleEvent(event: IncomingEvent) {
if (this.state === NostrMultiSubscription.OPEN && event.subId === this.id && !this.seenEvents.has(event.body.id)) {
this.onEvent.next(event.body);
this.seenEvents.add(event.body.id);
}
}
send(message: NostrOutgoingMessage) {
for (const relay of this.relays) {
relay.send(message);
private handleEvent(incomingEvent: IncomingEvent) {
if (
this.state === NostrMultiSubscription.OPEN &&
incomingEvent.subId === this.id &&
!this.seenEvents.has(incomingEvent.body.id)
) {
this.onEvent.next(incomingEvent.body);
this.seenEvents.add(incomingEvent.body.id);
}
}
/** listen for event and open events from relays */
private subscribeToRelays() {
for (const relay of this.relays) {
relay.onEvent.subscribe(this.handleEvent, this);
private connectToRelay(relay: Relay) {
relay.onEvent.subscribe(this.handleEvent, this);
relay.onOpen.subscribe(this.handleRelayConnect, this);
relay.onClose.subscribe(this.handleRelayDisconnect, this);
relayPoolService.addClaim(relay.url, this);
}
/** stop listing to events from relays */
private disconnectFromRelay(relay: Relay) {
relay.onEvent.unsubscribe(this.handleEvent, this);
relay.onOpen.unsubscribe(this.handleRelayConnect, this);
relay.onClose.unsubscribe(this.handleRelayDisconnect, this);
relayPoolService.removeClaim(relay.url, this);
// if the subscription is open and had sent a request to the relay
if (this.state === NostrMultiSubscription.OPEN && this.relayQueries.has(relay)) {
relay.send(["CLOSE", this.id]);
}
this.relayQueries.delete(relay);
}
setQueryMap(queryMap: RelayQueryMap) {
if (isFilterEqual(this.queryMap, queryMap)) return;
// add and remove relays
for (const url of Object.keys(queryMap)) {
if (!this.queryMap[url]) {
if (this.relays.some((r) => r.url === url)) continue;
// add relay
const relay = relayPoolService.requestRelay(url);
this.relays.push(relay);
this.connectToRelay(relay);
}
}
for (const url of Object.keys(this.queryMap)) {
if (!queryMap[url]) {
const relay = this.relays.find((r) => r.url === url);
if (!relay) continue;
this.relays = this.relays.filter((r) => r !== relay);
this.disconnectFromRelay(relay);
}
}
for (const url of this.relayUrls) {
relayPoolService.addClaim(url, this);
this.queryMap = queryMap;
this.updateRelayQueries();
}
private relayQueries = new WeakMap<Relay, NostrRequestFilter>();
private updateRelayQueries() {
if (this.state !== NostrMultiSubscription.OPEN) return;
for (const relay of this.relays) {
const filter = this.queryMap[relay.url];
const message: NostrOutgoingRequest = Array.isArray(filter)
? ["REQ", this.id, ...filter]
: ["REQ", this.id, filter];
const currentFilter = this.relayQueries.get(relay);
if (!currentFilter || !isFilterEqual(currentFilter, filter)) {
this.relayQueries.set(relay, filter);
relay.send(message);
}
}
}
/** listen for event and open events from relays */
private unsubscribeFromRelays() {
for (const relay of this.relays) {
relay.onEvent.unsubscribe(this.handleEvent, this);
}
for (const url of this.relayUrls) {
relayPoolService.removeClaim(url, this);
}
private handleRelayConnect(relay: Relay) {
this.updateRelayQueries();
}
private handleRelayDisconnect(relay: Relay) {
this.relayQueries.delete(relay);
}
open() {
if (!this.query) throw new Error("cant open without a query");
if (this.state === NostrMultiSubscription.OPEN) return this;
this.state = NostrMultiSubscription.OPEN;
if (Array.isArray(this.query)) {
this.send(["REQ", this.id, ...this.query]);
} else this.send(["REQ", this.id, this.query]);
this.subscribeToRelays();
// reconnect to all relays
for (const relay of this.relays) this.connectToRelay(relay);
// send queries
this.updateRelayQueries();
return this;
}
setQuery(query: NostrRequestFilter) {
this.query = query;
if (this.state === NostrMultiSubscription.OPEN) {
if (Array.isArray(this.query)) {
this.send(["REQ", this.id, ...this.query]);
} else this.send(["REQ", this.id, this.query]);
}
return this;
}
setRelays(relays: string[]) {
this.unsubscribeFromRelays();
const newRelays = relays.map((url) => relayPoolService.requestRelay(url));
for (const relay of this.relays) {
if (!newRelays.includes(relay)) {
// if the subscription is open and the relay is connected
if (this.state === NostrMultiSubscription.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 === NostrMultiSubscription.OPEN && this.query) {
// open a connection to this relay
if (Array.isArray(this.query)) {
relay.send(["REQ", this.id, ...this.query]);
} else relay.send(["REQ", this.id, this.query]);
}
}
}
// set new relays
this.relayUrls = relays;
this.relays = newRelays;
if (this.state === NostrMultiSubscription.OPEN) {
this.subscribeToRelays();
}
}
close() {
if (this.state !== NostrMultiSubscription.OPEN) return this;
// forget all seen events
this.forgetEvents();
// unsubscribe from relay messages
for (const relay of this.relays) this.disconnectFromRelay(relay);
// set state
this.state = NostrMultiSubscription.CLOSED;
// send close message
this.send(["CLOSE", this.id]);
// forget all seen events
this.seenEvents.clear();
// unsubscribe from relay messages
this.unsubscribeFromRelays();
return this;
}

View File

@ -1,9 +1,10 @@
import { nanoid } from "nanoid";
import { NostrEvent } from "../types/nostr-event";
import { NostrOutgoingMessage, NostrRequestFilter } from "../types/nostr-query";
import Relay, { IncomingEOSE } from "./relay";
import relayPoolService from "../services/relay-pool";
import { Subject } from "./subject";
import { nanoid } from "nanoid";
export default class NostrSubscription {
static INIT = "initial";
@ -26,7 +27,9 @@ export default class NostrSubscription {
this.relay = relayPoolService.requestRelay(relayUrl);
this.onEvent.connectWithHandler(this.relay.onEvent, (event, next) => {
if (this.state === NostrSubscription.OPEN) next(event.body);
if (this.state === NostrSubscription.OPEN) {
next(event.body);
}
});
this.onEOSE.connectWithHandler(this.relay.onEOSE, (eose, next) => {
if (this.state === NostrSubscription.OPEN) next(eose);

View File

@ -1,56 +0,0 @@
import Subject from "./subject";
/** @deprecated */
export class PubkeySubjectCache<T> {
subjects = new Map<string, Subject<T | null>>();
relays = new Map<string, Set<string>>();
dirty = false;
hasSubject(pubkey: string) {
return this.subjects.has(pubkey);
}
getSubject(pubkey: string) {
let subject = this.subjects.get(pubkey);
if (!subject) {
subject = new Subject<T | null>(null);
this.subjects.set(pubkey, subject);
this.dirty = true;
}
return subject;
}
addRelays(pubkey: string, relays: string[]) {
const set = this.relays.get(pubkey) ?? new Set();
for (const url of relays) set.add(url);
this.relays.set(pubkey, set);
this.dirty = true;
}
getAllPubkeysMissingData(include: string[] = []) {
const pubkeys: string[] = [];
const relays = new Set<string>();
for (const [pubkey, subject] of this.subjects) {
if (subject.value === null || include.includes(pubkey)) {
pubkeys.push(pubkey);
const r = this.relays.get(pubkey);
if (r) {
for (const url of r) relays.add(url);
}
}
}
return { pubkeys, relays: Array.from(relays) };
}
prune() {
const prunedKeys: string[] = [];
for (const [key, subject] of this.subjects) {
if (!subject.hasListeners) {
this.subjects.delete(key);
this.relays.delete(key);
prunedKeys.push(key);
this.dirty = true;
}
}
return prunedKeys;
}
}

View File

@ -1,109 +0,0 @@
import { getReferences } from "../helpers/nostr/events";
import { NostrEvent } from "../types/nostr-event";
import NostrRequest from "./nostr-request";
import NostrMultiSubscription from "./nostr-multi-subscription";
import { PersistentSubject } from "./subject";
export default class ThreadLoader {
loading = new PersistentSubject(false);
focusId = new PersistentSubject<string>("");
rootId = new PersistentSubject<string>("");
events = new PersistentSubject<Record<string, NostrEvent>>({});
private relays: string[];
private subscription: NostrMultiSubscription;
constructor(relays: string[], eventId: string) {
this.relays = relays;
this.subscription = new NostrMultiSubscription(relays);
this.subscription.onEvent.subscribe((event) => {
this.events.next({ ...this.events.value, [event.id]: event });
});
this.updateEventId(eventId);
}
loadEvent() {
this.loading.next(true);
const request = new NostrRequest(this.relays);
request.onEvent.subscribe((event) => {
this.events.next({ ...this.events.value, [event.id]: event });
this.checkAndUpdateRoot();
request.complete();
this.loading.next(false);
});
request.start({ ids: [this.focusId.value] });
}
private checkAndUpdateRoot() {
const event = this.events.value[this.focusId.value];
if (event) {
const refs = getReferences(event);
const rootId = refs.rootId || event.id;
// only update the root if its different
if (rootId !== this.rootId.value) {
this.rootId.next(rootId);
this.loadRoot();
this.updateSubscription();
}
}
}
loadRoot() {
if (this.rootId.value) {
const request = new NostrRequest(this.relays);
request.onEvent.subscribe((event) => {
this.events.next({ ...this.events.value, [event.id]: event });
request.complete();
});
request.start({ ids: [this.rootId.value] });
}
}
setRelays(relays: string[]) {
this.relays = relays;
this.subscription.setRelays(relays);
this.loadEvent();
}
private updateSubscription() {
if (this.rootId.value) {
this.subscription.setQuery({ "#e": [this.rootId.value], kinds: [1] });
if (this.subscription.state !== NostrMultiSubscription.OPEN) {
this.subscription.open();
}
}
}
updateEventId(eventId: string) {
if (this.loading.value) {
console.warn("trying to set eventId while loading");
return;
}
this.focusId.next(eventId);
const event = this.events.value[eventId];
if (!event) {
this.loadEvent();
} else {
this.checkAndUpdateRoot();
}
}
open() {
if (!this.loading.value && this.focusId.value && this.events.value[this.focusId.value]) {
this.loadEvent();
}
this.updateSubscription();
}
close() {
this.subscription.close();
}
}

View File

@ -1,7 +1,8 @@
import dayjs from "dayjs";
import { Debugger } from "debug";
import { NostrEvent, isATag, isETag } from "../types/nostr-event";
import { NostrQuery, NostrRequestFilter } from "../types/nostr-query";
import { NostrRequestFilter, RelayQueryMap } from "../types/nostr-query";
import NostrRequest from "./nostr-request";
import NostrMultiSubscription from "./nostr-multi-subscription";
import Subject, { PersistentSubject } from "./subject";
@ -10,21 +11,15 @@ import EventStore from "./event-store";
import { isReplaceable } from "../helpers/nostr/events";
import replaceableEventLoaderService from "../services/replaceable-event-requester";
import deleteEventService from "../services/delete-events";
function addToQuery(filter: NostrRequestFilter, query: NostrQuery) {
if (Array.isArray(filter)) {
return filter.map((f) => ({ ...f, ...query }));
}
return { ...filter, ...query };
}
import { addQueryToFilter, isFilterEqual, mapQueryMap } from "../helpers/nostr/filter";
const BLOCK_SIZE = 30;
export type EventFilter = (event: NostrEvent, store: EventStore) => boolean;
export class RelayTimelineLoader {
export class RelayBlockLoader {
relay: string;
query: NostrRequestFilter;
filter: NostrRequestFilter;
blockSize = BLOCK_SIZE;
private log: Debugger;
@ -35,9 +30,9 @@ export class RelayTimelineLoader {
onBlockFinish = new Subject<void>();
constructor(relay: string, query: NostrRequestFilter, log?: Debugger) {
constructor(relay: string, filter: NostrRequestFilter, log?: Debugger) {
this.relay = relay;
this.query = query;
this.filter = filter;
this.log = log || logger.extend(relay);
this.events = new EventStore(relay);
@ -47,13 +42,13 @@ export class RelayTimelineLoader {
loadNextBlock() {
this.loading = true;
let query: NostrRequestFilter = addToQuery(this.query, { limit: this.blockSize });
let filter: NostrRequestFilter = addQueryToFilter(this.filter, { limit: this.blockSize });
let oldestEvent = this.getLastEvent();
if (oldestEvent) {
query = addToQuery(query, { until: oldestEvent.created_at - 1 });
filter = addQueryToFilter(filter, { until: oldestEvent.created_at - 1 });
}
const request = new NostrRequest([this.relay], 20 * 1000);
const request = new NostrRequest([this.relay]);
let gotEvents = 0;
request.onEvent.subscribe((e) => {
@ -70,7 +65,11 @@ export class RelayTimelineLoader {
this.onBlockFinish.next();
});
request.start(query);
request.start(filter);
}
private handleEvent(event: NostrEvent) {
return this.events.addEvent(event);
}
private handleDeleteEvent(deleteEvent: NostrEvent) {
@ -81,26 +80,21 @@ export class RelayTimelineLoader {
if (eventId) this.events.deleteEvent(eventId);
}
private handleEvent(event: NostrEvent) {
return this.events.addEvent(event);
}
cleanup() {
deleteEventService.stream.unsubscribe(this.handleDeleteEvent, this);
}
getFirstEvent(nth = 0, filter?: EventFilter) {
return this.events.getFirstEvent(nth, filter);
getFirstEvent(nth = 0, eventFilter?: EventFilter) {
return this.events.getFirstEvent(nth, eventFilter);
}
getLastEvent(nth = 0, filter?: EventFilter) {
return this.events.getLastEvent(nth, filter);
getLastEvent(nth = 0, eventFilter?: EventFilter) {
return this.events.getLastEvent(nth, eventFilter);
}
}
export default class TimelineLoader {
cursor = dayjs().unix();
query?: NostrRequestFilter;
relays: string[] = [];
queryMap: RelayQueryMap = {};
events: EventStore;
timeline = new PersistentSubject<NostrEvent[]>([]);
@ -114,14 +108,14 @@ export default class TimelineLoader {
private log: Debugger;
private subscription: NostrMultiSubscription;
relayTimelineLoaders = new Map<string, RelayTimelineLoader>();
private blockLoaders = new Map<string, RelayBlockLoader>();
constructor(name: string) {
this.name = name;
this.log = logger.extend("TimelineLoader:" + name);
this.events = new EventStore(name);
this.subscription = new NostrMultiSubscription([], undefined, name);
this.subscription = new NostrMultiSubscription(name);
this.subscription.onEvent.subscribe(this.handleEvent, this);
// update the timeline when there are new events
@ -153,74 +147,70 @@ export default class TimelineLoader {
if (eventId) this.events.deleteEvent(eventId);
}
private createLoaders() {
if (!this.query) return;
private connectToBlockLoader(loader: RelayBlockLoader) {
this.events.connect(loader.events);
loader.onBlockFinish.subscribe(this.updateLoading, this);
loader.onBlockFinish.subscribe(this.updateComplete, this);
}
private disconnectToBlockLoader(loader: RelayBlockLoader) {
loader.cleanup();
this.events.disconnect(loader.events);
loader.onBlockFinish.unsubscribe(this.updateLoading, this);
loader.onBlockFinish.unsubscribe(this.updateComplete, this);
}
for (const relay of this.relays) {
if (!this.relayTimelineLoaders.has(relay)) {
const loader = new RelayTimelineLoader(relay, this.query, this.log.extend(relay));
this.relayTimelineLoaders.set(relay, loader);
this.events.connect(loader.events);
loader.onBlockFinish.subscribe(this.updateLoading, this);
loader.onBlockFinish.subscribe(this.updateComplete, this);
setQueryMap(queryMap: RelayQueryMap) {
if (isFilterEqual(this.queryMap, queryMap)) return;
this.log("set query map", queryMap);
// remove relays
for (const relay of Object.keys(this.queryMap)) {
const loader = this.blockLoaders.get(relay);
if (!loader) continue;
if (!queryMap[relay]) {
this.disconnectToBlockLoader(loader);
this.blockLoaders.delete(relay);
}
}
}
private removeLoaders(filter?: (loader: RelayTimelineLoader) => boolean) {
for (const [relay, loader] of this.relayTimelineLoaders) {
if (!filter || filter(loader)) {
loader.cleanup();
this.events.disconnect(loader.events);
loader.onBlockFinish.unsubscribe(this.updateLoading, this);
loader.onBlockFinish.unsubscribe(this.updateComplete, this);
this.relayTimelineLoaders.delete(relay);
for (const [relay, filter] of Object.entries(queryMap)) {
// remove outdated loaders
if (this.queryMap[relay] && !isFilterEqual(this.queryMap[relay], filter)) {
const old = this.blockLoaders.get(relay)!;
this.disconnectToBlockLoader(old);
this.blockLoaders.delete(relay);
}
if (!this.blockLoaders.has(relay)) {
const loader = new RelayBlockLoader(relay, filter, this.log.extend(relay));
this.blockLoaders.set(relay, loader);
this.connectToBlockLoader(loader);
}
}
this.queryMap = queryMap;
// update the subscription query map and add limit
this.subscription.setQueryMap(
mapQueryMap(this.queryMap, (filter) => addQueryToFilter(filter, { limit: BLOCK_SIZE / 2 })),
);
this.triggerBlockLoads();
}
setRelays(relays: string[]) {
if (this.relays.sort().join("|") === relays.sort().join("|")) return;
// remove loaders
this.removeLoaders((loader) => !relays.includes(loader.relay));
this.relays = relays;
this.createLoaders();
this.subscription.setRelays(relays);
this.updateComplete();
}
setQuery(query: NostrRequestFilter) {
if (JSON.stringify(this.query) === JSON.stringify(query)) return;
// remove all loaders
this.removeLoaders();
this.log("set query", query);
this.query = query;
// forget all events
this.forgetEvents();
// create any missing loaders
this.createLoaders();
// update the complete flag
this.updateComplete();
// update the subscription with the new query
this.subscription.setQuery(addToQuery(query, { limit: BLOCK_SIZE / 2 }));
}
setFilter(filter?: EventFilter) {
setEventFilter(filter?: EventFilter) {
this.eventFilter = filter;
this.updateTimeline();
}
setCursor(cursor: number) {
this.cursor = cursor;
this.loadNextBlocks();
this.triggerBlockLoads();
}
loadNextBlocks() {
triggerBlockLoads() {
let triggeredLoad = false;
for (const [relay, loader] of this.relayTimelineLoaders) {
for (const [relay, loader] of this.blockLoaders) {
if (loader.complete || loader.loading) continue;
const event = loader.getLastEvent(this.loadNextBlockBuffer, this.eventFilter);
if (!event || event.created_at >= this.cursor) {
@ -230,10 +220,9 @@ export default class TimelineLoader {
}
if (triggeredLoad) this.updateLoading();
}
/** @deprecated */
loadMore() {
loadNextBlock() {
let triggeredLoad = false;
for (const [relay, loader] of this.relayTimelineLoaders) {
for (const [relay, loader] of this.blockLoaders) {
if (loader.complete || loader.loading) continue;
loader.loadNextBlock();
triggeredLoad = true;
@ -242,7 +231,7 @@ export default class TimelineLoader {
}
private updateLoading() {
for (const [relay, loader] of this.relayTimelineLoaders) {
for (const [relay, loader] of this.blockLoaders) {
if (loader.loading) {
if (!this.loading.value) {
this.loading.next(true);
@ -253,7 +242,7 @@ export default class TimelineLoader {
if (this.loading.value) this.loading.next(false);
}
private updateComplete() {
for (const [relay, loader] of this.relayTimelineLoaders) {
for (const [relay, loader] of this.blockLoaders) {
if (!loader.complete) {
this.complete.next(false);
return;
@ -268,25 +257,27 @@ export default class TimelineLoader {
this.subscription.close();
}
forgetEvents() {
this.events.clear();
this.timeline.next([]);
this.subscription.forgetEvents();
}
reset() {
this.cursor = dayjs().unix();
this.removeLoaders();
for (const [_, loader] of this.blockLoaders) this.disconnectToBlockLoader(loader);
this.blockLoaders.clear();
this.forgetEvents();
}
/** close the subscription and remove any event listeners for this timeline */
cleanup() {
this.close();
this.removeLoaders();
for (const [_, loader] of this.blockLoaders) this.disconnectToBlockLoader(loader);
this.blockLoaders.clear();
this.events.cleanup();
deleteEventService.stream.unsubscribe(this.handleDeleteEvent, this);
}
// TODO: this is only needed because the current logic dose not remove events when the relay they where fetched from is removed
/** @deprecated */
forgetEvents() {
this.events.clear();
this.timeline.next([]);
this.subscription.forgetEvents();
}
}

View File

@ -2,13 +2,20 @@ import { Badge, BadgeProps } from "@chakra-ui/react";
import { Account } from "../services/account";
export default function AccountInfoBadge({ account, ...props }: BadgeProps & { account: Account }) {
if (account.useExtension) {
if (account.connectionType === "extension") {
return (
<Badge {...props} variant="solid" colorScheme="green">
extension
</Badge>
);
}
if (account.connectionType === "serial") {
return (
<Badge {...props} variant="solid" colorScheme="teal">
serial
</Badge>
);
}
if (account.secKey) {
return (
<Badge {...props} variant="solid" colorScheme="red">

View File

@ -31,7 +31,7 @@ export default function ContactsWindow({
const [expanded, setExpanded] = useState(true);
// TODO: find a better way to load recent contacts
const [from, setFrom] = useState(() => dayjs().subtract(2, "days"));
const [from, setFrom] = useState(() => dayjs().subtract(2, "days").unix());
const conversations = useSubject(directMessagesService.conversations);
useEffect(() => directMessagesService.loadDateRange(from), [from]);
const sortedConversations = useMemo(() => {

View File

@ -0,0 +1,17 @@
import { MenuItem } from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import { getSharableEventAddress } from "../../helpers/nip19";
import { CopyToClipboardIcon } from "../icons";
export default function CopyEmbedCodeMenuItem({ event }: { event: NostrEvent }) {
const address = getSharableEventAddress(event);
return (
address && (
<MenuItem onClick={() => window.navigator.clipboard.writeText("nostr:" + address)} icon={<CopyToClipboardIcon />}>
Copy Embed Code
</MenuItem>
)
);
}

View File

@ -0,0 +1,20 @@
import { MenuItem } from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import { getSharableEventAddress } from "../../helpers/nip19";
import { ShareIcon } from "../icons";
export default function CopyShareLinkMenuItem({ event }: { event: NostrEvent }) {
const address = getSharableEventAddress(event);
return (
address && (
<MenuItem
onClick={() => window.navigator.clipboard.writeText("https://njump.me/" + address)}
icon={<ShareIcon />}
>
Copy Share Link
</MenuItem>
)
);
}

View File

@ -0,0 +1,19 @@
import { MenuItem } from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import { useDeleteEventContext } from "../../providers/delete-event-provider";
import useCurrentAccount from "../../hooks/use-current-account";
import { TrashIcon } from "../icons";
export default function DeleteEventMenuItem({ event, label }: { event: NostrEvent; label?: string }) {
const account = useCurrentAccount();
const { deleteEvent } = useDeleteEventContext();
return (
account?.pubkey === event.pubkey && (
<MenuItem icon={<TrashIcon />} color="red.500" onClick={() => deleteEvent(event)}>
{label ?? "Delete Note"}
</MenuItem>
)
);
}

View File

@ -0,0 +1,25 @@
import { MenuItem } from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import useCurrentAccount from "../../hooks/use-current-account";
import { MuteIcon, UnmuteIcon } from "../icons";
import { useMuteModalContext } from "../../providers/mute-modal-provider";
import useUserMuteFunctions from "../../hooks/use-user-mute-functions";
export default function MuteUserMenuItem({ event }: { event: NostrEvent }) {
const account = useCurrentAccount();
const { isMuted, mute, unmute } = useUserMuteFunctions(event.pubkey);
const { openModal } = useMuteModalContext();
if (account?.pubkey === event.pubkey) return null;
return (
<MenuItem
onClick={isMuted ? unmute : () => openModal(event.pubkey)}
icon={isMuted ? <UnmuteIcon /> : <MuteIcon />}
color="red.500"
>
{isMuted ? "Unmute User" : "Mute User"}
</MenuItem>
);
}

View File

@ -0,0 +1,24 @@
import { Link, MenuItem } from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import { buildAppSelectUrl } from "../../helpers/nostr/apps";
import { ExternalLinkIcon } from "../icons";
import { getSharableEventAddress } from "../../helpers/nip19";
export default function OpenInAppMenuItem({ event }: { event: NostrEvent }) {
const address = getSharableEventAddress(event);
return (
address && (
<MenuItem
as={Link}
href={buildAppSelectUrl(address)}
icon={<ExternalLinkIcon />}
isExternal
textDecoration="none !important"
>
View in app...
</MenuItem>
)
);
}

View File

@ -0,0 +1,52 @@
import { useCallback, useState } from "react";
import { MenuItem, useToast } from "@chakra-ui/react";
import dayjs from "dayjs";
import useCurrentAccount from "../../hooks/use-current-account";
import { useSigningContext } from "../../providers/signing-provider";
import useUserPinList from "../../hooks/use-user-pin-list";
import { DraftNostrEvent, NostrEvent, isETag } from "../../types/nostr-event";
import { PIN_LIST_KIND, listAddEvent, listRemoveEvent } from "../../helpers/nostr/lists";
import clientRelaysService from "../../services/client-relays";
import NostrPublishAction from "../../classes/nostr-publish-action";
import { PinIcon } from "../icons";
export default function PinNoteMenuItem({ event }: { event: NostrEvent }) {
const toast = useToast();
const account = useCurrentAccount();
const { requestSignature } = useSigningContext();
const { list } = useUserPinList(account?.pubkey);
const isPinned = list?.tags.some((t) => isETag(t) && t[1] === event.id) ?? false;
const label = isPinned ? "Unpin Note" : "Pin Note";
const [loading, setLoading] = useState(false);
const togglePin = useCallback(async () => {
try {
setLoading(true);
let draft: DraftNostrEvent = {
kind: PIN_LIST_KIND,
created_at: dayjs().unix(),
content: list?.content ?? "",
tags: list?.tags ? Array.from(list.tags) : [],
};
if (isPinned) draft = listRemoveEvent(draft, event.id);
else draft = listAddEvent(draft, event.id);
const signed = await requestSignature(draft);
new NostrPublishAction(label, clientRelaysService.getWriteUrls(), signed);
setLoading(false);
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
}, [list, isPinned]);
if (event.pubkey !== account?.pubkey) return null;
return (
<MenuItem onClick={togglePin} icon={<PinIcon />} isDisabled={loading || !!account?.readonly}>
{label}
</MenuItem>
);
}

View File

@ -1,19 +1,19 @@
import React from "react";
import { Box, BoxProps } from "@chakra-ui/react";
import { Box, BoxProps, Text } from "@chakra-ui/react";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { EmbedableContent, embedUrls, truncateEmbedableContent } from "../../helpers/embeds";
import { embedNostrLinks, embedNostrMentions, embedNostrHashtags, embedEmoji, renderGenericUrl } from "../embed-types";
import { LightboxProvider } from "../lightbox-provider";
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
import { EmbedableContent, embedUrls, truncateEmbedableContent } from "../helpers/embeds";
import { embedNostrLinks, embedNostrMentions, embedNostrHashtags, embedEmoji, renderGenericUrl } from "./embed-types";
import { LightboxProvider } from "./lightbox-provider";
function buildContents(event: NostrEvent | DraftNostrEvent) {
function buildContents(event: NostrEvent | DraftNostrEvent, textOnly = false) {
let content: EmbedableContent = [event.content.trim().replace(/\n+/g, "\n")];
// common
content = embedUrls(content, [renderGenericUrl]);
// nostr
content = embedNostrLinks(content);
content = embedNostrLinks(content, textOnly);
content = embedNostrMentions(content, event);
content = embedNostrHashtags(content, event);
content = embedEmoji(content, event);
@ -23,19 +23,27 @@ function buildContents(event: NostrEvent | DraftNostrEvent) {
export type NoteContentsProps = {
event: NostrEvent | DraftNostrEvent;
textOnly?: boolean;
maxLength?: number;
};
export const InlineNoteContent = React.memo(
({ event, maxLength, ...props }: NoteContentsProps & Omit<BoxProps, "children">) => {
let content = buildContents(event);
export const CompactNoteContent = React.memo(
({ event, maxLength, textOnly = false, ...props }: NoteContentsProps & Omit<BoxProps, "children">) => {
let content = buildContents(event, textOnly);
let truncated = maxLength !== undefined ? truncateEmbedableContent(content, maxLength) : content;
return (
<LightboxProvider>
<Box whiteSpace="pre-wrap" {...props}>
{truncated}
{truncated !== content ? "..." : null}
{truncated !== content ? (
<>
<span>...</span>
<Text as="span" fontWeight="bold" ml="4">
Show More
</Text>
</>
) : null}
</Box>
</LightboxProvider>
);

View File

@ -9,6 +9,7 @@ import RawPre from "./raw-pre";
import userMetadataService from "../../services/user-metadata";
import { getUserDisplayName } from "../../helpers/user-metadata";
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
import { getSharableEventAddress } from "../../helpers/nip19";
export default function CommunityPostDebugModal({
event,
@ -27,7 +28,8 @@ export default function CommunityPostDebugModal({
<ModalBody p="4">
<Flex gap="2" direction="column">
<RawValue heading="Event Id" value={event.id} />
<RawValue heading="Encoded id (NIP-19)" value={nip19.noteEncode(event.id)} />
<RawValue heading="NIP-19 Encoded Id" value={nip19.noteEncode(event.id)} />
<RawValue heading="NIP-19 Pointer" value={getSharableEventAddress(event)} />
<RawValue heading="Community Coordinate" value={communityCoordinate} />
<RawPre heading="Content" value={event.content} />
<RawJson heading="JSON" json={event} />

View File

@ -7,6 +7,7 @@ import { NostrEvent } from "../../types/nostr-event";
import RawJson from "./raw-json";
import RawValue from "./raw-value";
import RawPre from "./raw-pre";
import { getSharableEventAddress } from "../../helpers/nip19";
export default function NoteDebugModal({ event, ...props }: { event: NostrEvent } & Omit<ModalProps, "children">) {
return (
@ -17,7 +18,8 @@ export default function NoteDebugModal({ event, ...props }: { event: NostrEvent
<ModalBody p="4">
<Flex gap="2" direction="column">
<RawValue heading="Event Id" value={event.id} />
<RawValue heading="Encoded id (NIP-19)" value={nip19.noteEncode(event.id)} />
<RawValue heading="NIP-19 Encoded Id" value={nip19.noteEncode(event.id)} />
<RawValue heading="NIP-19 Pointer" value={getSharableEventAddress(event)} />
<RawPre heading="Content" value={event.content} />
<RawJson heading="JSON" json={event} />
<RawJson heading="References" json={getReferences(event)} />

View File

@ -10,7 +10,7 @@ import { NostrEvent } from "../../../types/nostr-event";
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
import { getSharableEventAddress } from "../../../helpers/nip19";
import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import UserLink from "../../user-link";
import Timestamp from "../../timestamp";
export default function EmbeddedArticle({ article, ...props }: Omit<CardProps, "children"> & { article: NostrEvent }) {

View File

@ -13,7 +13,7 @@ import {
} from "@chakra-ui/react";
import UserAvatarLink from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import UserLink from "../../../components/user-link";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { NostrEvent } from "../../../types/nostr-event";
import { getBadgeDescription, getBadgeImage, getBadgeName } from "../../../helpers/nostr/badges";

View File

@ -0,0 +1,56 @@
import { Link as RouterLink } from "react-router-dom";
import { Box, Card, CardBody, CardFooter, CardHeader, CardProps, Flex, Heading, LinkBox, Text } from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
import UserAvatarLink from "../../user-avatar-link";
import UserLink from "../../user-link";
import { NostrEvent } from "../../../types/nostr-event";
import useChannelMetadata from "../../../hooks/use-channel-metadata";
import HoverLinkOverlay from "../../hover-link-overlay";
import singleEventService from "../../../services/single-event";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
export default function EmbeddedChannel({
channel,
additionalRelays,
...props
}: Omit<CardProps, "children"> & { channel: NostrEvent; additionalRelays?: string[] }) {
const readRelays = useReadRelayUrls(additionalRelays);
const { metadata } = useChannelMetadata(channel.id, readRelays);
if (!channel || !metadata) return null;
return (
<Card as={LinkBox} flexDirection="row" gap="2" overflow="hidden" alignItems="flex-start" {...props}>
<Box
backgroundImage={metadata.picture}
backgroundSize="cover"
backgroundPosition="center"
backgroundRepeat="no-repeat"
aspectRatio={1}
w="7rem"
flexShrink={0}
/>
<Flex direction="column" flex={1} overflow="hidden" h="full">
<CardHeader p="2" display="flex" gap="2" alignItems="center">
<Heading size="md" isTruncated>
<HoverLinkOverlay
as={RouterLink}
to={`/channels/${nip19.neventEncode({ id: channel.id })}`}
onClick={() => singleEventService.handleEvent(channel)}
>
{metadata.name}
</HoverLinkOverlay>
</Heading>
</CardHeader>
<CardBody px="2" py="0" overflow="hidden" flexGrow={1}>
<Text isTruncated>{metadata.about}</Text>
</CardBody>
<CardFooter p="2" gap="2">
<UserAvatarLink pubkey={channel.pubkey} size="xs" />
<UserLink pubkey={channel.pubkey} fontWeight="bold" />
</CardFooter>
</Flex>
</Card>
);
}

View File

@ -3,7 +3,7 @@ import { Card, CardFooter, CardHeader, CardProps, Heading, LinkBox, LinkOverlay,
import { nip19 } from "nostr-tools";
import UserAvatarLink from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import UserLink from "../../../components/user-link";
import { NostrEvent } from "../../../types/nostr-event";
import { getCommunityImage, getCommunityName } from "../../../helpers/nostr/communities";

View File

@ -3,7 +3,7 @@ import { Card, CardBody, CardHeader, CardProps, LinkBox, Spacer, Text } from "@c
import { NostrEvent } from "../../../types/nostr-event";
import { TrustProvider } from "../../../providers/trust";
import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import UserLink from "../../user-link";
import Timestamp from "../../timestamp";
import DecryptPlaceholder from "../../../views/messages/decrypt-placeholder";
import { MessageContent } from "../../../views/messages/message";

View File

@ -16,7 +16,7 @@ import { Link as RouterLink } from "react-router-dom";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { getEmojisFromPack, getPackName } from "../../../helpers/nostr/emoji-packs";
import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import UserLink from "../../user-link";
import EmojiPackFavoriteButton from "../../../views/emoji-packs/components/emoji-pack-favorite-button";
import EmojiPackMenu from "../../../views/emoji-packs/components/emoji-pack-menu";
import { NostrEvent } from "../../../types/nostr-event";

View File

@ -5,7 +5,7 @@ import { getSharableEventAddress } from "../../../helpers/nip19";
import { NostrEvent } from "../../../types/nostr-event";
import { getGoalName } from "../../../helpers/nostr/goal";
import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import UserLink from "../../user-link";
import GoalProgress from "../../../views/goals/components/goal-progress";
import GoalZapButton from "../../../views/goals/components/goal-zap-button";
import GoalTopZappers from "../../../views/goals/components/goal-top-zappers";

View File

@ -6,7 +6,7 @@ import { getListDescription, getListName, isSpecialListKind } from "../../../hel
import { createCoordinate } from "../../../services/replaceable-event-requester";
import { getSharableEventAddress } from "../../../helpers/nip19";
import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import UserLink from "../../user-link";
import ListFeedButton from "../../../views/lists/components/list-feed-button";
import { ListCardContent } from "../../../views/lists/components/list-card";

View File

@ -4,7 +4,7 @@ import { Link as RouterLink } from "react-router-dom";
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import UserLink from "../../user-link";
import { UserDnsIdentityIcon } from "../../user-dns-identity-icon";
import useSubject from "../../../hooks/use-subject";
import appSettings from "../../../services/settings/app-settings";
@ -13,9 +13,10 @@ import { TrustProvider } from "../../../providers/trust";
import { NoteLink } from "../../note-link";
import Timestamp from "../../timestamp";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { InlineNoteContent } from "../../note/inline-note-content";
import { CompactNoteContent } from "../../compact-note-content";
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
import HoverLinkOverlay from "../../hover-link-overlay";
import singleEventService from "../../../services/single-event";
export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const { showSignatureVerification } = useSubject(appSettings);
@ -25,6 +26,7 @@ export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "child
const handleClick = useCallback<MouseEventHandler>(
(e) => {
e.preventDefault();
singleEventService.handleEvent(event);
navigate(to);
},
[navigate, to],
@ -44,7 +46,7 @@ export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "child
<Timestamp timestamp={event.created_at} />
</NoteLink>
</Flex>
<InlineNoteContent px="2" event={event} maxLength={96} />
<CompactNoteContent px="2" event={event} maxLength={96} />
</Card>
</TrustProvider>
);

View File

@ -3,7 +3,7 @@ import { Card, CardProps, Flex, LinkBox, Spacer, Text } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { TrustProvider } from "../../../providers/trust";
import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import UserLink from "../../user-link";
import Timestamp from "../../timestamp";
import ReactionIcon from "../../event-reactions/reaction-icon";
import { NoteLink } from "../../note-link";

View File

@ -17,8 +17,8 @@ import {
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import { InlineNoteContent } from "../../note/inline-note-content";
import UserLink from "../../user-link";
import { CompactNoteContent } from "../../compact-note-content";
import { getDownloadURL, getHashtags, getStreamURL } from "../../../helpers/nostr/stemstr";
import { DownloadIcon, ReplyIcon } from "../../icons";
import NoteZapButton from "../../note/note-zap-button";
@ -54,7 +54,7 @@ export default function EmbeddedStemstrTrack({ track, ...props }: Omit<CardProps
</CardHeader>
<CardBody p="2" display="flex" gap="2" flexDirection="column">
{player}
<InlineNoteContent event={track} />
<CompactNoteContent event={track} />
{hashtags.length > 0 && (
<Flex wrap="wrap" gap="2">
{hashtags.map((hashtag) => (

View File

@ -2,7 +2,7 @@ import { Box, Card, CardProps, Divider, Flex, Link, Text } from "@chakra-ui/reac
import { Link as RouterLink } from "react-router-dom";
import { NostrEvent, isATag } from "../../../types/nostr-event";
import { UserLink } from "../../user-link";
import UserLink from "../../user-link";
import UserAvatar from "../../user-avatar";
import ChatMessageContent from "../../../views/streams/stream/stream-chat/chat-message-content";
import useReplaceableEvent from "../../../hooks/use-replaceable-event";

View File

@ -4,7 +4,7 @@ import { Link as RouterLink, useNavigate } from "react-router-dom";
import { parseStreamEvent } from "../../../helpers/nostr/stream";
import { NostrEvent } from "../../../types/nostr-event";
import StreamStatusBadge from "../../../views/streams/components/status-badge";
import { UserLink } from "../../user-link";
import UserLink from "../../user-link";
import UserAvatar from "../../user-avatar";
import useEventNaddr from "../../../hooks/use-event-naddr";
import Timestamp from "../../timestamp";

View File

@ -0,0 +1,57 @@
import { Card, CardProps, Flex, LinkBox, Spacer, Text } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user-avatar-link";
import UserLink from "../../user-link";
import useSubject from "../../../hooks/use-subject";
import appSettings from "../../../services/settings/app-settings";
import EventVerificationIcon from "../../event-verification-icon";
import { TrustProvider } from "../../../providers/trust";
import Timestamp from "../../timestamp";
import { getNeventForEventId } from "../../../helpers/nip19";
import { CompactNoteContent } from "../../compact-note-content";
import HoverLinkOverlay from "../../hover-link-overlay";
import { getReferences } from "../../../helpers/nostr/events";
import useSingleEvent from "../../../hooks/use-single-event";
import { getTorrentTitle } from "../../../helpers/nostr/torrents";
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
import { MouseEventHandler, useCallback } from "react";
export default function EmbeddedTorrentComment({
comment,
...props
}: Omit<CardProps, "children"> & { comment: NostrEvent }) {
const navigate = useNavigateInDrawer();
const { showSignatureVerification } = useSubject(appSettings);
const refs = getReferences(comment);
const torrent = useSingleEvent(refs.rootId, refs.rootRelay ? [refs.rootRelay] : []);
const linkToTorrent = refs.rootId && `/torrents/${getNeventForEventId(refs.rootId)}`;
const handleClick = useCallback<MouseEventHandler>(
(e) => {
e.preventDefault();
if (linkToTorrent) navigate(linkToTorrent);
},
[navigate, linkToTorrent],
);
return (
<TrustProvider event={comment}>
<Card as={LinkBox} {...props}>
<Flex p="2" gap="2" alignItems="center">
<UserAvatarLink pubkey={comment.pubkey} size="xs" />
<UserLink pubkey={comment.pubkey} fontWeight="bold" isTruncated fontSize="lg" />
<Text>Commented on</Text>
<HoverLinkOverlay as={RouterLink} to={linkToTorrent} fontWeight="bold" onClick={handleClick}>
{torrent ? getTorrentTitle(torrent) : "torrent"}
</HoverLinkOverlay>
<Spacer />
{showSignatureVerification && <EventVerificationIcon event={comment} />}
<Timestamp timestamp={comment.created_at} />
</Flex>
<CompactNoteContent px="2" event={comment} maxLength={96} />
</Card>
</TrustProvider>
);
}

View File

@ -0,0 +1,79 @@
import { MouseEventHandler, useCallback } from "react";
import {
Button,
Card,
CardBody,
CardFooter,
CardHeader,
CardProps,
Flex,
Heading,
Link,
LinkBox,
Spacer,
Tag,
Text,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { getSharableEventAddress } from "../../../helpers/nip19";
import UserAvatarLink from "../../user-avatar-link";
import UserLink from "../../user-link";
import { NostrEvent } from "../../../types/nostr-event";
import Timestamp from "../../timestamp";
import Magnet from "../../icons/magnet";
import { getTorrentMagnetLink, getTorrentSize, getTorrentTitle } from "../../../helpers/nostr/torrents";
import { formatBytes } from "../../../helpers/number";
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
import HoverLinkOverlay from "../../hover-link-overlay";
export default function EmbeddedTorrent({ torrent, ...props }: Omit<CardProps, "children"> & { torrent: NostrEvent }) {
const navigate = useNavigateInDrawer();
const link = `/torrents/${getSharableEventAddress(torrent)}`;
const handleClick = useCallback<MouseEventHandler>(
(e) => {
e.preventDefault();
navigate(link);
},
[navigate, link],
);
return (
<Card as={LinkBox} {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0" flexWrap="wrap">
<Heading size="md">
<HoverLinkOverlay as={RouterLink} to={link} onClick={handleClick}>
{getTorrentTitle(torrent)}
</HoverLinkOverlay>
</Heading>
<UserAvatarLink pubkey={torrent.pubkey} size="xs" />
<UserLink pubkey={torrent.pubkey} isTruncated fontWeight="bold" fontSize="md" />
<Spacer />
<Timestamp timestamp={torrent.created_at} />
</CardHeader>
<CardBody p="2">
<Text>Size: {formatBytes(getTorrentSize(torrent))}</Text>
<Flex gap="2">
<Text>Tags:</Text>
{torrent.tags
.filter((t) => t[0] === "t")
.map(([_, tag]) => (
<Tag key={tag}>{tag}</Tag>
))}
</Flex>
</CardBody>
<CardFooter p="2" display="flex" pt="0" gap="4">
<Button
as={Link}
leftIcon={<Magnet boxSize={5} />}
href={getTorrentMagnetLink(torrent)}
isExternal
variant="link"
>
Download torrent
</Button>
</CardFooter>
</Card>
);
}

View File

@ -3,7 +3,7 @@ import { Box, Button, Card, CardBody, CardHeader, CardProps, Flex, Link, Text, u
import { getSharableEventAddress } from "../../../helpers/nip19";
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import UserLink from "../../user-link";
import { truncatedId } from "../../../helpers/nostr/events";
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
import { UserDnsIdentityIcon } from "../../user-dns-identity-icon";

View File

@ -10,7 +10,13 @@ import { NostrEvent } from "../../types/nostr-event";
import { STREAM_CHAT_MESSAGE_KIND, STREAM_KIND } from "../../helpers/nostr/stream";
import { GOAL_KIND } from "../../helpers/nostr/goal";
import { EMOJI_PACK_KIND } from "../../helpers/nostr/emoji-packs";
import { NOTE_LIST_KIND, PEOPLE_LIST_KIND } from "../../helpers/nostr/lists";
import {
BOOKMARK_LIST_KIND,
CHANNELS_LIST_KIND,
COMMUNITIES_LIST_KIND,
NOTE_LIST_KIND,
PEOPLE_LIST_KIND,
} from "../../helpers/nostr/lists";
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
import { STEMSTR_TRACK_KIND } from "../../helpers/nostr/stemstr";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
@ -28,6 +34,10 @@ import EmbeddedStreamMessage from "./event-types/embedded-stream-message";
import EmbeddedCommunity from "./event-types/embedded-community";
import EmbeddedReaction from "./event-types/embedded-reaction";
import EmbeddedDM from "./event-types/embedded-dm";
import { TORRENT_COMMENT_KIND, TORRENT_KIND } from "../../helpers/nostr/torrents";
import EmbeddedTorrent from "./event-types/embedded-torrent";
import EmbeddedTorrentComment from "./event-types/embedded-torrent-comment";
import EmbeddedChannel from "./event-types/embedded-channel";
const EmbeddedStemstrTrack = lazy(() => import("./event-types/embedded-stemstr-track"));
export type EmbedProps = {
@ -54,6 +64,9 @@ export function EmbedEvent({
return <EmbeddedEmojiPack pack={event} {...cardProps} />;
case PEOPLE_LIST_KIND:
case NOTE_LIST_KIND:
case BOOKMARK_LIST_KIND:
case COMMUNITIES_LIST_KIND:
case CHANNELS_LIST_KIND:
return <EmbeddedList list={event} {...cardProps} />;
case Kind.Article:
return <EmbeddedArticle article={event} {...cardProps} />;
@ -65,6 +78,12 @@ export function EmbedEvent({
return <EmbeddedCommunity community={event} {...cardProps} />;
case STEMSTR_TRACK_KIND:
return <EmbeddedStemstrTrack track={event} {...cardProps} />;
case TORRENT_KIND:
return <EmbeddedTorrent torrent={event} {...cardProps} />;
case TORRENT_COMMENT_KIND:
return <EmbeddedTorrentComment comment={event} {...cardProps} />;
case Kind.ChannelCreation:
return <EmbeddedChannel channel={event} {...cardProps} />;
}
return <EmbeddedUnknown event={event} {...cardProps} />;

View File

@ -1,21 +1,8 @@
import { Link } from "@chakra-ui/react";
import OpenGraphCard from "../open-graph-card";
import { isVideoURL } from "../../helpers/url";
import OpenGraphLink from "../open-graph-link";
export function renderVideoUrl(match: URL) {
if (!isVideoURL(match)) return null;
return (
<video
src={match.toString()}
controls
style={{ maxWidth: "30rem", maxHeight: "20rem", width: "100%", position: "relative", zIndex: 1 }}
/>
);
}
export function renderGenericUrl(match: URL) {
return (
<Link href={match.toString()} isExternal color="blue.500">

View File

@ -1,13 +1,4 @@
import {
CSSProperties,
MouseEventHandler,
MutableRefObject,
forwardRef,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { MouseEventHandler, MutableRefObject, forwardRef, useCallback, useMemo, useRef } from "react";
import { Image, ImageProps, Link, LinkProps } from "@chakra-ui/react";
import appSettings from "../../services/settings/app-settings";
@ -20,25 +11,7 @@ import PhotoGallery, { PhotoWithoutSize } from "../photo-gallery";
import { NostrEvent } from "../../types/nostr-event";
import useAppSettings from "../../hooks/use-app-settings";
import { useBreakpointValue } from "../../providers/breakpoint-provider";
function useElementBlur(initBlur = false): { style: CSSProperties; onClick: MouseEventHandler } {
const [blur, setBlur] = useState(initBlur);
const onClick = useCallback<MouseEventHandler>(
(e) => {
if (blur) {
e.stopPropagation();
e.preventDefault();
setBlur(false);
}
},
[blur],
);
const style: CSSProperties = blur ? { filter: "blur(1.5rem)", cursor: "pointer" } : {};
return { onClick, style };
}
import useElementBlur from "../../hooks/use-element-blur";
export type TrustImageProps = ImageProps;

View File

@ -7,3 +7,4 @@ export * from "./nostr";
export * from "./emoji";
export * from "./image";
export * from "./cashu";
export * from "./video";

View File

@ -3,14 +3,14 @@ import { Link as RouterLink } from "react-router-dom";
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { UserLink } from "../user-link";
import UserLink from "../user-link";
import { getMatchHashtag, getMatchNostrLink, stripInvisibleChar } from "../../helpers/regexp";
import { safeDecode } from "../../helpers/nip19";
import { EmbedEventPointer } from "../embed-event";
// nostr:nevent1qqsthg2qlxp9l7egtwa92t8lusm7pjknmjwa75ctrrpcjyulr9754fqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq36amnwvaz7tmwdaehgu3dwp6kytnhv4kxcmmjv3jhytnwv46q2qg5q9
// nostr:nevent1qqsq3wc73lqxd70lg43m5rul57d4mhcanttjat56e30yx5zla48qzlspz9mhxue69uhkummnw3e82efwvdhk6qgdwaehxw309ahx7uewd3hkcq5hsum
export function embedNostrLinks(content: EmbedableContent) {
export function embedNostrLinks(content: EmbedableContent, inline = false) {
return embedJSX(content, {
name: "nostr-link",
regexp: getMatchNostrLink(),
@ -27,7 +27,7 @@ export function embedNostrLinks(content: EmbedableContent) {
case "nevent":
case "naddr":
case "nrelay":
return <EmbedEventPointer pointer={decoded} />;
return inline === false ? <EmbedEventPointer pointer={decoded} /> : null;
default:
return null;
}

View File

@ -0,0 +1,35 @@
import styled from "@emotion/styled";
import { isVideoURL } from "../../helpers/url";
import useAppSettings from "../../hooks/use-app-settings";
import useElementBlur from "../../hooks/use-element-blur";
import { useTrusted } from "../../providers/trust";
const StyledVideo = styled.video`
max-width: 30rem;
max-height: 20rem;
width: 100%;
position: relative;
z-index: 1;
`;
function TrustVideo({ src }: { src: string }) {
const { blurImages } = useAppSettings();
const trusted = useTrusted();
const { onClick, handleEvent, style } = useElementBlur(!trusted);
return (
<StyledVideo
src={src}
controls
style={blurImages ? style : undefined}
onClick={blurImages ? onClick : undefined}
onPlay={blurImages ? handleEvent : undefined}
/>
);
}
export function renderVideoUrl(match: URL) {
if (!isVideoURL(match)) return null;
return <TrustVideo src={match.toString()} />;
}

View File

@ -0,0 +1,104 @@
import React, { useState } from "react";
import {
Modal,
ModalOverlay,
ModalContent,
ModalBody,
ModalCloseButton,
Button,
ModalProps,
Text,
Flex,
ButtonGroup,
Spacer,
ModalHeader,
} from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import UserAvatarLink from "../user-avatar-link";
import UserLink from "../user-link";
import { LightningIcon } from "../icons";
import { ParsedZap } from "../../helpers/nostr/zaps";
import { readablizeSats } from "../../helpers/bolt11";
import useEventReactions from "../../hooks/use-event-reactions";
import useEventZaps from "../../hooks/use-event-zaps";
import Timestamp from "../timestamp";
import { getEventUID } from "../../helpers/nostr/events";
import ReactionDetails from "./reaction-details";
import RepostDetails from "./repost-details";
const ZapEvent = React.memo(({ zap }: { zap: ParsedZap }) => {
if (!zap.payment.amount) return null;
return (
<>
<Flex gap="2" alignItems="center">
<UserAvatarLink pubkey={zap.request.pubkey} size="xs" mr="2" />
<UserLink pubkey={zap.request.pubkey} />
<Timestamp timestamp={zap.event.created_at} />
<Spacer />
<LightningIcon color="yellow.500" />
<Text fontWeight="bold">{readablizeSats(zap.payment.amount / 1000)}</Text>
</Flex>
<Text>{zap.request.content}</Text>
</>
);
});
export default function EventInteractionDetailsModal({
isOpen,
onClose,
event,
size = "2xl",
...props
}: Omit<ModalProps, "children"> & { event: NostrEvent }) {
const uuid = getEventUID(event);
const zaps = useEventZaps(uuid, [], true) ?? [];
const reactions = useEventReactions(uuid, [], true) ?? [];
const [tab, setTab] = useState(zaps.length > 0 ? "zaps" : "reactions");
const renderTab = () => {
switch (tab) {
case "reposts":
return <RepostDetails event={event} />;
case "reactions":
return <ReactionDetails reactions={reactions} />;
case "zaps":
return (
<>
{zaps
.sort((a, b) => b.request.created_at - a.request.created_at)
.map((zap) => (
<ZapEvent key={zap.request.id} zap={zap} />
))}
</>
);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size={size} {...props}>
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalHeader p={["2", "4"]}>
<ButtonGroup>
<Button size="sm" variant={tab === "zaps" ? "solid" : "outline"} onClick={() => setTab("zaps")}>
Zaps ({zaps.length})
</Button>
<Button size="sm" variant={tab === "reactions" ? "solid" : "outline"} onClick={() => setTab("reactions")}>
Reactions ({reactions.length})
</Button>
<Button size="sm" variant={tab === "reposts" ? "solid" : "outline"} onClick={() => setTab("reposts")}>
Reposts
</Button>
</ButtonGroup>
</ModalHeader>
<ModalBody px={["2", "4"]} pt="0" pb={["2", "4"]} display="flex" flexDirection="column" gap="2">
{renderTab()}
</ModalBody>
</ModalContent>
</Modal>
);
}

View File

@ -0,0 +1,55 @@
import { Box, Button, Divider, Flex, SimpleGrid, SimpleGridProps, useDisclosure } from "@chakra-ui/react";
import { useMemo } from "react";
import { NostrEvent } from "../../types/nostr-event";
import { groupReactions } from "../../helpers/nostr/reactions";
import UserAvatarLink from "../user-avatar-link";
import UserLink from "../user-link";
import ReactionIcon from "../event-reactions/reaction-icon";
function ShowMoreGrid({
pubkeys,
cutoff,
...props
}: Omit<SimpleGridProps, "children"> & { pubkeys: string[]; cutoff: number }) {
const showMore = useDisclosure();
const limited = pubkeys.length > cutoff && !showMore.isOpen ? pubkeys.slice(0, cutoff) : pubkeys;
return (
<>
<SimpleGrid spacing="1" {...props}>
{limited.map((pubkey) => (
<Flex gap="2" key={pubkey} alignItems="center" overflow="hidden">
<UserAvatarLink pubkey={pubkey} size="xs" />
<UserLink pubkey={pubkey} isTruncated />
</Flex>
))}
</SimpleGrid>
{limited.length !== pubkeys.length && (
<Button variant="link" size="md" onClick={showMore.onOpen}>
Show {pubkeys.length - limited.length} more
</Button>
)}
</>
);
}
export default function ReactionDetails({ reactions }: { reactions: NostrEvent[] }) {
const groups = useMemo(() => groupReactions(reactions), [reactions]);
return (
<Flex gap="2" direction="column">
{groups.map((group) => (
<Flex key={group.emoji} direction="column" gap="2">
<Flex gap="2" alignItems="center">
<Box fontSize="lg" borderWidth={1} w="8" h="8" borderRadius="md" p="1">
<ReactionIcon emoji={group.emoji} url={group.url} />
</Box>
<Divider />
</Flex>
<ShowMoreGrid pubkeys={group.pubkeys} columns={{ base: 2, sm: 3, md: 4 }} cutoff={12} />
</Flex>
))}
</Flex>
);
}

View File

@ -0,0 +1,30 @@
import { Button, Flex, SimpleGrid, SimpleGridProps, Text, useDisclosure } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import { NostrEvent } from "../../types/nostr-event";
import UserAvatarLink from "../user-avatar-link";
import UserLink from "../user-link";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useSubject from "../../hooks/use-subject";
import Timestamp from "../timestamp";
export default function RepostDetails({ event }: { event: NostrEvent }) {
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader(`${event.id}-reposts`, readRelays, { kinds: [Kind.Repost], "#e": [event.id] });
const reposts = useSubject(timeline.timeline);
return (
<>
{reposts.map((repost) => (
<Flex key={repost.id} gap="2" alignItems="center">
<UserAvatarLink pubkey={repost.pubkey} size="sm" />
<UserLink pubkey={repost.pubkey} fontWeight="bold" />
<Text>Shared</Text>
<Timestamp timestamp={repost.created_at} />
</Flex>
))}
</>
);
}

View File

@ -1,17 +1,14 @@
import { useMemo } from "react";
import { Button, useDisclosure } from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import useEventReactions from "../../hooks/use-event-reactions";
import { groupReactions } from "../../helpers/nostr/reactions";
import ReactionDetailsModal from "../reaction-details-modal";
import useCurrentAccount from "../../hooks/use-current-account";
import ReactionGroupButton from "./reaction-group-button";
import { useAddReaction } from "./common-hooks";
export default function EventReactionButtons({ event, max }: { event: NostrEvent; max?: number }) {
const account = useCurrentAccount();
const detailsModal = useDisclosure();
const reactions = useEventReactions(event.id) ?? [];
const grouped = useMemo(() => groupReactions(reactions), [reactions]);
@ -34,8 +31,6 @@ export default function EventReactionButtons({ event, max }: { event: NostrEvent
colorScheme={account && group.pubkeys.includes(account?.pubkey) ? "primary" : undefined}
/>
))}
<Button onClick={detailsModal.onOpen}>Show all</Button>
{detailsModal.isOpen && <ReactionDetailsModal isOpen onClose={detailsModal.onClose} reactions={reactions} />}
</>
);
}

View File

@ -28,6 +28,8 @@ import signingService from "../../services/signing";
import accountService from "../../services/account";
import PayStep from "./pay-step";
import { getInvoiceFromCallbackUrl } from "../../helpers/lnurl";
import UserLink from "../user-link";
import relayHintService from "../../services/event-relay-hint";
export type PayRequest = { invoice?: string; pubkey: string; error?: any };
@ -68,7 +70,7 @@ async function getPayRequestForPubkey(
.map((r) => r.url) ?? [],
)
.slice(0, 4);
const eventRelays = event ? relayScoreboardService.getRankedRelays(getEventRelays(event.id).value).slice(0, 4) : [];
const eventRelays = event ? relayHintService.getEventRelayHints(event, 4) : [];
const outbox = relayScoreboardService.getRankedRelays(clientRelaysService.getWriteUrls()).slice(0, 4);
const additional = relayScoreboardService.getRankedRelays(additionalRelays);
@ -199,7 +201,13 @@ export default function ZapModal({
<ModalContent>
<ModalCloseButton />
<ModalHeader px="4" pb="0" pt="4">
Zap Event
{event ? (
"Zap Event"
) : (
<>
Zap <UserLink pubkey={pubkey} fontWeight="bold" />
</>
)}
</ModalHeader>
<ModalBody padding="4">{renderContent()}</ModalBody>
</ModalContent>

View File

@ -10,7 +10,7 @@ import { EmbedEvent, EmbedProps } from "../embed-event";
import useAppSettings from "../../hooks/use-app-settings";
import CustomZapAmountOptions from "./zap-options";
import UserAvatar from "../user-avatar";
import { UserLink } from "../user-link";
import UserLink from "../user-link";
function UserCard({ pubkey, percent }: { pubkey: string; percent?: number }) {
const { address } = useUserLNURLMetadata(pubkey);
@ -109,7 +109,14 @@ export default function InputStep({
flex={1}
{...register("amount", { valueAsNumber: true, min: 1 })}
/>
<Button leftIcon={<LightningIcon />} type="submit" isLoading={isSubmitting} variant="solid" size="md">
<Button
leftIcon={<LightningIcon />}
type="submit"
isLoading={isSubmitting}
variant="solid"
size="md"
autoFocus
>
{actionName} {readablizeSats(watch("amount"))} sats
</Button>
</Flex>

View File

@ -3,7 +3,7 @@ import { Alert, Box, Button, ButtonGroup, Flex, IconButton, Spacer, useDisclosur
import { PayRequest } from ".";
import UserAvatar from "../user-avatar";
import { UserLink } from "../user-link";
import UserLink from "../user-link";
import { ChevronDownIcon, ChevronUpIcon, CheckIcon, ErrorIcon, LightningIcon } from "../icons";
import { InvoiceModalContent } from "../invoice-modal";
import { PropsWithChildren, useEffect, useState } from "react";
@ -141,6 +141,7 @@ export default function PayStep({ callbacks, onComplete }: { callbacks: PayReque
colorScheme="yellow"
onClick={payAllWithWebLN}
isLoading={payingAll}
isDisabled={!window.webln}
>
Pay All
</Button>

View File

@ -61,6 +61,8 @@ import Download01 from "./icons/download-01";
import Repeat01 from "./icons/repeat-01";
import ReverseLeft from "./icons/reverse-left";
import Pin01 from "./icons/pin-01";
import Translate01 from "./icons/translate-01";
import MessageChatSquare from "./icons/message-chat-square";
const defaultProps: IconProps = { boxSize: 4 };
@ -90,6 +92,7 @@ export const ChevronRightIcon = ChevronRight;
export const LightningIcon = Zap;
export const RelayIcon = Server04;
export const BroadcastEventIcon = Share07;
export const ShareIcon = Share07;
export const PinIcon = Pin01;
export const ExternalLinkIcon = Share04;
@ -192,7 +195,6 @@ export const BookmarkedIcon = createIcon({
export const V4VStreamIcon = PlayCircle;
export const V4VStopIcon = StopCircle;
/** @deprecated */
export const AddReactionIcon = createIcon({
displayName: "AddReactionIcon",
d: "M19.0001 13.9999V16.9999H22.0001V18.9999H18.9991L19.0001 21.9999H17.0001L16.9991 18.9999H14.0001V16.9999H17.0001V13.9999H19.0001ZM20.2426 4.75736C22.505 7.0244 22.5829 10.636 20.4795 12.992L19.06 11.574C20.3901 10.0499 20.3201 7.65987 18.827 6.1701C17.3244 4.67092 14.9076 4.60701 13.337 6.01688L12.0019 7.21524L10.6661 6.01781C9.09098 4.60597 6.67506 4.66808 5.17157 6.17157C3.68183 7.66131 3.60704 10.0473 4.97993 11.6232L13.412 20.069L11.9999 21.485L3.52138 12.993C1.41705 10.637 1.49571 7.01901 3.75736 4.75736C6.02157 2.49315 9.64519 2.41687 12.001 4.52853C14.35 2.42 17.98 2.49 20.2426 4.75736Z",
@ -212,7 +214,6 @@ export const AppearanceIcon = Colors;
export const DatabaseIcon = Database01;
export const PerformanceIcon = Speedometer03;
/** @deprecated */
export const CommunityIcon = createIcon({
displayName: "CommunityIcon",
d: "M9.55 11.5C8.30736 11.5 7.3 10.4926 7.3 9.25C7.3 8.00736 8.30736 7 9.55 7C10.7926 7 11.8 8.00736 11.8 9.25C11.8 10.4926 10.7926 11.5 9.55 11.5ZM10 19.748V16.4C10 15.9116 10.1442 15.4627 10.4041 15.0624C10.1087 15.0213 9.80681 15 9.5 15C7.93201 15 6.49369 15.5552 5.37091 16.4797C6.44909 18.0721 8.08593 19.2553 10 19.748ZM4.45286 14.66C5.86432 13.6168 7.61013 13 9.5 13C10.5435 13 11.5431 13.188 12.4667 13.5321C13.3447 13.1888 14.3924 13 15.5 13C17.1597 13 18.6849 13.4239 19.706 14.1563C19.8976 13.4703 20 12.7471 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 12.9325 4.15956 13.8278 4.45286 14.66ZM18.8794 16.0859C18.4862 15.5526 17.1708 15 15.5 15C13.4939 15 12 15.7967 12 16.4V20C14.9255 20 17.4843 18.4296 18.8794 16.0859ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM15.5 12.5C14.3954 12.5 13.5 11.6046 13.5 10.5C13.5 9.39543 14.3954 8.5 15.5 8.5C16.6046 8.5 17.5 9.39543 17.5 10.5C17.5 11.6046 16.6046 12.5 15.5 12.5Z",
@ -229,3 +230,7 @@ export const GhostIcon = createIcon({
export const ECashIcon = BankNote01;
export const WalletIcon = Wallet02;
export const DownloadIcon = Download01;
export const TranslateIcon = Translate01;
export const ChannelsIcon = MessageChatSquare;

View File

@ -0,0 +1,18 @@
import { createIcon } from "@chakra-ui/icons";
const Magnet = createIcon({
displayName: "Magnet",
viewBox: "0 0 24 24",
path: [
<path
d="M 19.504983,4.5176541 C 17.87262,2.8549106 15.707242,1.9237742 13.408609,1.9237742 c -2.165378,0 -4.1975029,0.7981169 -5.7299246,2.3278409 L 2.5484026,9.4393749 c -0.8328379,0.8313721 -0.8328379,2.1948221 0,3.0261931 l 1.9654976,1.962037 c 0.8328378,0.831372 2.1986922,0.831372 3.0315302,0 L 12.37589,9.6389042 c 0.599643,-0.5985877 1.532422,-0.6983525 2.098752,-0.2327843 0.299822,0.2327843 0.466389,0.5985881 0.499703,0.9976461 0.03331,0.465568 -0.166568,0.897882 -0.499703,1.230431 l -4.83046,4.821956 c -0.8328381,0.831372 -0.8328381,2.194821 0,3.026193 l 1.965498,1.962037 c 0.399761,0.399058 0.966092,0.631843 1.499108,0.631843 0.533016,0 1.099346,-0.19953 1.499108,-0.631843 l 5.163596,-5.154505 C 22.936275,13.130666 22.836334,7.8763962 19.504983,4.5176541 Z M 6.4793979,13.330194 c -0.2331948,0.232785 -0.6662703,0.232785 -0.8994651,0 L 3.6144352,11.368158 c -0.2331945,-0.232785 -0.2331945,-0.665098 0,-0.897882 L 5.3134247,8.7742775 8.178387,11.634197 Z m 7.0624651,7.050033 c -0.233195,0.232785 -0.66627,0.232785 -0.899464,0 l -1.965498,-1.962036 c -0.233195,-0.232785 -0.233195,-0.665098 0,-0.897883 l 1.698989,-1.695998 2.864963,2.859919 z m 5.196909,-5.154505 -2.398573,2.394352 -2.864962,-2.859919 2.098751,-2.095058 c 0.632957,-0.631842 0.966091,-1.496469 0.932779,-2.361096 C 16.473453,9.47263 16.07369,8.7410225 15.440734,8.2089446 14.907717,7.7766311 14.274761,7.577102 13.608491,7.577102 c -0.832838,0 -1.665677,0.3325489 -2.298634,0.9643913 L 9.2777332,10.570041 6.4127708,7.7101218 8.8113439,5.3157711 C 10.043944,4.1185957 11.676306,3.4202433 13.441923,3.4202433 c 0,0 0.03331,0 0.03331,0 1.89887,0 3.664487,0.7648621 5.030341,2.1615665 2.665081,2.7601543 2.798335,7.0832872 0.233195,9.6439122 z"
id="path1"
stroke="currentColor"
fill="currentColor"
strokeWidth="0.332843"
></path>,
],
defaultProps: { boxSize: 4 },
});
export default Magnet;

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="24"
height="24"
version="1.1"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<path
d="M 19.504983,4.5176541 C 17.87262,2.8549106 15.707242,1.9237742 13.408609,1.9237742 c -2.165378,0 -4.1975029,0.7981169 -5.7299246,2.3278409 L 2.5484026,9.4393749 c -0.8328379,0.8313721 -0.8328379,2.1948221 0,3.0261931 l 1.9654976,1.962037 c 0.8328378,0.831372 2.1986922,0.831372 3.0315302,0 L 12.37589,9.6389042 c 0.599643,-0.5985877 1.532422,-0.6983525 2.098752,-0.2327843 0.299822,0.2327843 0.466389,0.5985881 0.499703,0.9976461 0.03331,0.465568 -0.166568,0.897882 -0.499703,1.230431 l -4.83046,4.821956 c -0.8328381,0.831372 -0.8328381,2.194821 0,3.026193 l 1.965498,1.962037 c 0.399761,0.399058 0.966092,0.631843 1.499108,0.631843 0.533016,0 1.099346,-0.19953 1.499108,-0.631843 l 5.163596,-5.154505 C 22.936275,13.130666 22.836334,7.8763962 19.504983,4.5176541 Z M 6.4793979,13.330194 c -0.2331948,0.232785 -0.6662703,0.232785 -0.8994651,0 L 3.6144352,11.368158 c -0.2331945,-0.232785 -0.2331945,-0.665098 0,-0.897882 L 5.3134247,8.7742775 8.178387,11.634197 Z m 7.0624651,7.050033 c -0.233195,0.232785 -0.66627,0.232785 -0.899464,0 l -1.965498,-1.962036 c -0.233195,-0.232785 -0.233195,-0.665098 0,-0.897883 l 1.698989,-1.695998 2.864963,2.859919 z m 5.196909,-5.154505 -2.398573,2.394352 -2.864962,-2.859919 2.098751,-2.095058 c 0.632957,-0.631842 0.966091,-1.496469 0.932779,-2.361096 C 16.473453,9.47263 16.07369,8.7410225 15.440734,8.2089446 14.907717,7.7766311 14.274761,7.577102 13.608491,7.577102 c -0.832838,0 -1.665677,0.3325489 -2.298634,0.9643913 L 9.2777332,10.570041 6.4127708,7.7101218 8.8113439,5.3157711 C 10.043944,4.1185957 11.676306,3.4202433 13.441923,3.4202433 c 0,0 0.03331,0 0.03331,0 1.89887,0 3.664487,0.7648621 5.030341,2.1615665 2.665081,2.7601543 2.798335,7.0832872 0.233195,9.6439122 z"
id="path1"
stroke-width="0.332843" stroke="currentColor" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -9,7 +9,7 @@ import useCurrentAccount from "../../hooks/use-current-account";
import useSubject from "../../hooks/use-subject";
import accountService from "../../services/account";
import UserAvatar from "../user-avatar";
import { UserLink } from "../user-link";
import UserLink from "../user-link";
import { GhostIcon } from "../icons";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useReadRelayUrls } from "../../hooks/use-client-relays";

View File

@ -34,7 +34,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<ReloadPrompt mb="2" />
<Flex direction={{ base: "column", md: "row" }}>
<Flex direction={{ base: "column", md: "row" }} minH="100vh">
<Spacer display={["none", null, "block"]} />
{!isMobile && <DesktopSideNav position="sticky" top="0" flexShrink={0} />}
<Container

View File

@ -1,7 +1,6 @@
import { Box, Button, ButtonProps, Text } from "@chakra-ui/react";
import { useLocation } from "react-router-dom";
import { Box, Button, ButtonProps, Link, Text, useDisclosure } from "@chakra-ui/react";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { Link as RouterLink } from "react-router-dom";
import {
BadgeIcon,
@ -20,14 +19,21 @@ import {
LogoutIcon,
NotesIcon,
LightningIcon,
ChannelsIcon,
} from "../icons";
import useCurrentAccount from "../../hooks/use-current-account";
import accountService from "../../services/account";
import { useLocalStorage } from "react-use";
import ZapModal from "../event-zap-modal";
import dayjs from "dayjs";
export default function NavItems() {
const location = useLocation();
const account = useCurrentAccount();
const donateModal = useDisclosure();
const [lastDonate, setLastDonate] = useLocalStorage<number>("last-donate");
const buttonProps: ButtonProps = {
py: "2",
justifyContent: "flex-start",
@ -41,6 +47,7 @@ export default function NavItems() {
else if (location.pathname.startsWith("/relays")) active = "relays";
else if (location.pathname.startsWith("/lists")) active = "lists";
else if (location.pathname.startsWith("/communities")) active = "communities";
else if (location.pathname.startsWith("/channels")) active = "channels";
else if (location.pathname.startsWith("/c/")) active = "communities";
else if (location.pathname.startsWith("/goals")) active = "goals";
else if (location.pathname.startsWith("/badges")) active = "badges";
@ -49,6 +56,8 @@ export default function NavItems() {
else if (location.pathname.startsWith("/tools")) active = "tools";
else if (location.pathname.startsWith("/search")) active = "search";
else if (location.pathname.startsWith("/t/")) active = "search";
else if (location.pathname.startsWith("/torrents")) active = "tools";
else if (location.pathname.startsWith("/map")) active = "tools";
else if (location.pathname.startsWith("/profile")) active = "profile";
else if (
account &&
@ -141,6 +150,15 @@ export default function NavItems() {
>
Communities
</Button>
<Button
as={RouterLink}
to="/channels"
leftIcon={<ChannelsIcon boxSize={6} />}
colorScheme={active === "channels" ? "primary" : undefined}
{...buttonProps}
>
Channels
</Button>
<Button
as={RouterLink}
to="/lists"
@ -196,9 +214,29 @@ export default function NavItems() {
>
Settings
</Button>
{/* <Button leftIcon={<LightningIcon boxSize={6} color="yellow.400" />} {...buttonProps}>
Donate
</Button> */}
{(lastDonate === undefined || dayjs.unix(lastDonate).isBefore(dayjs().subtract(1, "week"))) && (
<Button
as={Link}
leftIcon={<LightningIcon boxSize={6} color="yellow.400" />}
href="https://geyser.fund/project/nostrudel"
isExternal
onClick={(e) => {
e.preventDefault();
donateModal.onOpen();
}}
{...buttonProps}
>
Donate
</Button>
)}
{donateModal.isOpen && (
<ZapModal
isOpen
pubkey="713978c3094081b34fcf2f5491733b0c22728cd3b7a6946519d40f5f08598af8"
onClose={donateModal.onClose}
onZapped={() => setLastDonate(dayjs().unix())}
/>
)}
{account && (
<Button onClick={() => accountService.logout()} leftIcon={<LogoutIcon boxSize={6} />} {...buttonProps}>
Logout

View File

@ -31,7 +31,7 @@ declare module "yet-another-react-lightbox" {
import { NostrEvent } from "../types/nostr-event";
import UserAvatarLink from "./user-avatar-link";
import { UserLink } from "./user-link";
import UserLink from "./user-link";
import { UserDnsIdentityIcon } from "./user-dns-identity-icon";
import styled from "@emotion/styled";
import { getSharableEventAddress } from "../helpers/nip19";

View File

@ -1,20 +1,20 @@
import { useMemo } from "react";
import { Link, LinkProps } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { truncatedId } from "../helpers/nostr/events";
import { getNeventForEventId } from "../helpers/nip19";
export type NoteLinkProps = LinkProps & {
noteId: string;
};
export const NoteLink = ({ children, noteId, color = "blue.500", ...props }: NoteLinkProps) => {
const encoded = useMemo(() => nip19.noteEncode(noteId), [noteId]);
const nevent = useMemo(() => getNeventForEventId(noteId), [noteId]);
return (
<Link as={RouterLink} to={`/n/${encoded}`} color={color} {...props}>
{children || truncatedId(nip19.noteEncode(noteId))}
<Link as={RouterLink} to={`/n/${nevent}`} color={color} {...props}>
{children || truncatedId(nevent)}
</Link>
);
};

View File

@ -5,13 +5,10 @@ import {
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Button,
Card,
CardBody,
CardHeader,
Flex,
Heading,
Modal,
ModalBody,
ModalCloseButton,
@ -28,13 +25,13 @@ import {
import dayjs from "dayjs";
import codes from "iso-language-codes";
import { DraftNostrEvent, NostrEvent, isETag, isPTag } from "../../types/nostr-event";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { getEventUID } from "../../helpers/nostr/events";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useSubject from "../../hooks/use-subject";
import UserAvatarLink from "../user-avatar-link";
import { UserLink } from "../user-link";
import UserLink from "../user-link";
import { useSigningContext } from "../../providers/signing-provider";
import relayScoreboardService from "../../services/relay-scoreboard";
import NostrPublishAction from "../../classes/nostr-publish-action";
@ -137,6 +134,7 @@ function TranslationOffer({ offer }: { offer: NostrEvent }) {
<Button
colorScheme="yellow"
size="sm"
variant="solid"
leftIcon={<LightningIcon />}
onClick={payInvoice}
isLoading={paying || paid}
@ -229,7 +227,7 @@ export default function NoteTranslationModal({
</option>
))}
</Select>
<Button colorScheme="primary" onClick={requestTranslation} flexShrink={0}>
<Button size="md" variant="solid" colorScheme="primary" onClick={requestTranslation} flexShrink={0}>
Request new translation
</Button>
</Flex>

View File

@ -12,6 +12,7 @@ import {
useDisclosure,
useToast,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import useCurrentAccount from "../../../hooks/use-current-account";
import { useSigningContext } from "../../../providers/signing-provider";
@ -22,14 +23,16 @@ import {
listRemoveEvent,
getEventsFromList,
getListName,
BOOKMARK_LIST_KIND,
} from "../../../helpers/nostr/lists";
import { NostrEvent } from "../../../types/nostr-event";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import { getEventCoordinate } from "../../../helpers/nostr/events";
import clientRelaysService from "../../../services/client-relays";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import { BookmarkIcon, BookmarkedIcon, PlusCircleIcon } from "../../icons";
import NewListModal from "../../../views/lists/components/new-list-modal";
import replaceableEventLoaderService from "../../../services/replaceable-event-requester";
import userUserBookmarksList from "../../../hooks/use-user-bookmarks-list";
export default function BookmarkButton({ event, ...props }: { event: NostrEvent } & Omit<IconButtonProps, "icon">) {
const toast = useToast();
@ -38,8 +41,37 @@ export default function BookmarkButton({ event, ...props }: { event: NostrEvent
const { requestSignature } = useSigningContext();
const [isLoading, setLoading] = useState(false);
const { list: bookmarkList, pointers: bookmarkPointers } = userUserBookmarksList();
const lists = useUserLists(account?.pubkey).filter((list) => list.kind === NOTE_LIST_KIND);
const isBookmarked = bookmarkPointers.some((p) => p.id === event.id);
const handleBookmarkClick = useCallback(async () => {
const writeRelays = clientRelaysService.getWriteUrls();
setLoading(true);
try {
let draft: DraftNostrEvent = {
kind: BOOKMARK_LIST_KIND,
content: bookmarkList?.content ?? "",
tags: bookmarkList?.tags ?? [],
created_at: dayjs().unix(),
};
if (isBookmarked) {
draft = listRemoveEvent(draft, event.id);
const signed = await requestSignature(draft);
new NostrPublishAction("Remove Bookmark", writeRelays, signed);
} else {
draft = listAddEvent(draft, event.id);
const signed = await requestSignature(draft);
new NostrPublishAction("Bookmark Note", writeRelays, signed);
}
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(false);
}, [event.id, requestSignature, bookmarkList, isBookmarked]);
const inLists = lists.filter((list) => getEventsFromList(list).some((p) => p.id === event.id));
const handleChange = useCallback(
@ -76,14 +108,22 @@ export default function BookmarkButton({ event, ...props }: { event: NostrEvent
return (
<>
<Menu closeOnSelect={false}>
<Menu isLazy closeOnSelect={false}>
<MenuButton
as={IconButton}
icon={inLists.length > 0 ? <BookmarkedIcon /> : <BookmarkIcon />}
icon={inLists.length > 0 || isBookmarked ? <BookmarkedIcon /> : <BookmarkIcon />}
isDisabled={account?.readonly ?? true}
{...props}
/>
<MenuList minWidth="240px">
<MenuItem
icon={isBookmarked ? <BookmarkedIcon /> : <BookmarkIcon />}
isDisabled={account?.readonly || isLoading}
onClick={handleBookmarkClick}
>
Bookmark
</MenuItem>
<MenuDivider />
{lists.length > 0 && (
<MenuOptionGroup
type="checkbox"
@ -94,7 +134,7 @@ export default function BookmarkButton({ event, ...props }: { event: NostrEvent
<MenuItemOption
key={getEventCoordinate(list)}
value={getEventCoordinate(list)}
isDisabled={account?.readonly && isLoading}
isDisabled={account?.readonly || isLoading}
isTruncated
maxW="90vw"
>

View File

@ -0,0 +1,20 @@
import { IconButton, IconButtonProps } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import InfoCircle from "../../icons/info-circle";
import useEventReactions from "../../../hooks/use-event-reactions";
import { getEventUID } from "../../../helpers/nostr/events";
import useEventZaps from "../../../hooks/use-event-zaps";
export function NoteDetailsButton({
event,
...props
}: { event: NostrEvent } & Omit<IconButtonProps, "icon" | "aria-label">) {
const uuid = getEventUID(event);
const reactions = useEventReactions(uuid) ?? [];
const zaps = useEventZaps(uuid);
if (reactions.length === 0 && zaps.length === 0) return null;
return <IconButton icon={<InfoCircle />} aria-label="Note Details" title="Note Details" {...props} />;
}

View File

@ -6,6 +6,8 @@ import {
PopoverBody,
PopoverContent,
PopoverTrigger,
useBoolean,
useToast,
} from "@chakra-ui/react";
import useEventReactions from "../../../hooks/use-event-reactions";
@ -17,26 +19,44 @@ import NostrPublishAction from "../../../classes/nostr-publish-action";
import { AddReactionIcon } from "../../icons";
import ReactionPicker from "../../reaction-picker";
import { draftEventReaction } from "../../../helpers/nostr/reactions";
import { getEventUID } from "../../../helpers/nostr/events";
import { useState } from "react";
export default function ReactionButton({ event, ...props }: { event: NostrEvent } & Omit<ButtonProps, "children">) {
const toast = useToast();
const { requestSignature } = useSigningContext();
const reactions = useEventReactions(event.id) ?? [];
const reactions = useEventReactions(getEventUID(event)) ?? [];
const [popover, setPopover] = useBoolean();
const [loading, setLoading] = useState(false);
const addReaction = async (emoji = "+", url?: string) => {
const draft = draftEventReaction(event, emoji, url);
setLoading(true);
try {
const draft = draftEventReaction(event, emoji, url);
const signed = await requestSignature(draft);
if (signed) {
const writeRelays = clientRelaysService.getWriteUrls();
new NostrPublishAction("Reaction", writeRelays, signed);
eventReactionsService.handleEvent(signed);
const signed = await requestSignature(draft);
if (signed) {
const writeRelays = clientRelaysService.getWriteUrls();
new NostrPublishAction("Reaction", writeRelays, signed);
eventReactionsService.handleEvent(signed);
setPopover.off();
}
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(false);
};
return (
<Popover isLazy>
<Popover isLazy isOpen={popover} onOpen={setPopover.on} onClose={setPopover.off}>
<PopoverTrigger>
<IconButton icon={<AddReactionIcon />} aria-label="Add Reaction" {...props}>
<IconButton
icon={<AddReactionIcon />}
aria-label="Add Reaction"
title="Add Reaction"
isLoading={loading}
{...props}
>
{reactions?.length ?? 0}
</IconButton>
</PopoverTrigger>

View File

@ -13,28 +13,25 @@ import {
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import dayjs from "dayjs";
import type { AddressPointer } from "nostr-tools/lib/types/nip19";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import { EmbedEvent } from "../../embed-event";
import { getEventRelays } from "../../../services/event-relays";
import relayScoreboardService from "../../../services/relay-scoreboard";
import { Kind } from "nostr-tools";
import dayjs from "dayjs";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import clientRelaysService from "../../../services/client-relays";
import { useSigningContext } from "../../../providers/signing-provider";
import { ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon } from "../../icons";
import useUserCommunitiesList from "../../../hooks/use-user-communities-list";
import useCurrentAccount from "../../../hooks/use-current-account";
import { AddressPointer } from "nostr-tools/lib/types/nip19";
import { createCoordinate } from "../../../services/replaceable-event-requester";
import relayHintService from "../../../services/event-relay-hint";
function buildRepost(event: NostrEvent): DraftNostrEvent {
const relays = getEventRelays(event.id).value;
const topRelay = relayScoreboardService.getRankedRelays(relays)[0] ?? "";
const hint = relayHintService.getEventRelayHint(event);
const tags: NostrEvent["tags"] = [];
tags.push(["e", event.id, topRelay]);
tags.push(["e", event.id, hint ?? ""]);
return {
kind: Kind.Repost,
@ -65,6 +62,7 @@ export default function RepostModal({
draftRepost.tags.push([
"a",
createCoordinate(communityPointer.kind, communityPointer.pubkey, communityPointer.identifier),
relayHintService.getAddressPointerRelayHint(communityPointer) ?? "",
]);
}
const signed = await requestSignature(draftRepost);

View File

@ -20,7 +20,7 @@ import { Link as RouterLink } from "react-router-dom";
import NoteMenu from "./note-menu";
import { EventRelays } from "./note-relays";
import { UserLink } from "../user-link";
import UserLink from "../user-link";
import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
import NoteZapButton from "./note-zap-button";
import { ExpandProvider } from "../../providers/expanded";
@ -43,11 +43,13 @@ import OpenInDrawerButton from "../open-in-drawer-button";
import { getSharableEventAddress } from "../../helpers/nip19";
import { useBreakpointValue } from "../../providers/breakpoint-provider";
import HoverLinkOverlay from "../hover-link-overlay";
import { nip19 } from "nostr-tools";
import NoteCommunityMetadata from "./note-community-metadata";
import useSingleEvent from "../../hooks/use-single-event";
import { InlineNoteContent } from "./inline-note-content";
import { CompactNoteContent } from "../compact-note-content";
import NoteProxyLink from "./components/note-proxy-link";
import { NoteDetailsButton } from "./components/note-details-button";
import EventInteractionDetailsModal from "../event-interactions-modal";
import singleEventService from "../../services/single-event";
export type NoteProps = Omit<CardProps, "children"> & {
event: NostrEvent;
@ -72,6 +74,7 @@ export const Note = React.memo(
const account = useCurrentAccount();
const { showReactions, showSignatureVerification } = useSubject(appSettings);
const replyForm = useDisclosure();
const detailsModal = useDisclosure();
// if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null);
@ -80,9 +83,9 @@ export const Note = React.memo(
const refs = getReferences(event);
const repliedTo = useSingleEvent(refs.replyId);
const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false });
const showReactionsOnNewLine = useBreakpointValue({ base: true, lg: false });
const reactionButtons = showReactions && <NoteReactions event={event} flexWrap="wrap" variant="ghost" size="xs" />;
const reactionButtons = showReactions && <NoteReactions event={event} flexWrap="wrap" variant="ghost" size="sm" />;
return (
<TrustProvider event={event}>
@ -94,7 +97,13 @@ export const Note = React.memo(
data-event-id={event.id}
{...props}
>
{clickable && <HoverLinkOverlay as={RouterLink} to={`/n/${nip19.noteEncode(event.id)}`} />}
{clickable && (
<HoverLinkOverlay
as={RouterLink}
to={`/n/${getSharableEventAddress(event)}`}
onClick={() => singleEventService.handleEvent(event)}
/>
)}
<CardHeader p="2">
<Flex flex="1" gap="2" alignItems="center">
<UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} />
@ -103,9 +112,14 @@ export const Note = React.memo(
<Flex grow={1} />
{showSignatureVerification && <EventVerificationIcon event={event} />}
{!hideDrawerButton && (
<OpenInDrawerButton to={`/n/${getSharableEventAddress(event)}`} size="sm" variant="ghost" />
<OpenInDrawerButton
to={`/n/${getSharableEventAddress(event)}`}
size="sm"
variant="ghost"
onClick={() => singleEventService.handleEvent(event)}
/>
)}
<Link as={RouterLink} whiteSpace="nowrap" color="current" to={`/n/${nip19.noteEncode(event.id)}`}>
<Link as={RouterLink} whiteSpace="nowrap" color="current" to={`/n/${getSharableEventAddress(event)}`}>
<Timestamp timestamp={event.created_at} />
</Link>
</Flex>
@ -116,7 +130,7 @@ export const Note = React.memo(
<Text>
Replying to <UserLink pubkey={repliedTo.pubkey} fontWeight="bold" />
</Text>
<InlineNoteContent event={repliedTo} maxLength={96} isTruncated />
<CompactNoteContent event={repliedTo} maxLength={96} isTruncated textOnly />
</Flex>
)}
</CardHeader>
@ -126,7 +140,7 @@ export const Note = React.memo(
<CardFooter padding="2" display="flex" gap="2" flexDirection="column" alignItems="flex-start">
{showReactionsOnNewLine && reactionButtons}
<Flex gap="2" w="full" alignItems="center">
<ButtonGroup size="xs" variant="ghost" isDisabled={account?.readonly ?? true}>
<ButtonGroup size="sm" variant="ghost" isDisabled={account?.readonly ?? true}>
{showReplyButton && (
<IconButton icon={<ReplyIcon />} aria-label="Reply" title="Reply" onClick={replyForm.onOpen} />
)}
@ -136,10 +150,12 @@ export const Note = React.memo(
</ButtonGroup>
{!showReactionsOnNewLine && reactionButtons}
<Box flexGrow={1} />
<NoteProxyLink event={event} size="xs" variant="ghost" />
<EventRelays event={event} />
<BookmarkButton event={event} aria-label="Bookmark note" size="xs" variant="ghost" />
<NoteMenu event={event} size="xs" variant="ghost" aria-label="More Options" />
<ButtonGroup size="sm" variant="ghost">
<NoteProxyLink event={event} />
<NoteDetailsButton event={event} onClick={detailsModal.onOpen} />
<BookmarkButton event={event} aria-label="Bookmark note" />
<NoteMenu event={event} aria-label="More Options" detailsClick={detailsModal.onOpen} />
</ButtonGroup>
</Flex>
</CardFooter>
</Card>
@ -147,6 +163,7 @@ export const Note = React.memo(
{replyForm.isOpen && (
<ReplyForm item={{ event, replies: [], refs }} onCancel={replyForm.onClose} onSubmitted={replyForm.onClose} />
)}
{detailsModal.isOpen && <EventInteractionDetailsModal isOpen onClose={detailsModal.onClose} event={event} />}
</TrustProvider>
);
},

View File

@ -1,92 +1,30 @@
import { useCallback, useState } from "react";
import { MenuItem, useDisclosure, useToast } from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { nip19 } from "nostr-tools";
import dayjs from "dayjs";
import { useCallback } from "react";
import { MenuItem, useDisclosure } from "@chakra-ui/react";
import {
BroadcastEventIcon,
CopyToClipboardIcon,
CodeIcon,
ExternalLinkIcon,
LikeIcon,
MuteIcon,
RepostIcon,
TrashIcon,
UnmuteIcon,
PinIcon,
} from "../icons";
import { getSharableEventAddress } from "../../helpers/nip19";
import { DraftNostrEvent, NostrEvent, isETag } from "../../types/nostr-event";
import { BroadcastEventIcon, CodeIcon } from "../icons";
import { NostrEvent } from "../../types/nostr-event";
import { CustomMenuIconButton, MenuIconButtonProps } from "../menu-icon-button";
import NoteReactionsModal from "./note-zaps-modal";
import NoteDebugModal from "../debug-modals/note-debug-modal";
import useCurrentAccount from "../../hooks/use-current-account";
import { buildAppSelectUrl } from "../../helpers/nostr/apps";
import { useDeleteEventContext } from "../../providers/delete-event-provider";
import clientRelaysService from "../../services/client-relays";
import { handleEventFromRelay } from "../../services/event-relays";
import NostrPublishAction from "../../classes/nostr-publish-action";
import useUserMuteFunctions from "../../hooks/use-user-mute-functions";
import { useMuteModalContext } from "../../providers/mute-modal-provider";
import NoteTranslationModal from "../note-translation-modal";
import Translate01 from "../icons/translate-01";
import useUserPinList from "../../hooks/use-user-pin-list";
import { useSigningContext } from "../../providers/signing-provider";
import { PIN_LIST_KIND, listAddEvent, listRemoveEvent } from "../../helpers/nostr/lists";
import InfoCircle from "../icons/info-circle";
import PinNoteMenuItem from "../common-menu-items/pin-note";
import CopyShareLinkMenuItem from "../common-menu-items/copy-share-link";
import OpenInAppMenuItem from "../common-menu-items/open-in-app";
import MuteUserMenuItem from "../common-menu-items/mute-user";
import DeleteEventMenuItem from "../common-menu-items/delete-event";
import CopyEmbedCodeMenuItem from "../common-menu-items/copy-embed-code";
function PinNoteItem({ event }: { event: NostrEvent }) {
const toast = useToast();
const account = useCurrentAccount();
const { requestSignature } = useSigningContext();
const { list } = useUserPinList(account?.pubkey);
const isPinned = list?.tags.some((t) => isETag(t) && t[1] === event.id) ?? false;
const label = isPinned ? "Unpin Note" : "Pin Note";
const [loading, setLoading] = useState(false);
const togglePin = useCallback(async () => {
try {
setLoading(true);
let draft: DraftNostrEvent = {
kind: PIN_LIST_KIND,
created_at: dayjs().unix(),
content: list?.content ?? "",
tags: list?.tags ? Array.from(list.tags) : [],
};
if (isPinned) draft = listRemoveEvent(draft, event.id);
else draft = listAddEvent(draft, event.id);
const signed = await requestSignature(draft);
new NostrPublishAction(label, clientRelaysService.getWriteUrls(), signed);
setLoading(false);
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
}, [list, isPinned]);
if (event.pubkey !== account?.pubkey) return null;
return (
<MenuItem onClick={togglePin} icon={<PinIcon />} isDisabled={loading || account.readonly}>
{label}
</MenuItem>
);
}
export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
const account = useCurrentAccount();
const infoModal = useDisclosure();
const reactionsModal = useDisclosure();
export default function NoteMenu({
event,
detailsClick,
...props
}: { event: NostrEvent; detailsClick?: () => void } & Omit<MenuIconButtonProps, "children">) {
const debugModal = useDisclosure();
const translationsModal = useDisclosure();
const { isMuted, mute, unmute } = useUserMuteFunctions(event.pubkey);
const { openModal } = useMuteModalContext();
const { deleteEvent } = useDeleteEventContext();
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const noteId = nip19.noteEncode(event.id);
const broadcast = useCallback(() => {
const missingRelays = clientRelaysService.getWriteUrls();
@ -96,36 +34,17 @@ export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Om
});
}, []);
const address = getSharableEventAddress(event);
return (
<>
<CustomMenuIconButton {...props}>
{address && (
<MenuItem onClick={() => window.open(buildAppSelectUrl(address), "_blank")} icon={<ExternalLinkIcon />}>
View in app...
</MenuItem>
)}
{account?.pubkey !== event.pubkey && (
<MenuItem
onClick={isMuted ? unmute : () => openModal(event.pubkey)}
icon={isMuted ? <UnmuteIcon /> : <MuteIcon />}
color="red.500"
>
{isMuted ? "Unmute User" : "Mute User"}
</MenuItem>
)}
<MenuItem onClick={() => copyToClipboard("nostr:" + address)} icon={<RepostIcon />}>
Copy Share Link
</MenuItem>
{noteId && (
<MenuItem onClick={() => copyToClipboard(noteId)} icon={<CopyToClipboardIcon />}>
Copy Note ID
</MenuItem>
)}
{account?.pubkey === event.pubkey && (
<MenuItem icon={<TrashIcon />} color="red.500" onClick={() => deleteEvent(event)}>
Delete Note
<OpenInAppMenuItem event={event} />
<CopyShareLinkMenuItem event={event} />
<CopyEmbedCodeMenuItem event={event} />
<MuteUserMenuItem event={event} />
<DeleteEventMenuItem event={event} />
{detailsClick && (
<MenuItem onClick={detailsClick} icon={<InfoCircle />}>
Details
</MenuItem>
)}
<MenuItem onClick={translationsModal.onOpen} icon={<Translate01 />}>
@ -134,21 +53,14 @@ export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Om
<MenuItem onClick={broadcast} icon={<BroadcastEventIcon />}>
Broadcast
</MenuItem>
<PinNoteItem event={event} />
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
<PinNoteMenuItem event={event} />
<MenuItem onClick={debugModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>
<MenuItem onClick={reactionsModal.onOpen} icon={<LikeIcon />}>
Zaps/Reactions
</MenuItem>
</CustomMenuIconButton>
{infoModal.isOpen && (
<NoteDebugModal event={event} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
)}
{reactionsModal.isOpen && (
<NoteReactionsModal noteId={event.id} isOpen={reactionsModal.isOpen} onClose={reactionsModal.onClose} />
{debugModal.isOpen && (
<NoteDebugModal event={event} isOpen={debugModal.isOpen} onClose={debugModal.onClose} size="6xl" />
)}
{translationsModal.isOpen && <NoteTranslationModal isOpen onClose={translationsModal.onClose} note={event} />}

View File

@ -1,112 +0,0 @@
import React, { useState } from "react";
import {
Modal,
ModalOverlay,
ModalContent,
ModalBody,
ModalCloseButton,
Button,
ModalProps,
Text,
Flex,
ButtonGroup,
Box,
} from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import UserAvatarLink from "../user-avatar-link";
import { UserLink } from "../user-link";
import { DislikeIcon, LightningIcon, LikeIcon } from "../icons";
import { ParsedZap } from "../../helpers/nostr/zaps";
import { readablizeSats } from "../../helpers/bolt11";
import useEventReactions from "../../hooks/use-event-reactions";
import useEventZaps from "../../hooks/use-event-zaps";
import Timestamp from "../timestamp";
function getReactionIcon(content: string) {
switch (content) {
case "+":
return <LikeIcon />;
case "-":
return <DislikeIcon />;
default:
return content;
}
}
const ReactionEvent = React.memo(({ event }: { event: NostrEvent }) => (
<Flex gap="2">
<Text>{getReactionIcon(event.content)}</Text>
<Flex overflow="hidden" gap="2">
<UserAvatarLink pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} />
</Flex>
<Text ml="auto" flexShrink={0}>
<Timestamp timestamp={event.created_at} />
</Text>
</Flex>
));
const ZapEvent = React.memo(({ zap }: { zap: ParsedZap }) => {
if (!zap.payment.amount) return null;
return (
<Box borderWidth="1px" borderRadius="lg" py="2" px={["2", "4"]}>
<Flex gap="2" justifyContent="space-between">
<Box>
<UserAvatarLink pubkey={zap.request.pubkey} size="xs" mr="2" />
<UserLink pubkey={zap.request.pubkey} />
</Box>
<Text fontWeight="bold">
{readablizeSats(zap.payment.amount / 1000)} <LightningIcon color="yellow.500" />
</Text>
</Flex>
<Text>{zap.request.content}</Text>
</Box>
);
});
function sortEvents(a: NostrEvent, b: NostrEvent) {
return b.created_at - a.created_at;
}
export default function NoteReactionsModal({
isOpen,
onClose,
noteId,
}: { noteId: string } & Omit<ModalProps, "children">) {
const zaps = useEventZaps(noteId, [], true) ?? [];
const reactions = useEventReactions(noteId, [], true) ?? [];
const [selected, setSelected] = useState("zaps");
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalBody p={["2", "4"]}>
<Flex direction="column" gap="2">
<ButtonGroup>
<Button size="sm" variant={selected === "zaps" ? "solid" : "outline"} onClick={() => setSelected("zaps")}>
Zaps ({zaps.length})
</Button>
<Button
size="sm"
variant={selected === "reactions" ? "solid" : "outline"}
onClick={() => setSelected("reactions")}
>
Reactions ({reactions.length})
</Button>
</ButtonGroup>
{selected === "reactions" &&
reactions.sort(sortEvents).map((event) => <ReactionEvent key={event.id} event={event} />)}
{selected === "zaps" &&
zaps
.sort((a, b) => b.request.created_at - a.request.created_at)
.map((zap) => <ZapEvent key={zap.request.id} zap={zap} />)}
</Flex>
</ModalBody>
</ModalContent>
</Modal>
);
}

View File

@ -1,18 +1,31 @@
import { MouseEventHandler, useCallback } from "react";
import { IconButton, IconButtonProps } from "@chakra-ui/react";
import { To } from "react-router-dom";
import { DrawerIcon } from "./icons";
import { useNavigateInDrawer } from "../providers/drawer-sub-view-provider";
export default function OpenInDrawerButton({ to, ...props }: Omit<IconButtonProps, "aria-label"> & { to: To }) {
export default function OpenInDrawerButton({
to,
onClick,
...props
}: Omit<IconButtonProps, "aria-label"> & { to: To }) {
const navigate = useNavigateInDrawer();
const handleClick = useCallback<MouseEventHandler<HTMLButtonElement>>(
(e) => {
navigate(to);
if (onClick) onClick(e);
},
[navigate, onClick],
);
return (
<IconButton
icon={<DrawerIcon />}
aria-label="Open in drawer"
title="Open in drawer"
onClick={() => navigate(to)}
onClick={handleClick}
{...props}
/>
);

View File

@ -34,7 +34,7 @@ export default function PeopleListSelection({
};
return (
<Menu>
<Menu isLazy>
<MenuButton as={Button} {...props}>
{listEvent ? getListName(listEvent) : selected === "global" ? "Global" : "Loading..."}
</MenuButton>

View File

@ -17,7 +17,7 @@ import { EventSplit } from "../../helpers/nostr/zaps";
import { AddIcon } from "../icons";
import { normalizeToHex } from "../../helpers/nip19";
import UserAvatar from "../user-avatar";
import { UserLink } from "../user-link";
import UserLink from "../user-link";
import NpubAutocomplete from "../npub-autocomplete";
function getRemainingPercent(split: EventSplit) {

View File

@ -1,83 +0,0 @@
import {
Box,
Button,
Divider,
Flex,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
ModalProps,
SimpleGrid,
SimpleGridProps,
useDisclosure,
} from "@chakra-ui/react";
import { useMemo } from "react";
import { NostrEvent } from "../types/nostr-event";
import { groupReactions } from "../helpers/nostr/reactions";
import UserAvatarLink from "./user-avatar-link";
import { UserLink } from "./user-link";
import ReactionIcon from "./event-reactions/reaction-icon";
export type ReactionDetailsModalProps = Omit<ModalProps, "children"> & {
reactions: NostrEvent[];
};
function ShowMoreGrid({
pubkeys,
cutoff,
...props
}: Omit<SimpleGridProps, "children"> & { pubkeys: string[]; cutoff: number }) {
const showMore = useDisclosure();
const limited = pubkeys.length > cutoff && !showMore.isOpen ? pubkeys.slice(0, cutoff) : pubkeys;
return (
<>
<SimpleGrid spacing="1" {...props}>
{limited.map((pubkey) => (
<Flex gap="2" key={pubkey} alignItems="center" overflow="hidden">
<UserAvatarLink pubkey={pubkey} size="xs" />
<UserLink pubkey={pubkey} isTruncated />
</Flex>
))}
</SimpleGrid>
{limited.length !== pubkeys.length && (
<Button variant="link" size="md" onClick={showMore.onOpen}>
Show {pubkeys.length - limited.length} more
</Button>
)}
</>
);
}
export default function ReactionDetailsModal({ reactions, onClose, ...props }: ReactionDetailsModalProps) {
const groups = useMemo(() => groupReactions(reactions), [reactions]);
return (
<Modal onClose={onClose} size="2xl" {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader px="4" pb="0">
Reactions
</ModalHeader>
<ModalCloseButton />
<ModalBody display="flex" gap="2" px="4" pt="0" flexDirection="column">
{groups.map((group) => (
<Flex key={group.emoji} direction="column" gap="2">
<Flex gap="2" alignItems="center">
<Box fontSize="lg" borderWidth={1} w="8" h="8" borderRadius="md" p="1">
<ReactionIcon emoji={group.emoji} url={group.url} />
</Box>
<Divider />
</Flex>
<ShowMoreGrid pubkeys={group.pubkeys} columns={{ base: 2, sm: 3, md: 4 }} cutoff={12} />
</Flex>
))}
</ModalBody>
</ModalContent>
</Modal>
);
}

View File

@ -28,7 +28,7 @@ function EmojiPack({ cord, onSelect }: { cord: string; onSelect: ReactionPickerP
icon={<Image src={emoji.url} height="1.2rem" />}
aria-label={emoji.name}
title={emoji.name}
variant="outline"
variant="ghost"
size="sm"
onClick={() => onSelect(emoji.name, emoji.url)}
/>
@ -46,11 +46,11 @@ export default function ReactionPicker({ onSelect }: ReactionPickerProps) {
return (
<Flex direction="column" gap="2">
<Flex wrap="wrap" gap="2">
<IconButton icon={<LikeIcon />} aria-label="Like" variant="outline" size="sm" onClick={() => onSelect("+")} />
<IconButton icon={<LikeIcon />} aria-label="Like" variant="ghost" size="sm" onClick={() => onSelect("+")} />
<IconButton
icon={<DislikeIcon />}
aria-label="Dislike"
variant="outline"
variant="ghost"
size="sm"
onClick={() => onSelect("-")}
/>
@ -58,7 +58,7 @@ export default function ReactionPicker({ onSelect }: ReactionPickerProps) {
<IconButton
icon={<span>{emoji}</span>}
aria-label="Shaka"
variant="outline"
variant="ghost"
size="sm"
onClick={() => onSelect(emoji)}
/>

View File

@ -3,7 +3,7 @@ import { Flex, Text } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatar from "../../user-avatar";
import { UserLink } from "../../user-link";
import UserLink from "../../user-link";
import RelayCard from "../../../views/relays/components/relay-card";
import { safeRelayUrl } from "../../../helpers/url";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";

View File

@ -8,7 +8,7 @@ import { Note } from "../../note";
import NoteMenu from "../../note/note-menu";
import UserAvatar from "../../user-avatar";
import { UserDnsIdentityIcon } from "../../user-dns-identity-icon";
import { UserLink } from "../../user-link";
import UserLink from "../../user-link";
import { TrustProvider } from "../../../providers/trust";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";

View File

@ -21,7 +21,7 @@ import { parseStreamEvent } from "../../../helpers/nostr/stream";
import useEventNaddr from "../../../hooks/use-event-naddr";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import UserAvatar from "../../user-avatar";
import { UserLink } from "../../user-link";
import UserLink from "../../user-link";
import StreamStatusBadge from "../../../views/streams/components/status-badge";
import { EventRelays } from "../../note/note-relays";
import { useAsync } from "react-use";

View File

@ -21,7 +21,7 @@ export default function TimelineActionAndStatus({ timeline }: { timeline: Timeli
}
return (
<Button onClick={() => timeline.loadMore()} flexShrink={0} size="lg" mx="auto" colorScheme="primary" my="4">
<Button onClick={() => timeline.loadNextBlock()} flexShrink={0} size="lg" mx="auto" colorScheme="primary" my="4">
Load More
</Button>
);

View File

@ -88,6 +88,7 @@ function EventRow({
export default function TimelineHealth({ timeline }: { timeline: TimelineLoader }) {
const events = useSubject(timeline.timeline);
const relays = Array.from(Object.keys(timeline.queryMap));
return (
<>
@ -103,7 +104,7 @@ export default function TimelineHealth({ timeline }: { timeline: TimelineLoader
</Th>
<Th p="2">Content</Th>
<Th />
{timeline.relays.map((relay) => (
{relays.map((relay) => (
<Th key={relay} title={relay} w="0.1rem" p="0">
<Tooltip label={relay}>
<Box p="2">
@ -116,7 +117,7 @@ export default function TimelineHealth({ timeline }: { timeline: TimelineLoader
</Thead>
<Tbody>
{events.map((event) => (
<EventRow key={event.id} event={event} relays={timeline.relays} />
<EventRow key={event.id} event={event} relays={relays} />
))}
</Tbody>
</Table>

View File

@ -4,7 +4,6 @@ import { ButtonGroup, ButtonGroupProps, IconButton } from "@chakra-ui/react";
import { ImageGridTimelineIcon, NoteFeedIcon, TimelineHealthIcon } from "../icons";
import { TimelineViewType } from "./index";
import { searchParamsToJson } from "../../helpers/url";
export default function TimelineViewTypeButtons(props: ButtonGroupProps) {
const [params, setParams] = useSearchParams();
@ -12,7 +11,14 @@ export default function TimelineViewTypeButtons(props: ButtonGroupProps) {
const onChange = useCallback(
(type: TimelineViewType) => {
setParams((p) => ({ ...searchParamsToJson(p), view: type }), { replace: true });
setParams(
(p) => {
const newParams = new URLSearchParams(p);
newParams.set("view", type);
return newParams;
},
{ replace: true },
);
},
[setParams],
);

View File

@ -5,9 +5,9 @@ import { useAsync } from "react-use";
import { getIdenticon } from "../helpers/identicon";
import { safeUrl } from "../helpers/parse";
import appSettings from "../services/settings/app-settings";
import useSubject from "../hooks/use-subject";
import { getUserDisplayName } from "../helpers/user-metadata";
import useAppSettings from "../hooks/use-app-settings";
import useCurrentAccount from "../hooks/use-current-account";
export const UserIdenticon = memo(({ pubkey }: { pubkey: string }) => {
const { value: identicon } = useAsync(() => getIdenticon(pubkey), [pubkey]);
@ -21,9 +21,11 @@ export type UserAvatarProps = Omit<AvatarProps, "src"> & {
noProxy?: boolean;
};
export const UserAvatar = forwardRef<HTMLDivElement, UserAvatarProps>(({ pubkey, noProxy, relay, ...props }, ref) => {
const { imageProxy, proxyUserMedia } = useSubject(appSettings);
const { imageProxy, proxyUserMedia, hideUsernames } = useAppSettings();
const account = useCurrentAccount();
const metadata = useUserMetadata(pubkey, relay ? [relay] : undefined);
const picture = useMemo(() => {
if (hideUsernames && pubkey !== account?.pubkey) return undefined;
if (metadata?.picture) {
const src = safeUrl(metadata?.picture);
if (!noProxy) {
@ -36,7 +38,7 @@ export const UserAvatar = forwardRef<HTMLDivElement, UserAvatarProps>(({ pubkey,
}
return src;
}
}, [metadata?.picture, imageProxy]);
}, [metadata?.picture, imageProxy, hideUsernames, account]);
return (
<Avatar

View File

@ -4,19 +4,24 @@ import { nip19 } from "nostr-tools";
import { getUserDisplayName } from "../helpers/user-metadata";
import { useUserMetadata } from "../hooks/use-user-metadata";
import useAppSettings from "../hooks/use-app-settings";
import useCurrentAccount from "../hooks/use-current-account";
export type UserLinkProps = LinkProps & {
pubkey: string;
showAt?: boolean;
tab?: string;
};
export const UserLink = ({ pubkey, showAt, ...props }: UserLinkProps) => {
export default function UserLink({ pubkey, showAt, tab, ...props }: UserLinkProps) {
const metadata = useUserMetadata(pubkey);
const account = useCurrentAccount();
const { hideUsernames } = useAppSettings();
return (
<Link as={RouterLink} to={`/u/${nip19.npubEncode(pubkey)}`} whiteSpace="nowrap" {...props}>
<Link as={RouterLink} to={`/u/${nip19.npubEncode(pubkey)}` + (tab ? "/" + tab : "")} whiteSpace="nowrap" {...props}>
{showAt && "@"}
{getUserDisplayName(metadata, pubkey)}
{hideUsernames && pubkey !== account?.pubkey ? "Anon" : getUserDisplayName(metadata, pubkey)}
</Link>
);
};
}

View File

@ -2,13 +2,15 @@ import { Text, TextProps } from "@chakra-ui/react";
import { getUserDisplayName } from "../helpers/user-metadata";
import { useUserMetadata } from "../hooks/use-user-metadata";
import useAppSettings from "../hooks/use-app-settings";
export default function UserName({ pubkey, ...props }: Omit<TextProps, "children"> & { pubkey: string }) {
const metadata = useUserMetadata(pubkey);
const { hideUsernames } = useAppSettings();
return (
<Text as="span" whiteSpace="nowrap" fontWeight="bold" {...props}>
{getUserDisplayName(metadata, pubkey)}
{hideUsernames ? "Anon" : getUserDisplayName(metadata, pubkey)}
</Text>
);
}

View File

@ -1,9 +1,11 @@
import { Flex, FlexProps } from "@chakra-ui/react";
import { ComponentWithAs, Flex, FlexProps } from "@chakra-ui/react";
export default function VerticalPageLayout({ children, ...props }: FlexProps) {
const VerticalPageLayout: ComponentWithAs<"div", FlexProps> = ({ children, ...props }: FlexProps) => {
return (
<Flex direction="column" pt="2" pb="12" gap="2" px="2" {...props}>
{children}
</Flex>
);
}
};
export default VerticalPageLayout;

View File

@ -1,10 +1,9 @@
import { bech32 } from "bech32";
import { getPublicKey, nip19 } from "nostr-tools";
import { getEventRelays } from "../services/event-relays";
import relayScoreboardService from "../services/relay-scoreboard";
import { NostrEvent, Tag, isATag, isDTag, isETag, isPTag } from "../types/nostr-event";
import { getEventUID, isReplaceable } from "./nostr/events";
import { isReplaceable } from "./nostr/events";
import { DecodeResult } from "nostr-tools/lib/types/nip19";
import relayHintService from "../services/event-relay-hint";
export function isHexKey(key?: string) {
if (key?.toLowerCase()?.match(/^[0-9a-f]{64}$/)) return true;
@ -47,7 +46,7 @@ export function safeDecode(str: string) {
} catch (e) {}
}
export function getPubkey(result?: nip19.DecodeResult) {
export function getPubkeyFromDecodeResult(result?: nip19.DecodeResult) {
if (!result) return;
switch (result.type) {
case "naddr":
@ -68,21 +67,23 @@ export function normalizeToHex(hex: string) {
}
export function getSharableEventAddress(event: NostrEvent) {
const relays = getEventRelays(getEventUID(event)).value;
const ranked = relayScoreboardService.getRankedRelays(relays);
const maxTwo = ranked.slice(0, 2);
const relays = relayHintService.getEventRelayHints(event, 2);
if (isReplaceable(event.kind)) {
const d = event.tags.find(isDTag)?.[1];
if (!d) return null;
return nip19.naddrEncode({ kind: event.kind, identifier: d, pubkey: event.pubkey, relays: maxTwo });
return nip19.naddrEncode({ kind: event.kind, identifier: d, pubkey: event.pubkey, relays });
} else {
if (maxTwo.length == 2) {
return nip19.neventEncode({ id: event.id, relays: maxTwo });
} else return nip19.neventEncode({ id: event.id, relays: maxTwo, author: event.pubkey });
return nip19.neventEncode({ id: event.id, kind: event.kind, relays, author: event.pubkey });
}
}
/** @deprecated use getSharableEventAddress unless required */
export function getNeventForEventId(eventId: string, maxRelays = 2) {
const relays = relayHintService.getEventPointerRelayHints(eventId).slice(0, maxRelays);
return nip19.neventEncode({ id: eventId, relays });
}
export function encodePointer(pointer: DecodeResult) {
switch (pointer.type) {
case "naddr":

Some files were not shown because too many files have changed in this diff Show More