forked from Kieran/snort
Compare commits
163 Commits
1dc3059d0d
...
0043b7e8bd
Author | SHA1 | Date | |
---|---|---|---|
0043b7e8bd | |||
267c09a946 | |||
2fa75e8e3d | |||
2a2144b59b | |||
7ac5bb6d41 | |||
38093fdf3b | |||
98e1be883b | |||
e700c97c71 | |||
4d9226b3b6 | |||
0cc0a47501 | |||
d187fbc6e5 | |||
6582b4c7d5 | |||
acbd3a9004 | |||
1199418d0e | |||
395848fd8c | |||
8571ea0aa7 | |||
d840f2b952 | |||
43591c4ce6 | |||
bc7ec4d77f | |||
0d10122394 | |||
8bbbd11f7a | |||
7c8136b503 | |||
6ad99e7e95 | |||
f7d8d1de16 | |||
2c14d64a95 | |||
81c9285d46 | |||
6928ad04d7 | |||
19eeb890ac | |||
3d98532e40 | |||
4bbad0563b | |||
13fc3bb843 | |||
7b72f9f775 | |||
c2e1215667 | |||
e7e7fdc14d | |||
3e52bb755e | |||
789476c677 | |||
f47994b3ee | |||
9d2b867552 | |||
d82c7957be | |||
3af04a79cc | |||
9fc0b676f5 | |||
f70d752fae | |||
6b88df96ab | |||
1f03a5ee5a | |||
9c94e84b9d | |||
8c1bbe58f6 | |||
118ada989e | |||
1b9dc3f480 | |||
82bb71136e | |||
74591a6adb | |||
87d3bbe1a1 | |||
1639937d8c | |||
e10a11b707 | |||
9e6971423e | |||
782a2217b4 | |||
1309937869 | |||
0c2ed147b0 | |||
9ed5757875 | |||
06b7dcad11 | |||
96368d4a2b | |||
bf822aae5b | |||
80690df15a | |||
df66a861f7 | |||
f2d46e340a | |||
47f70b0157 | |||
c1c99f1b9e | |||
7eb8edbf74 | |||
34e892937e | |||
30df180e33 | |||
083f512bdf | |||
0f4352aa1b | |||
7040253f32 | |||
a937c75c64 | |||
7523b41610 | |||
457cba32a7 | |||
be3f46c7f1 | |||
509c1664c1 | |||
a34bf6591c | |||
eef4e526c1 | |||
9bd5062922 | |||
3021001c02 | |||
33963f35ed | |||
9702529437 | |||
63b3ad2d57 | |||
e2e3c9e638 | |||
8aeda3f7a1 | |||
722a3a1a0e | |||
ffda31895a | |||
ad449bc295 | |||
73026ff152 | |||
04756d2741 | |||
bedcd7aba6 | |||
933e891b37 | |||
1389bfe1ed | |||
39549dbe96 | |||
57c0998eaa | |||
a4570084ef | |||
20a0a3aea4 | |||
2bf62f3a03 | |||
188f96c86f | |||
a7ab7b024f | |||
1ba9218a2f | |||
86ed4e042e | |||
4212fe8dc9 | |||
817b791e13 | |||
8dc12b31df | |||
a086dc101f | |||
0d1e73d40f | |||
7a27bb022e | |||
91ae31a267 | |||
444b7b5379 | |||
27f6597f88 | |||
9e65024652 | |||
6e5fba4f15 | |||
b38527ca1f | |||
14692aceb9 | |||
e107d4cb7e | |||
26a88537a5 | |||
e3642c5449 | |||
ac94f7c1e4 | |||
85846422dd | |||
1b70815109 | |||
8ab1b9a643 | |||
7cb51aa8cf | |||
4e2189a893 | |||
87ad31df30 | |||
f124082f6f | |||
e408389cdb | |||
1fe6a2a50d | |||
b199d297ce | |||
3fa13de33e | |||
20e40c1f65 | |||
4ed6ec7c3d | |||
8d6cdb3868 | |||
9431b294c6 | |||
33888988af | |||
15806c56d0 | |||
f0af0c81f0 | |||
d9309b5fac | |||
b41e8a919a | |||
6951383045 | |||
ad57b440a9 | |||
e0a6df7f3a | |||
47d187516f | |||
dcb3389aa1 | |||
fce7cc70a3 | |||
cb95032e7c | |||
b63d46e96d | |||
07e42405a0 | |||
0f6fe23f18 | |||
755ba17dab | |||
d00f8b0d85 | |||
a1e9df8254 | |||
d2cec4909c | |||
91709c88be | |||
5add66711a | |||
d167579348 | |||
735d5fd5a5 | |||
4d6331ce81 | |||
bee8498283 | |||
74bc8bafda | |||
4f7152d3e0 | |||
31d9c52080 |
@ -1,3 +1,51 @@
|
||||
# v0.1.24
|
||||
|
||||
`+11,573,-3,010`
|
||||
|
||||
## Added
|
||||
|
||||
- 3 Column layout - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Fuzzy cache search - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Followed by on profile pages - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Show more on long notes - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Better error message page - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Media grid feed - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Mobile fixed footer - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Follow button on profile search results - nostr:npub17q5n2z8naw0xl6vu9lvt560lg33pdpe29k0k09umlfxm3vc4tqrq466f2y
|
||||
- Invite codes (WIP Community Program) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- `imeta` tag insertion for images - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Wallet settings page improvements - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Nostr Wallet Connect upgrade (balance + history) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Schnorr sig check in WASM binary - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Autoplay videos in feed (muted) - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Followed by friends feed (a feed of your 2nd degree follows posts) - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- imgproxy image integrity check (sha256 from `imeta` passed to imgproxy) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
|
||||
## Changed
|
||||
|
||||
- Removed Twitter embed - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Removed attachment button on DM's - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Note broadcaster dialog changed to toast notification - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Removed npub link from profile (use QR button) - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Render image size from `imeta` tags - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Style fixes - nostr:npub1cz2ve34nk0ukn0ph4yq2qx3ud8rfy5e0ak4epx42dn8gha0sdgpsgra9kv
|
||||
- Zap pool slider tweak - nostr:npub1ltx67888tz7lqnxlrg06x234vjnq349tcfyp52r0lstclp548mcqnuz40t
|
||||
- New Malay translations - nostr:npub1cjtt3nywuflj65ftld4v7zzpg0qh3ergycjcym0956vf9eftv7esekxpmn
|
||||
- Updated Persian translations - nostr:npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk
|
||||
- Updated Finnish translations - nostr:npub1ust7u0v3qffejwhqee45r49zgcyewrcn99vdwkednd356c9resyqtnn3mj
|
||||
- Updated French translations - nostr:npub1x8dzy9xegwmdk2vy30l8u08caspcqq2yzncxehdsa6kvnte9pr3qnt8pg4 & nostr:npub13w02l37gkjwv90lnklfet5653jj0p5ueu976v3dpda5afvxgw3uslcqdnv
|
||||
- Updated German translations - nostr:npub19a6x8frkkn2660fw0flz74a7qg8c2jxk5v9p2rsh7tv5e6ftsq3sav63vp
|
||||
- Updated Hungarian translations - nostr:npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp
|
||||
- Updated Swedish translations - nostr:npub19jk45jz45gczwfm22y9z69xhaex3nwg47dz84zw096xl6z62amkqj99rv7
|
||||
- Updated Japanese translations - nostr:npub1wh69w45awqnlsxw7jt5tkymets87h6t4phplkx6ug2ht2qkssswswntjk0
|
||||
|
||||
## Fixed
|
||||
|
||||
- Longform note overlfow-x - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Trim zap content - nostr:npub1u8lnhlw5usp3t9vmpz60ejpyt649z33hu82wc2hpv6m5xdqmuxhs46turz
|
||||
|
||||
---
|
||||
|
||||
# v0.1.23
|
||||
|
||||
## Added
|
||||
|
@ -10,16 +10,25 @@
|
||||
"publicDir": "public/snort",
|
||||
"httpCache": "",
|
||||
"animalNamePlaceholders": false,
|
||||
"defaultZapPoolFee": 0.5,
|
||||
"bypassImgProxyError": false,
|
||||
"defaultZapPoolFee": 1,
|
||||
"features": {
|
||||
"analytics": true,
|
||||
"subscriptions": true,
|
||||
"deck": true,
|
||||
"zapPool": true
|
||||
"zapPool": true,
|
||||
"notificationGraph": true,
|
||||
"communityLeaders": true
|
||||
},
|
||||
"signUp": {
|
||||
"moderation": true
|
||||
"moderation": true,
|
||||
"defaultFollows": ["npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws"]
|
||||
},
|
||||
"media": {
|
||||
"bypassImgProxyError": false,
|
||||
"preferLargeMedia": true
|
||||
},
|
||||
"communityLeaders": {
|
||||
"list": "naddr1qq4xc6tnw3ez6vp58y6rywpjxckngdtyxukngwr9vckkze33vcknzcnrxcenje35xqmn2cczyp3lucccm3v9s087z6qslpkap8schltk427zfgqgrn3g2menq5zw6qcyqqq82vqprpmhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv7rajfl"
|
||||
},
|
||||
"noteCreatorToast": true,
|
||||
"hideFromNavbar": ["/graph"],
|
||||
@ -30,5 +39,6 @@
|
||||
"wss://relay.snort.social/": { "read": true, "write": true },
|
||||
"wss://nostr.wine/": { "read": true, "write": false },
|
||||
"wss://eden.nostr.land/": { "read": true, "write": false }
|
||||
}
|
||||
},
|
||||
"useIndexedDBEvents": false
|
||||
}
|
||||
|
@ -8,19 +8,26 @@
|
||||
"appleTouchIconUrl": "/img/apple-touch-icon.png",
|
||||
"navLogo": "/img/icon128.png",
|
||||
"publicDir": "public/iris",
|
||||
"httpCache": "https://api.iris.to",
|
||||
"httpCache": "",
|
||||
"animalNamePlaceholders": true,
|
||||
"defaultZapPoolFee": 0.5,
|
||||
"bypassImgProxyError": true,
|
||||
"features": {
|
||||
"analytics": true,
|
||||
"subscriptions": false,
|
||||
"deck": true,
|
||||
"zapPool": true
|
||||
"zapPool": true,
|
||||
"notificationGraph": false,
|
||||
"communityLeaders": false
|
||||
},
|
||||
"signUp": {
|
||||
"moderation": false
|
||||
"moderation": false,
|
||||
"defaultFollows": ["npub1wnwwcv0a8wx0m9stck34ajlwhzuua68ts8mw3kjvspn42dcfyjxs4n95l8"]
|
||||
},
|
||||
"media": {
|
||||
"bypassImgProxyError": true,
|
||||
"preferLargeMedia": true
|
||||
},
|
||||
"noteCreatorToast": false,
|
||||
"hideFromNavbar": [],
|
||||
"eventLinkPrefix": "note",
|
||||
"profileLinkPrefix": "npub",
|
||||
@ -30,5 +37,6 @@
|
||||
"wss://eden.nostr.land/": { "read": true, "write": false },
|
||||
"wss://relay.nostr.band/": { "read": true, "write": true },
|
||||
"wss://relay.damus.io/": { "read": true, "write": true }
|
||||
}
|
||||
},
|
||||
"useIndexedDBEvents": true
|
||||
}
|
||||
|
17
packages/app/custom.d.ts
vendored
17
packages/app/custom.d.ts
vendored
@ -52,15 +52,27 @@ declare const CONFIG: {
|
||||
httpCache: string;
|
||||
animalNamePlaceholders: boolean;
|
||||
defaultZapPoolFee: number;
|
||||
bypassImgProxyError: boolean;
|
||||
features: {
|
||||
analytics: boolean;
|
||||
subscriptions: boolean;
|
||||
deck: boolean;
|
||||
zapPool: boolean;
|
||||
notificationGraph: boolean;
|
||||
communityLeaders: boolean;
|
||||
};
|
||||
defaultPreferences: {
|
||||
checkSigs: boolean;
|
||||
};
|
||||
signUp: {
|
||||
moderation: boolean;
|
||||
defaultFollows: Array<string>;
|
||||
};
|
||||
media: {
|
||||
bypassImgProxyError: boolean;
|
||||
preferLargeMedia: boolean;
|
||||
};
|
||||
communityLeaders?: {
|
||||
list: string;
|
||||
};
|
||||
// Filter urls from nav sidebar
|
||||
hideFromNavbar: Array<string>;
|
||||
@ -68,10 +80,11 @@ declare const CONFIG: {
|
||||
deckSubKind?: number;
|
||||
showDeck?: boolean;
|
||||
// Create toast notifications when publishing notes
|
||||
noteCreatorToast?: boolean;
|
||||
noteCreatorToast: boolean;
|
||||
eventLinkPrefix: NostrPrefix;
|
||||
profileLinkPrefix: NostrPrefix;
|
||||
defaultRelays: Record<string, RelaySettings>;
|
||||
useIndexedDBEvents: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@snort/app",
|
||||
"version": "0.1.23",
|
||||
"version": "0.1.24",
|
||||
"dependencies": {
|
||||
"@cashu/cashu-ts": "^0.6.1",
|
||||
"@lightninglabs/lnc-web": "^0.2.3-alpha",
|
||||
@ -16,8 +16,9 @@
|
||||
"@snort/system-web": "workspace:*",
|
||||
"@szhsin/react-menu": "^3.3.1",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@void-cat/api": "^1.0.10",
|
||||
"@void-cat/api": "^1.0.12",
|
||||
"classnames": "^2.3.2",
|
||||
"comlink": "^4.4.1",
|
||||
"debug": "^4.3.4",
|
||||
"dexie": "^3.2.4",
|
||||
"emojilib": "^3.0.10",
|
||||
@ -43,6 +44,7 @@
|
||||
"use-sync-external-store": "^1.2.0",
|
||||
"uuid": "^9.0.0",
|
||||
"workbox-core": "^6.4.2",
|
||||
"workbox-expiration": "^7.0.0",
|
||||
"workbox-precaching": "^7.0.0",
|
||||
"workbox-routing": "^6.4.2",
|
||||
"workbox-strategies": "^6.4.2"
|
||||
@ -91,7 +93,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
||||
"@typescript-eslint/parser": "^6.1.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"@webbtc/webln-types": "^1.0.10",
|
||||
"@webbtc/webln-types": "^2.1.0",
|
||||
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"config": "^3.3.9",
|
||||
|
@ -26,7 +26,7 @@ export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
|
||||
loaded: unixNowMs(),
|
||||
});
|
||||
if (update !== "no_change") {
|
||||
socialGraphInstance.handleFollowEvent(e);
|
||||
socialGraphInstance.handleEvent(e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@ -42,6 +42,6 @@ export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
|
||||
|
||||
override async preload() {
|
||||
await super.preload();
|
||||
this.snapshot().forEach(e => socialGraphInstance.handleFollowEvent(e));
|
||||
this.snapshot().forEach(e => socialGraphInstance.handleEvent(e));
|
||||
}
|
||||
}
|
||||
|
@ -27,8 +27,10 @@ export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
|
||||
}
|
||||
|
||||
buildSub(session: LoginSession, rb: RequestBuilder): void {
|
||||
const authors = session.follows.item;
|
||||
authors.push(session.publicKey);
|
||||
const authors = [...session.follows.item];
|
||||
if (session.publicKey) {
|
||||
authors.push(session.publicKey);
|
||||
}
|
||||
const since = this.newest();
|
||||
rb.withFilter()
|
||||
.kinds(this.#kinds)
|
||||
@ -69,8 +71,10 @@ export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
|
||||
async loadMore(system: SystemInterface, session: LoginSession, before: number) {
|
||||
if (this.#oldest && before <= this.#oldest) {
|
||||
const rb = new RequestBuilder(`${this.name}-loadmore`);
|
||||
const authors = session.follows.item;
|
||||
authors.push(session.publicKey);
|
||||
const authors = [...session.follows.item];
|
||||
if (session.publicKey) {
|
||||
authors.push(session.publicKey);
|
||||
}
|
||||
rb.withFilter()
|
||||
.kinds(this.#kinds)
|
||||
.authors(authors)
|
||||
|
222
packages/app/src/Cache/IndexedDB.ts
Normal file
222
packages/app/src/Cache/IndexedDB.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import Dexie, { Table } from "dexie";
|
||||
import { TaggedNostrEvent, ReqFilter as Filter } from "@snort/system";
|
||||
import * as Comlink from "comlink";
|
||||
import LRUSet from "@/Cache/LRUSet";
|
||||
|
||||
type Tag = {
|
||||
id: string;
|
||||
eventId: string;
|
||||
type: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SaveQueueEntry = { event: TaggedNostrEvent; tags: Tag[] };
|
||||
|
||||
class IndexedDB extends Dexie {
|
||||
events!: Table<TaggedNostrEvent>;
|
||||
tags!: Table<Tag>;
|
||||
private saveQueue: SaveQueueEntry[] = [];
|
||||
private seenEvents = new LRUSet<string>(1000);
|
||||
private subscribedEventIds = new Set<string>();
|
||||
private subscribedAuthors = new Set<string>();
|
||||
private subscribedTags = new Set<string>();
|
||||
private subscribedAuthorsAndKinds = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
super("EventDB");
|
||||
|
||||
this.version(5).stores({
|
||||
events: "id, pubkey, kind, created_at, [pubkey+kind]",
|
||||
tags: "id, eventId, [type+value]",
|
||||
});
|
||||
|
||||
this.startInterval();
|
||||
}
|
||||
|
||||
private startInterval() {
|
||||
const processQueue = async () => {
|
||||
if (this.saveQueue.length > 0) {
|
||||
try {
|
||||
const eventsToSave: TaggedNostrEvent[] = [];
|
||||
const tagsToSave: Tag[] = [];
|
||||
for (const item of this.saveQueue) {
|
||||
eventsToSave.push(item.event);
|
||||
tagsToSave.push(...item.tags);
|
||||
}
|
||||
await this.events.bulkPut(eventsToSave);
|
||||
await this.tags.bulkPut(tagsToSave);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.saveQueue = [];
|
||||
}
|
||||
}
|
||||
setTimeout(() => processQueue(), 3000);
|
||||
};
|
||||
|
||||
setTimeout(() => processQueue(), 3000);
|
||||
}
|
||||
|
||||
handleEvent(event: TaggedNostrEvent) {
|
||||
if (this.seenEvents.has(event.id)) {
|
||||
return;
|
||||
}
|
||||
this.seenEvents.add(event.id);
|
||||
|
||||
// maybe we don't want event.kind 3 tags
|
||||
const tags =
|
||||
event.kind === 3
|
||||
? []
|
||||
: event.tags
|
||||
?.filter(tag => {
|
||||
if (tag[0] === "d") {
|
||||
return true;
|
||||
}
|
||||
if (tag[0] === "e") {
|
||||
return true;
|
||||
}
|
||||
// we're only interested in p tags where we are mentioned
|
||||
/*
|
||||
if (tag[0] === "p") {
|
||||
Key.isMine(tag[1])) { // TODO
|
||||
return true;
|
||||
}*/
|
||||
return false;
|
||||
})
|
||||
.map(tag => ({
|
||||
id: event.id.slice(0, 16) + "-" + tag[0].slice(0, 16) + "-" + tag[1].slice(0, 16),
|
||||
eventId: event.id,
|
||||
type: tag[0],
|
||||
value: tag[1],
|
||||
})) || [];
|
||||
|
||||
this.saveQueue.push({ event, tags });
|
||||
}
|
||||
|
||||
_throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function (...args) {
|
||||
if (!inThrottle) {
|
||||
inThrottle = true;
|
||||
setTimeout(() => {
|
||||
inThrottle = false;
|
||||
func.apply(this, args);
|
||||
}, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
subscribeToAuthors = this._throttle(async function (callback: (event: TaggedNostrEvent) => void, limit?: number) {
|
||||
const authors = [...this.subscribedAuthors];
|
||||
this.subscribedAuthors.clear();
|
||||
await this.events
|
||||
.where("pubkey")
|
||||
.anyOf(authors)
|
||||
.limit(limit || 1000)
|
||||
.each(callback);
|
||||
}, 200);
|
||||
|
||||
subscribeToEventIds = this._throttle(async function (callback: (event: TaggedNostrEvent) => void) {
|
||||
const ids = [...this.subscribedEventIds];
|
||||
this.subscribedEventIds.clear();
|
||||
await this.events.where("id").anyOf(ids).each(callback);
|
||||
}, 200);
|
||||
|
||||
subscribeToTags = this._throttle(async function (callback: (event: TaggedNostrEvent) => void) {
|
||||
const tagPairs = [...this.subscribedTags].map(tag => tag.split("|"));
|
||||
this.subscribedTags.clear();
|
||||
await this.tags
|
||||
.where("[type+value]")
|
||||
.anyOf(tagPairs)
|
||||
.each(tag => this.subscribedEventIds.add(tag.eventId));
|
||||
|
||||
await this.subscribeToEventIds(callback);
|
||||
}, 200);
|
||||
|
||||
subscribeToAuthorsAndKinds = this._throttle(async function (callback: (event: TaggedNostrEvent) => void) {
|
||||
const authorsAndKinds = [...this.subscribedAuthorsAndKinds];
|
||||
this.subscribedAuthorsAndKinds.clear();
|
||||
// parse pair[1] as int
|
||||
const pairs = authorsAndKinds.map(pair => {
|
||||
const [author, kind] = pair.split("|");
|
||||
return [author, parseInt(kind)];
|
||||
});
|
||||
await this.events.where("[pubkey+kind]").anyOf(pairs).each(callback);
|
||||
}, 200);
|
||||
|
||||
async find(filter: Filter, callback: (event: TaggedNostrEvent) => void): Promise<void> {
|
||||
if (!filter) return;
|
||||
|
||||
// make sure only 1 argument is passed
|
||||
const cb = e => {
|
||||
this.seenEvents.add(e.id);
|
||||
callback(e);
|
||||
};
|
||||
|
||||
if (filter["#p"] && Array.isArray(filter["#p"])) {
|
||||
for (const eventId of filter["#p"]) {
|
||||
this.subscribedTags.add("p|" + eventId);
|
||||
}
|
||||
|
||||
await this.subscribeToTags(cb);
|
||||
return;
|
||||
}
|
||||
|
||||
if (filter["#e"] && Array.isArray(filter["#e"])) {
|
||||
for (const eventId of filter["#e"]) {
|
||||
this.subscribedTags.add("e|" + eventId);
|
||||
}
|
||||
|
||||
await this.subscribeToTags(cb);
|
||||
return;
|
||||
}
|
||||
|
||||
if (filter["#d"] && Array.isArray(filter["#d"])) {
|
||||
for (const eventId of filter["#d"]) {
|
||||
this.subscribedTags.add("d|" + eventId);
|
||||
}
|
||||
|
||||
await this.subscribeToTags(cb);
|
||||
return;
|
||||
}
|
||||
|
||||
if (filter.ids?.length) {
|
||||
filter.ids.forEach(id => this.subscribedEventIds.add(id));
|
||||
await this.subscribeToEventIds(cb);
|
||||
return;
|
||||
}
|
||||
|
||||
if (filter.authors?.length && filter.kinds?.length) {
|
||||
const permutations = filter.authors.flatMap(author => filter.kinds!.map(kind => author + "|" + kind));
|
||||
permutations.forEach(permutation => this.subscribedAuthorsAndKinds.add(permutation));
|
||||
await this.subscribeToAuthorsAndKinds(cb);
|
||||
return;
|
||||
}
|
||||
|
||||
if (filter.authors?.length) {
|
||||
filter.authors.forEach(author => this.subscribedAuthors.add(author));
|
||||
await this.subscribeToAuthors(cb);
|
||||
return;
|
||||
}
|
||||
|
||||
let query = this.events;
|
||||
if (filter.kinds) {
|
||||
query = query.where("kind").anyOf(filter.kinds);
|
||||
}
|
||||
if (filter.search) {
|
||||
const regexp = new RegExp(filter.search, "i");
|
||||
query = query.filter((event: Event) => event.content?.match(regexp));
|
||||
}
|
||||
if (filter.limit) {
|
||||
query = query.limit(filter.limit);
|
||||
}
|
||||
// TODO test that the sort is actually working
|
||||
await query.each(e => {
|
||||
cb(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const db = new IndexedDB();
|
||||
|
||||
Comlink.expose(db);
|
23
packages/app/src/Cache/LRUSet.ts
Normal file
23
packages/app/src/Cache/LRUSet.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export default class LRUSet<T> {
|
||||
private set = new Set<T>();
|
||||
private limit: number;
|
||||
|
||||
constructor(limit: number) {
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
add(item: T) {
|
||||
if (this.set.size >= this.limit) {
|
||||
this.set.delete(this.set.values().next().value);
|
||||
}
|
||||
this.set.add(item);
|
||||
}
|
||||
|
||||
has(item: T) {
|
||||
return this.set.has(item);
|
||||
}
|
||||
|
||||
values() {
|
||||
return this.set.values();
|
||||
}
|
||||
}
|
@ -28,11 +28,6 @@ export const KieranPubKey = "npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7v
|
||||
*/
|
||||
export const SnortPubKey = "npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
|
||||
|
||||
/**
|
||||
* Default bootstrap relays
|
||||
*/
|
||||
export const DefaultRelays = new Map(Object.entries(CONFIG.defaultRelays));
|
||||
|
||||
/**
|
||||
* Default search relays
|
||||
*/
|
||||
@ -155,3 +150,13 @@ export const WavlakeRegex =
|
||||
* Regex to match any base64 string
|
||||
*/
|
||||
export const CashuRegex = /(cashuA[A-Za-z0-9_-]{0,10000}={0,3})/i;
|
||||
|
||||
/*
|
||||
* Max username length - profile/settings
|
||||
*/
|
||||
export const MaxUsernameLength = 100;
|
||||
|
||||
/*
|
||||
* Max about length - profile/settings
|
||||
*/
|
||||
export const MaxAboutLength = 1000;
|
||||
|
15
packages/app/src/Element/Button/CloseButton.tsx
Normal file
15
packages/app/src/Element/Button/CloseButton.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import Icon from "@/Icons/Icon";
|
||||
import classNames from "classnames";
|
||||
|
||||
export default function CloseButton({ onClick, className }: { onClick?: () => void; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
"self-center circle flex flex-shrink-0 flex-grow-0 items-center justify-center hover:opacity-80 bg-dark p-2 cursor-pointer",
|
||||
className,
|
||||
)}>
|
||||
<Icon name="close" size={12} />
|
||||
</div>
|
||||
);
|
||||
}
|
54
packages/app/src/Element/CommunityLeaders/Award.tsx
Normal file
54
packages/app/src/Element/CommunityLeaders/Award.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
export default function AwardIcon({ size }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 62 62" fill="none" className="award">
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_2660_40043"
|
||||
x1="31"
|
||||
y1="3.57143"
|
||||
x2="31"
|
||||
y2="58.4286"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5B2CB3" />
|
||||
<stop offset="1" stop-color="#811EFF" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_2660_40043"
|
||||
x1="15.5594"
|
||||
y1="24.305"
|
||||
x2="46.433"
|
||||
y2="24.305"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#AC88FF" />
|
||||
<stop offset="1" stop-color="#7234FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="award-02">
|
||||
<rect x="1.85713" y="1.85714" width="58.2857" height="58.2857" rx="29.1429" fill="#AC88FF" fill-opacity="0.2" />
|
||||
<rect
|
||||
x="1.85713"
|
||||
y="1.85714"
|
||||
width="58.2857"
|
||||
height="58.2857"
|
||||
rx="29.1429"
|
||||
stroke="url(#paint0_linear_2660_40043)"
|
||||
strokeWidth="3.42857"
|
||||
/>
|
||||
<path
|
||||
id="Solid"
|
||||
d="M23.2006 52.4983L22.5639 50.9066L23.2006 52.4983L30.9963 49.38L38.7919 52.4983C39.8813 52.934 41.116 52.801 42.0876 52.1432C43.0592 51.4854 43.6412 50.3885 43.6412 49.2151V38.1015C46.467 35.038 48.1957 30.9408 48.1957 26.4427C48.1957 16.9437 40.4952 9.24329 30.9963 9.24329C21.4973 9.24329 13.7968 16.9437 13.7968 26.4427C13.7968 30.9408 15.5255 35.038 18.3513 38.1015V49.2151C18.3513 50.3885 18.9333 51.4854 19.9049 52.1432C20.8765 52.801 22.1112 52.934 23.2006 52.4983ZM27.2967 43.2429L25.4234 43.9922V42.7187C26.0332 42.9275 26.6584 43.1029 27.2967 43.2429ZM34.6958 43.2429C35.3341 43.1029 35.9593 42.9275 36.5691 42.7187V43.9922L34.6958 43.2429Z"
|
||||
fill="url(#paint1_linear_2660_40043)"
|
||||
stroke="#251250"
|
||||
strokeWidth="3.42857"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
id="Ellipse 1595"
|
||||
d="M24.2557 14.6002C17.7766 18.3409 15.5567 26.6257 19.2974 33.1049L42.7604 19.5585C39.0196 13.0794 30.7348 10.8595 24.2557 14.6002Z"
|
||||
fill="white"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
48
packages/app/src/Element/CommunityLeaders/LeaderBadge.tsx
Normal file
48
packages/app/src/Element/CommunityLeaders/LeaderBadge.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import AwardIcon from "./Award";
|
||||
import Modal from "../Modal";
|
||||
import { Link } from "react-router-dom";
|
||||
import CloseButton from "../Button/CloseButton";
|
||||
|
||||
export function LeaderBadge() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex gap-1 p-1 pr-2 items-center border border-[#5B2CB3] rounded-full"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowModal(true);
|
||||
}}>
|
||||
<AwardIcon size={16} />
|
||||
<div className="text-xs font-medium text-[#AC88FF]">
|
||||
<FormattedMessage defaultMessage="Community Leader" id="7YkSA2" />
|
||||
</div>
|
||||
</div>
|
||||
{showModal && (
|
||||
<Modal onClose={() => setShowModal(false)} id="leaders">
|
||||
<div className="flex flex-col gap-4 items-center relative">
|
||||
<CloseButton className="absolute right-2 top-2" onClick={() => setShowModal(false)} />
|
||||
<AwardIcon size={80} />
|
||||
<div className="text-3xl font-semibold">
|
||||
<FormattedMessage defaultMessage="Community Leader" id="7YkSA2" />
|
||||
</div>
|
||||
<p className="text-secondary">
|
||||
<FormattedMessage
|
||||
defaultMessage="Community leaders are individuals who grow the nostr ecosystem by being active in their local communities and helping onboard new users. Anyone can become a community leader, but few hold the current honorary title."
|
||||
id="f1OxTe"
|
||||
/>
|
||||
</p>
|
||||
<Link to="/settings/invite">
|
||||
<button className="primary">
|
||||
<FormattedMessage defaultMessage="Become a leader" id="M6C/px" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -46,7 +46,7 @@ export default function HyperText({ link, depth, showLinkPreview, children }: Hy
|
||||
if (youtubeId) {
|
||||
return (
|
||||
<iframe
|
||||
className="-mx-4 md:mx-0 w-max"
|
||||
className="-mx-4 md:mx-0 w-max my-2"
|
||||
src={`https://www.youtube.com/embed/${youtubeId}`}
|
||||
title="YouTube video player"
|
||||
key={youtubeId}
|
||||
|
@ -1,45 +1,111 @@
|
||||
import { ProxyImg } from "@/Element/ProxyImg";
|
||||
import useImgProxy from "@/Hooks/useImgProxy";
|
||||
import React from "react";
|
||||
import { IMeta } from "@snort/system";
|
||||
import React, { CSSProperties, useEffect, useMemo, useRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
interface MediaElementProps {
|
||||
mime: string;
|
||||
url: string;
|
||||
magnet?: string;
|
||||
sha256?: string;
|
||||
blurHash?: string;
|
||||
meta?: IMeta;
|
||||
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
|
||||
}
|
||||
|
||||
export function MediaElement(props: MediaElementProps) {
|
||||
interface AudioElementProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface VideoElementProps {
|
||||
url: string;
|
||||
meta?: IMeta;
|
||||
}
|
||||
|
||||
interface ImageElementProps {
|
||||
url: string;
|
||||
meta?: IMeta;
|
||||
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
|
||||
}
|
||||
|
||||
const AudioElement = ({ url }: AudioElementProps) => {
|
||||
return <audio key={url} src={url} controls />;
|
||||
};
|
||||
|
||||
const ImageElement = ({ url, meta, onMediaClick }: ImageElementProps) => {
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const style = useMemo(() => {
|
||||
const style = {} as CSSProperties;
|
||||
if (meta?.height && meta.width && imageRef.current) {
|
||||
const scale = imageRef.current.offsetWidth / meta.width;
|
||||
style.height = `${Math.min(document.body.clientHeight * 0.8, meta.height * scale)}px`;
|
||||
}
|
||||
return style;
|
||||
}, [imageRef.current, meta]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames("flex items-center -mx-4 md:mx-0 my-2", {
|
||||
"md:h-[510px]": !meta && !CONFIG.media.preferLargeMedia,
|
||||
})}>
|
||||
<ProxyImg
|
||||
key={url}
|
||||
src={url}
|
||||
sha256={meta?.sha256}
|
||||
onClick={onMediaClick}
|
||||
className={classNames("max-h-[80vh] w-full h-full object-contain object-center", {
|
||||
"md:max-h-[510px]": !meta && !CONFIG.media.preferLargeMedia,
|
||||
})}
|
||||
style={style}
|
||||
ref={imageRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoElement = ({ url }: VideoElementProps) => {
|
||||
const { proxy } = useImgProxy();
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const { ref: videoContainerRef, inView } = useInView({ threshold: 0.33 });
|
||||
const isMobile = window.innerWidth < 768;
|
||||
|
||||
const autoplay = window.innerWidth >= 768;
|
||||
useEffect(() => {
|
||||
if (isMobile || !videoRef.current) {
|
||||
return;
|
||||
}
|
||||
if (inView) {
|
||||
videoRef.current.play();
|
||||
} else {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
}, [inView]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={videoContainerRef}
|
||||
className={classNames("flex justify-center items-center -mx-4 md:mx-0 my-2", {
|
||||
"md:h-[510px]": !CONFIG.media.preferLargeMedia,
|
||||
})}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
loop={true}
|
||||
muted={!isMobile}
|
||||
src={url}
|
||||
controls
|
||||
poster={proxy(url)}
|
||||
className={classNames("max-h-[80vh]", { "md:max-h-[510px]": !CONFIG.media.preferLargeMedia })}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function MediaElement(props: MediaElementProps) {
|
||||
if (props.mime.startsWith("image/")) {
|
||||
return (
|
||||
// constant height container avoids layout shift when images load
|
||||
<div className="-mx-4 md:mx-0 my-3 md:h-80 flex items-center justify-center">
|
||||
<ProxyImg key={props.url} src={props.url} onClick={props.onMediaClick} className="max-h-[80vh] md:max-h-80" />
|
||||
</div>
|
||||
);
|
||||
return <ImageElement url={props.url} meta={props.meta} onMediaClick={props.onMediaClick} />;
|
||||
} else if (props.mime.startsWith("audio/")) {
|
||||
return <audio key={props.url} src={props.url} controls />;
|
||||
return <AudioElement url={props.url} />;
|
||||
} else if (props.mime.startsWith("video/")) {
|
||||
return (
|
||||
<div className="-mx-4 md:mx-0 my-3 md:h-80 flex items-center justify-center">
|
||||
<video
|
||||
autoPlay={autoplay}
|
||||
loop={true}
|
||||
muted={autoplay}
|
||||
key={props.url}
|
||||
src={props.url}
|
||||
controls
|
||||
poster={proxy(props.url)}
|
||||
className="max-h-[80vh] md:max-h-80"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <VideoElement url={props.url} />;
|
||||
} else {
|
||||
return (
|
||||
<a
|
||||
|
@ -78,6 +78,7 @@
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note-creator-icon.pfp .avatar {
|
||||
|
@ -6,7 +6,7 @@ import { TagsInput } from "react-tag-input-component";
|
||||
|
||||
import Icon from "@/Icons/Icon";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import { appendDedupe, openFile } from "@/SnortUtils";
|
||||
import { appendDedupe, openFile, trackEvent } from "@/SnortUtils";
|
||||
import Textarea from "@/Element/Textarea";
|
||||
import Modal from "@/Element/Modal";
|
||||
import ProfileImage from "@/Element/User/ProfileImage";
|
||||
@ -27,6 +27,7 @@ import { sendEventToRelays } from "@/Element/Event/Create/util";
|
||||
import { TrendingHashTagsLine } from "@/Element/Event/Create/TrendingHashTagsLine";
|
||||
import { Toastore } from "@/Toaster";
|
||||
import { OkResponseRow } from "./OkResponseRow";
|
||||
import CloseButton from "@/Element/Button/CloseButton";
|
||||
|
||||
export function NoteCreator() {
|
||||
const { formatMessage } = useIntl();
|
||||
@ -158,6 +159,21 @@ export function NoteCreator() {
|
||||
async function sendNote() {
|
||||
const ev = await buildNote();
|
||||
if (ev) {
|
||||
let props: Record<string, boolean> | undefined = undefined;
|
||||
if (ev.tags.find(a => a[0] === "content-warning")) {
|
||||
props ??= {};
|
||||
props["content-warning"] = true;
|
||||
}
|
||||
if (ev.tags.find(a => a[0] === "poll_option")) {
|
||||
props ??= {};
|
||||
props["poll"] = true;
|
||||
}
|
||||
if (ev.tags.find(a => a[0] === "zap")) {
|
||||
props ??= {};
|
||||
props["zap-split"] = true;
|
||||
}
|
||||
trackEvent("PostNote", props);
|
||||
|
||||
const events = (note.otherEvents ?? []).concat(ev);
|
||||
events.map(a =>
|
||||
sendEventToRelays(system, a, note.selectedCustomRelays, r => {
|
||||
@ -193,7 +209,7 @@ export function NoteCreator() {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(file: File | Blob) {
|
||||
async function uploadFile(file: File) {
|
||||
try {
|
||||
if (file) {
|
||||
const rx = await uploader.upload(file, file.name);
|
||||
@ -215,6 +231,9 @@ export function NoteCreator() {
|
||||
if (rx.metadata.width && rx.metadata.height) {
|
||||
imeta.push(`dim ${rx.metadata.width}x${rx.metadata.height}`);
|
||||
}
|
||||
if (rx.metadata.hash) {
|
||||
imeta.push(`x ${rx.metadata.hash}`);
|
||||
}
|
||||
v.extraTags.push(imeta);
|
||||
}
|
||||
} else if (rx?.error) {
|
||||
@ -256,6 +275,7 @@ export function NoteCreator() {
|
||||
note.update(v => (v.preview = undefined));
|
||||
} else if (publisher) {
|
||||
const tmpNote = await buildNote();
|
||||
trackEvent("PostNotePreview");
|
||||
note.update(v => (v.preview = tmpNote));
|
||||
}
|
||||
}
|
||||
@ -291,11 +311,7 @@ export function NoteCreator() {
|
||||
</div>
|
||||
<div>
|
||||
<input type="text" value={a} onChange={e => changePollOption(i, e.target.value)} />
|
||||
{i > 1 && (
|
||||
<button onClick={() => removePollOption(i)} className="ml5">
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
)}
|
||||
{i > 1 && <CloseButton className="ml5" onClick={() => removePollOption(i)} />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -392,7 +408,7 @@ export function NoteCreator() {
|
||||
<div className="flex flex-col g8">
|
||||
{[...(note.zapSplits ?? [])].map((v, i, arr) => (
|
||||
<div className="flex items-center g8">
|
||||
<div className="flex flex-col f-4 g4">
|
||||
<div className="flex flex-col flex-4 g4">
|
||||
<h4>
|
||||
<FormattedMessage defaultMessage="Recipient" id="8Rkoyb" />
|
||||
</h4>
|
||||
@ -407,7 +423,7 @@ export function NoteCreator() {
|
||||
placeholder={formatMessage({ defaultMessage: "npub / nprofile / nostr address", id: "WvGmZT" })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col f-1 g4">
|
||||
<div className="flex flex-col flex-1 g4">
|
||||
<h4>
|
||||
<FormattedMessage defaultMessage="Weight" id="zCb8fX" />
|
||||
</h4>
|
||||
@ -479,7 +495,7 @@ export function NoteCreator() {
|
||||
|
||||
function noteCreatorFooter() {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center g8">
|
||||
<ProfileImage
|
||||
pubkey={login.publicKey ?? ""}
|
||||
@ -487,6 +503,7 @@ export function NoteCreator() {
|
||||
link=""
|
||||
showUsername={false}
|
||||
showFollowDistance={false}
|
||||
showProfileCard={false}
|
||||
/>
|
||||
{note.pollOptions === undefined && !note.replyTo && (
|
||||
<AsyncIcon
|
||||
@ -579,6 +596,7 @@ export function NoteCreator() {
|
||||
options={{
|
||||
showFooter: false,
|
||||
showContextMenu: false,
|
||||
showProfileCard: false,
|
||||
showTime: false,
|
||||
canClick: false,
|
||||
showMedia: false,
|
||||
@ -662,7 +680,11 @@ export function NoteCreator() {
|
||||
|
||||
if (!note.show) return null;
|
||||
return (
|
||||
<Modal id="note-creator" className="note-creator-modal" onClose={reset}>
|
||||
<Modal
|
||||
id="note-creator"
|
||||
bodyClassName="modal-body flex flex-col gap-4"
|
||||
className="note-creator-modal"
|
||||
onClose={reset}>
|
||||
{noteCreatorForm()}
|
||||
</Modal>
|
||||
);
|
||||
|
@ -32,7 +32,6 @@ export function OkResponseRow({ rsp, close }: { rsp: OkResponse; close: () => vo
|
||||
|
||||
return (
|
||||
<div className="flex items-center g16">
|
||||
<Icon name={r.ok ? "check" : "x"} className={r.ok ? "success" : "error"} size={24} />
|
||||
<div className="flex flex-col grow g4">
|
||||
<b>{getRelayName(r.relay)}</b>
|
||||
{r.message && <small>{r.message}</small>}
|
||||
|
@ -1,23 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocale } from "@/IntlProvider";
|
||||
import NostrBandApi from "@/External/NostrBand";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import useCachedFetch from "@/Hooks/useCachedFetch";
|
||||
import { ErrorOrOffline } from "@/Element/ErrorOrOffline";
|
||||
|
||||
export function TrendingHashTagsLine(props: { onClick: (tag: string) => void }) {
|
||||
const [hashtags, setHashtags] = useState<Array<{ hashtag: string; posts: number }>>();
|
||||
const { lang } = useLocale();
|
||||
const api = new NostrBandApi();
|
||||
const trendingHashtagsUrl = api.trendingHashtagsUrl(lang);
|
||||
const storageKey = `nostr-band-${trendingHashtagsUrl}`;
|
||||
|
||||
async function loadTrendingHashtags() {
|
||||
const api = new NostrBandApi();
|
||||
const rsp = await api.trendingHashtags(lang);
|
||||
setHashtags(rsp.hashtags);
|
||||
}
|
||||
const { data: hashtags, isLoading, error } = useCachedFetch(trendingHashtagsUrl, storageKey, data => data.hashtags);
|
||||
|
||||
useEffect(() => {
|
||||
loadTrendingHashtags().catch(console.error);
|
||||
}, []);
|
||||
if (error && !hashtags) return <ErrorOrOffline error={error} className="p" />;
|
||||
|
||||
if (isLoading || hashtags.length === 0) return null;
|
||||
|
||||
if (!hashtags || hashtags.length === 0) return;
|
||||
return (
|
||||
<div className="flex flex-col g4">
|
||||
<small>
|
||||
@ -25,7 +23,10 @@ export function TrendingHashTagsLine(props: { onClick: (tag: string) => void })
|
||||
</small>
|
||||
<div className="flex g4 flex-wrap">
|
||||
{hashtags.slice(0, 5).map(a => (
|
||||
<span className="px-2 py-1 bg-dark rounded-full pointer nowrap" onClick={() => props.onClick(a.hashtag)}>
|
||||
<span
|
||||
key={a.hashtag}
|
||||
className="px-2 py-1 bg-dark rounded-full pointer nowrap"
|
||||
onClick={() => props.onClick(a.hashtag)}>
|
||||
#{a.hashtag}
|
||||
</span>
|
||||
))}
|
||||
|
@ -7,8 +7,8 @@ export async function sendEventToRelays(
|
||||
customRelays?: Array<string>,
|
||||
setResults?: (x: Array<OkResponse>) => void,
|
||||
) {
|
||||
console.log("sendEventToRelays", ev, customRelays);
|
||||
if (customRelays) {
|
||||
system.HandleEvent({ ...ev, relays: [] });
|
||||
return removeUndefined(
|
||||
await Promise.all(
|
||||
customRelays.map(async r => {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import "./LongFormText.css";
|
||||
import { CSSProperties, useCallback, useRef, useState } from "react";
|
||||
import React, { CSSProperties, useCallback, useRef, useState } from "react";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { useEventReactions } from "@snort/system-react";
|
||||
@ -11,20 +11,25 @@ import useImgProxy from "@/Hooks/useImgProxy";
|
||||
import ProfilePreview from "@/Element/User/ProfilePreview";
|
||||
import NoteFooter from "./NoteFooter";
|
||||
import NoteTime from "./NoteTime";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface LongFormTextProps {
|
||||
ev: TaggedNostrEvent;
|
||||
isPreview: boolean;
|
||||
related: ReadonlyArray<TaggedNostrEvent>;
|
||||
onClick?: () => void;
|
||||
truncate?: boolean;
|
||||
}
|
||||
|
||||
const TEXT_TRUNCATE_LENGTH = 400;
|
||||
|
||||
export function LongFormText(props: LongFormTextProps) {
|
||||
const title = findTag(props.ev, "title");
|
||||
const summary = findTag(props.ev, "summary");
|
||||
const image = findTag(props.ev, "image");
|
||||
const { proxy } = useImgProxy();
|
||||
const [reading, setReading] = useState(false);
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { reactions, reposts, zaps } = useEventReactions(NostrLink.fromEvent(props.ev), props.related);
|
||||
|
||||
@ -85,6 +90,25 @@ export function LongFormText(props: LongFormTextProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const ToggleShowMore = () => (
|
||||
<a
|
||||
className="highlight cursor-pointer"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowMore(!showMore);
|
||||
}}>
|
||||
{showMore ? (
|
||||
<FormattedMessage defaultMessage="Show less" id="qyJtWy" />
|
||||
) : (
|
||||
<FormattedMessage defaultMessage="Show more" id="aWpBzj" />
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
|
||||
const shouldTruncate = props.truncate && props.ev.content.length > TEXT_TRUNCATE_LENGTH;
|
||||
const content = shouldTruncate && !showMore ? props.ev.content.slice(0, TEXT_TRUNCATE_LENGTH) : props.ev.content;
|
||||
|
||||
function fullText() {
|
||||
return (
|
||||
<>
|
||||
@ -113,7 +137,9 @@ export function LongFormText(props: LongFormTextProps) {
|
||||
)}
|
||||
</div>
|
||||
<hr />
|
||||
<Markdown content={props.ev.content} tags={props.ev.tags} ref={ref} />
|
||||
{shouldTruncate && showMore && <ToggleShowMore />}
|
||||
<Markdown content={content} tags={props.ev.tags} ref={ref} />
|
||||
{shouldTruncate && !showMore && <ToggleShowMore />}
|
||||
<hr />
|
||||
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} />
|
||||
</>
|
||||
@ -121,12 +147,7 @@ export function LongFormText(props: LongFormTextProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="long-form-note flex flex-col g16 p pointer"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
props.onClick?.();
|
||||
}}>
|
||||
<div className={classNames("long-form-note flex flex-col g16 p break-words")}>
|
||||
<ProfilePreview
|
||||
pubkey={props.ev.pubkey}
|
||||
actions={
|
||||
|
@ -30,7 +30,15 @@ export function NostrFileElement({ ev }: { ev: NostrEvent }) {
|
||||
message={
|
||||
<FormattedMessage defaultMessage="Click to load content from {link}" id="lsNFM1" values={{ link: u }} />
|
||||
}>
|
||||
<MediaElement mime={m} url={u} sha256={x} magnet={magnet} blurHash={blurHash} />
|
||||
<MediaElement
|
||||
mime={m}
|
||||
url={u}
|
||||
meta={{
|
||||
sha256: x,
|
||||
magnet: magnet,
|
||||
blurHash: blurHash,
|
||||
}}
|
||||
/>
|
||||
</Reveal>
|
||||
);
|
||||
} else {
|
||||
|
@ -1,10 +1,3 @@
|
||||
.note {
|
||||
min-height: 110px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.note > .header .reply {
|
||||
font-size: 13px;
|
||||
color: var(--font-secondary-color);
|
||||
|
@ -10,6 +10,7 @@ import NoteReaction from "@/Element/Event/NoteReaction";
|
||||
import ProfilePreview from "@/Element/User/ProfilePreview";
|
||||
import { NoteInner } from "./NoteInner";
|
||||
import { LongFormText } from "./LongFormText";
|
||||
import ErrorBoundary from "@/Element/ErrorBoundary";
|
||||
|
||||
export interface NoteProps {
|
||||
data: TaggedNostrEvent;
|
||||
@ -26,6 +27,7 @@ export interface NoteProps {
|
||||
isRoot?: boolean;
|
||||
showHeader?: boolean;
|
||||
showContextMenu?: boolean;
|
||||
showProfileCard?: boolean;
|
||||
showTime?: boolean;
|
||||
showPinned?: boolean;
|
||||
showBookmarked?: boolean;
|
||||
@ -39,41 +41,50 @@ export interface NoteProps {
|
||||
longFormPreview?: boolean;
|
||||
truncate?: boolean;
|
||||
};
|
||||
waitUntilInView?: boolean;
|
||||
}
|
||||
|
||||
export default function Note(props: NoteProps) {
|
||||
const { data: ev, className } = props;
|
||||
if (ev.kind === EventKind.Repost) {
|
||||
return <NoteReaction data={ev} key={ev.id} root={undefined} depth={(props.depth ?? 0) + 1} />;
|
||||
}
|
||||
if (ev.kind === EventKind.FileHeader) {
|
||||
return <NostrFileElement ev={ev} />;
|
||||
}
|
||||
if (ev.kind === EventKind.ZapstrTrack) {
|
||||
return <ZapstrEmbed ev={ev} />;
|
||||
}
|
||||
if (ev.kind === EventKind.FollowSet || ev.kind === EventKind.ContactList) {
|
||||
return <PubkeyList ev={ev} className={className} />;
|
||||
}
|
||||
if (ev.kind === EventKind.LiveEvent) {
|
||||
return <LiveEvent ev={ev} />;
|
||||
}
|
||||
if (ev.kind === EventKind.SetMetadata) {
|
||||
return <ProfilePreview actions={<></>} pubkey={ev.pubkey} />;
|
||||
}
|
||||
if (ev.kind === (9041 as EventKind)) {
|
||||
return <ZapGoal ev={ev} />;
|
||||
}
|
||||
if (ev.kind === EventKind.LongFormTextNote) {
|
||||
return (
|
||||
<LongFormText
|
||||
ev={ev}
|
||||
related={props.related}
|
||||
isPreview={props.options?.longFormPreview ?? false}
|
||||
onClick={() => props.onClick?.(ev)}
|
||||
/>
|
||||
);
|
||||
|
||||
let content;
|
||||
switch (ev.kind) {
|
||||
case EventKind.Repost:
|
||||
content = <NoteReaction data={ev} key={ev.id} root={undefined} depth={(props.depth ?? 0) + 1} />;
|
||||
break;
|
||||
case EventKind.FileHeader:
|
||||
content = <NostrFileElement ev={ev} />;
|
||||
break;
|
||||
case EventKind.ZapstrTrack:
|
||||
content = <ZapstrEmbed ev={ev} />;
|
||||
break;
|
||||
case EventKind.FollowSet:
|
||||
case EventKind.ContactList:
|
||||
content = <PubkeyList ev={ev} className={className} />;
|
||||
break;
|
||||
case EventKind.LiveEvent:
|
||||
content = <LiveEvent ev={ev} />;
|
||||
break;
|
||||
case EventKind.SetMetadata:
|
||||
content = <ProfilePreview actions={<></>} pubkey={ev.pubkey} />;
|
||||
break;
|
||||
case 9041: // Assuming 9041 is a valid EventKind
|
||||
content = <ZapGoal ev={ev} />;
|
||||
break;
|
||||
case EventKind.LongFormTextNote:
|
||||
content = (
|
||||
<LongFormText
|
||||
ev={ev}
|
||||
related={props.related}
|
||||
isPreview={props.options?.longFormPreview ?? false}
|
||||
onClick={() => props.onClick?.(ev)}
|
||||
truncate={props.options?.truncate}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
content = <NoteInner {...props} />;
|
||||
}
|
||||
|
||||
return <NoteInner {...props} />;
|
||||
return <ErrorBoundary>{content}</ErrorBoundary>;
|
||||
}
|
||||
|
@ -91,7 +91,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
if (!hasReacted(content) && publisher) {
|
||||
const evLike = await publisher.react(ev, content);
|
||||
system.BroadcastEvent(evLike);
|
||||
await interactionCache.react();
|
||||
interactionCache.react();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -31,14 +31,14 @@ import DisplayName from "@/Element/User/DisplayName";
|
||||
const TEXT_TRUNCATE_LENGTH = 400;
|
||||
|
||||
export function NoteInner(props: NoteProps) {
|
||||
const { data: ev, related, highlight, options: opt, ignoreModeration = false, className } = props;
|
||||
const { data: ev, related, highlight, options: opt, ignoreModeration = false, className, waitUntilInView } = props;
|
||||
|
||||
const baseClassName = classNames("note card", className);
|
||||
const baseClassName = classNames("note min-h-[110px] flex flex-col gap-4 card", className);
|
||||
const navigate = useNavigate();
|
||||
const [showReactions, setShowReactions] = useState(false);
|
||||
|
||||
const { isEventMuted } = useModeration();
|
||||
const { ref, inView } = useInView({ triggerOnce: true });
|
||||
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
|
||||
const { reactions, reposts, deletions, zaps } = useEventReactions(NostrLink.fromEvent(ev), related);
|
||||
const login = useLogin();
|
||||
const { pinned, bookmarked } = useLogin();
|
||||
@ -326,7 +326,7 @@ export function NoteInner(props: NoteProps) {
|
||||
}
|
||||
|
||||
function content() {
|
||||
if (!inView) return undefined;
|
||||
if (waitUntilInView && !inView) return undefined;
|
||||
return (
|
||||
<>
|
||||
{options.showHeader && (
|
||||
@ -335,6 +335,8 @@ export function NoteInner(props: NoteProps) {
|
||||
pubkey={ev.pubkey}
|
||||
subHeader={replyTag() ?? undefined}
|
||||
link={opt?.canClick === undefined ? undefined : ""}
|
||||
showProfileCard={options.showProfileCard ?? true}
|
||||
showBadges={true}
|
||||
/>
|
||||
<div className="info">
|
||||
{props.context}
|
||||
@ -371,9 +373,9 @@ export function NoteInner(props: NoteProps) {
|
||||
{translation()}
|
||||
{pollOptions()}
|
||||
{options.showReactionsLink && (
|
||||
<div className="reactions-link cursor-pointer" onClick={() => setShowReactions(true)}>
|
||||
<span className="reactions-link cursor-pointer" onClick={() => setShowReactions(true)}>
|
||||
<FormattedMessage {...messages.ReactionsLink} values={{ n: totalReactions }} />
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{options.showFooter && (
|
||||
|
@ -6,7 +6,12 @@ import PageSpinner from "@/Element/PageSpinner";
|
||||
|
||||
export default function NoteQuote({ link, depth }: { link: NostrLink; depth?: number }) {
|
||||
const ev = useEventFeed(link);
|
||||
if (!ev.data) return <PageSpinner />;
|
||||
if (!ev.data)
|
||||
return (
|
||||
<div className="note-quote flex items-center justify-center h-[110px]">
|
||||
<PageSpinner />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Note
|
||||
data={ev.data}
|
||||
|
@ -19,7 +19,7 @@ export interface NoteReactionProps {
|
||||
export default function NoteReaction(props: NoteReactionProps) {
|
||||
const { data: ev } = props;
|
||||
const { isMuted } = useModeration();
|
||||
const { inView, ref } = useInView({ triggerOnce: true });
|
||||
const { inView, ref } = useInView({ triggerOnce: true, rootMargin: "2000px" });
|
||||
const profile = useUserProfile(inView ? ev.pubkey : "");
|
||||
|
||||
const refEvent = useMemo(() => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export interface NoteTimeProps {
|
||||
@ -11,13 +11,17 @@ const secondsInAnHour = secondsInAMinute * 60;
|
||||
const secondsInADay = secondsInAnHour * 24;
|
||||
|
||||
export default function NoteTime(props: NoteTimeProps) {
|
||||
const [time, setTime] = useState<string | JSX.Element>();
|
||||
const { from, fallback } = props;
|
||||
const [time, setTime] = useState<string | JSX.Element>(calcTime());
|
||||
|
||||
const absoluteTime = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "long",
|
||||
}).format(from);
|
||||
const absoluteTime = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "long",
|
||||
}).format(from),
|
||||
[from],
|
||||
);
|
||||
|
||||
const isoDate = new Date(from).toISOString();
|
||||
|
||||
|
@ -12,6 +12,7 @@ import Tabs from "@/Element/Tabs";
|
||||
import Modal from "@/Element/Modal";
|
||||
|
||||
import messages from "../messages";
|
||||
import CloseButton from "@/Element/Button/CloseButton";
|
||||
|
||||
interface ReactionsProps {
|
||||
show: boolean;
|
||||
@ -73,9 +74,8 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
|
||||
|
||||
return show ? (
|
||||
<Modal id="reactions" className="reactions-modal" onClose={onClose}>
|
||||
<div className="close" onClick={onClose}>
|
||||
<Icon name="close" />
|
||||
</div>
|
||||
<CloseButton onClick={onClose} className="absolute right-4 top-3" />
|
||||
|
||||
<div className="reactions-header">
|
||||
<h2>
|
||||
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
|
||||
@ -88,7 +88,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
|
||||
return (
|
||||
<div key={ev.id} className="reactions-item">
|
||||
<div className="reaction-icon">{ev.content === "+" ? <Icon name="heart" /> : ev.content}</div>
|
||||
<ProfileImage pubkey={ev.pubkey} />
|
||||
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -102,6 +102,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
|
||||
<span className="zap-amount">{formatShort(z.amount)}</span>
|
||||
</div>
|
||||
<ProfileImage
|
||||
showProfileCard={true}
|
||||
pubkey={z.anonZap ? "" : z.sender}
|
||||
subHeader={<div title={z.content}>{z.content}</div>}
|
||||
link={z.anonZap ? "" : undefined}
|
||||
@ -120,7 +121,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
|
||||
<div className="reaction-icon">
|
||||
<Icon name="repost" size={16} />
|
||||
</div>
|
||||
<ProfileImage pubkey={ev.pubkey} />
|
||||
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -131,7 +132,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
|
||||
<div className="reaction-icon">
|
||||
<Icon name="dislike" />
|
||||
</div>
|
||||
<ProfileImage pubkey={ev.pubkey} />
|
||||
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
@ -5,11 +5,13 @@ import Reveal from "@/Element/Event/Reveal";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { MediaElement } from "@/Element/Embed/MediaElement";
|
||||
import { Link } from "react-router-dom";
|
||||
import { IMeta } from "@snort/system";
|
||||
|
||||
interface RevealMediaProps {
|
||||
creator: string;
|
||||
link: string;
|
||||
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
|
||||
meta?: IMeta;
|
||||
}
|
||||
|
||||
export default function RevealMedia(props: RevealMediaProps) {
|
||||
@ -66,10 +68,22 @@ export default function RevealMedia(props: RevealMediaProps) {
|
||||
}}
|
||||
/>
|
||||
}>
|
||||
<MediaElement mime={`${type}/${extension}`} url={url.toString()} onMediaClick={props.onMediaClick} />
|
||||
<MediaElement
|
||||
mime={`${type}/${extension}`}
|
||||
url={url.toString()}
|
||||
onMediaClick={props.onMediaClick}
|
||||
meta={props.meta}
|
||||
/>
|
||||
</Reveal>
|
||||
);
|
||||
} else {
|
||||
return <MediaElement mime={`${type}/${extension}`} url={url.toString()} onMediaClick={props.onMediaClick} />;
|
||||
return (
|
||||
<MediaElement
|
||||
mime={`${type}/${extension}`}
|
||||
url={url.toString()}
|
||||
onMediaClick={props.onMediaClick}
|
||||
meta={props.meta}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ const ShowMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
|
||||
export default ShowMore;
|
||||
|
||||
export function ShowMoreInView({ text, onClick, className }: ShowMoreProps) {
|
||||
const { ref, inView } = useInView();
|
||||
const { ref, inView } = useInView({ rootMargin: "2000px" });
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
|
@ -18,7 +18,7 @@ const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean
|
||||
return valid && sender ? (
|
||||
<div className="card">
|
||||
<div className="flex justify-between">
|
||||
<ProfileImage pubkey={sender} />
|
||||
<ProfileImage pubkey={sender} showProfileCard={true} />
|
||||
{receiver !== pubKey && showZapped && <ProfileImage pubkey={unwrap(receiver)} />}
|
||||
<h3>
|
||||
<FormattedMessage {...messages.Sats} values={{ n: formatShort(amount ?? 0) }} />
|
||||
|
9
packages/app/src/Element/Event/getEventMedia.ts
Normal file
9
packages/app/src/Element/Event/getEventMedia.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { transformTextCached } from "@/Hooks/useTextTransformCache";
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
export default function getEventMedia(event: TaggedNostrEvent) {
|
||||
const parsed = transformTextCached(event.id, event.content, event.tags);
|
||||
return parsed.filter(
|
||||
a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")),
|
||||
);
|
||||
}
|
@ -1,18 +1,14 @@
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { MouseEvent } from "react";
|
||||
import useImgProxy from "@/Hooks/useImgProxy";
|
||||
import { transformTextCached } from "@/Hooks/useTextTransformCache";
|
||||
import { Link } from "react-router-dom";
|
||||
import Icon from "@/Icons/Icon";
|
||||
import getEventMedia from "@/Element/Event/getEventMedia";
|
||||
import { ProxyImg } from "@/Element/ProxyImg";
|
||||
|
||||
const ImageGridItem = (props: { event: TaggedNostrEvent; onClick: (e: MouseEvent) => void }) => {
|
||||
const { event, onClick } = props;
|
||||
const { proxy } = useImgProxy();
|
||||
|
||||
const parsed = transformTextCached(event.id, event.content, event.tags);
|
||||
const media = parsed.filter(
|
||||
a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")),
|
||||
);
|
||||
const media = getEventMedia(event);
|
||||
|
||||
if (media.length === 0) return null;
|
||||
|
||||
@ -29,7 +25,7 @@ const ImageGridItem = (props: { event: TaggedNostrEvent; onClick: (e: MouseEvent
|
||||
|
||||
return (
|
||||
<Link to={`/${noteId}`} className="aspect-square cursor-pointer hover:opacity-80 relative" onClick={myOnClick}>
|
||||
<img src={proxy(media[0].content, 256)} alt="Note Media" className="w-full h-full object-cover" />
|
||||
<ProxyImg src={media[0].content} alt="Note Media" className="w-full h-full object-cover" />
|
||||
<div className="absolute right-2 top-2 flex flex-col gap-2">
|
||||
{multiple && <Icon name="copy-solid" className="text-white opacity-80 drop-shadow-md" />}
|
||||
{isVideo && <Icon name="play-square-outline" className="text-white opacity-80 drop-shadow-md" />}
|
||||
|
@ -13,7 +13,7 @@ export default function LoadMore({
|
||||
shouldLoadMore: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { ref, inView } = useInView();
|
||||
const { ref, inView } = useInView({ rootMargin: "2000px" });
|
||||
const [tick, setTick] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -10,6 +10,7 @@ import { Newest } from "@/Login";
|
||||
|
||||
export type RootTab =
|
||||
| "following"
|
||||
| "followed-by-friends"
|
||||
| "conversations"
|
||||
| "trending-notes"
|
||||
| "trending-people"
|
||||
@ -53,13 +54,13 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: New
|
||||
),
|
||||
},
|
||||
{
|
||||
tab: "trending-people",
|
||||
path: `${base}/trending/people`,
|
||||
show: true,
|
||||
tab: "followed-by-friends",
|
||||
path: `${base}/followed-by-friends`,
|
||||
show: Boolean(pubKey),
|
||||
element: (
|
||||
<>
|
||||
<Icon name="user-up" />
|
||||
<FormattedMessage defaultMessage="Trending People" id="CVWeJ6" />
|
||||
<Icon name="user-v2" />
|
||||
<FormattedMessage defaultMessage="Followed by friends" id="voxBKC" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
@ -11,8 +11,6 @@
|
||||
.latest-notes-fixed {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: auto;
|
||||
z-index: 42;
|
||||
opacity: 0.9;
|
||||
@ -23,16 +21,6 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.latest-notes-fixed {
|
||||
width: 200px;
|
||||
padding: 6px 12px;
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: calc(50% - 110px);
|
||||
}
|
||||
}
|
||||
|
||||
.latest-notes .pfp:not(:last-of-type) {
|
||||
margin: 0;
|
||||
margin-right: -26px;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import "./Timeline.css";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { TaggedNostrEvent, EventKind } from "@snort/system";
|
||||
import { TaggedNostrEvent, EventKind, socialGraphInstance } from "@snort/system";
|
||||
|
||||
import { dedupeByPubkey, findTag } from "@/SnortUtils";
|
||||
import useTimelineFeed, { TimelineFeed, TimelineSubject } from "@/Feed/TimelineFeed";
|
||||
@ -16,6 +16,7 @@ export interface TimelineProps {
|
||||
postsOnly: boolean;
|
||||
subject: TimelineSubject;
|
||||
method: "TIME_RANGE" | "LIMIT_UNTIL";
|
||||
followDistance?: number;
|
||||
ignoreModeration?: boolean;
|
||||
window?: number;
|
||||
now?: number;
|
||||
@ -44,13 +45,20 @@ const Timeline = (props: TimelineProps) => {
|
||||
const { muted, isEventMuted } = useModeration();
|
||||
const filterPosts = useCallback(
|
||||
(nts: readonly TaggedNostrEvent[]) => {
|
||||
const checkFollowDistance = (a: TaggedNostrEvent) => {
|
||||
if (props.followDistance === undefined) {
|
||||
return true;
|
||||
}
|
||||
const followDistance = socialGraphInstance.getFollowDistance(a.pubkey);
|
||||
return followDistance === props.followDistance;
|
||||
};
|
||||
const a = [...nts.filter(a => a.kind !== EventKind.LiveEvent)];
|
||||
props.noSort || a.sort((a, b) => b.created_at - a.created_at);
|
||||
return a
|
||||
?.filter(a => (props.postsOnly ? !a.tags.some(b => b[0] === "e") : true))
|
||||
.filter(a => props.ignoreModeration || !isEventMuted(a));
|
||||
.filter(a => (props.ignoreModeration || !isEventMuted(a)) && checkFollowDistance(a));
|
||||
},
|
||||
[props.postsOnly, muted, props.ignoreModeration],
|
||||
[props.postsOnly, muted, props.ignoreModeration, props.followDistance],
|
||||
);
|
||||
|
||||
const mainFeed = useMemo(() => {
|
||||
|
@ -13,6 +13,7 @@ export interface TimelineFragment {
|
||||
export interface TimelineFragProps {
|
||||
frag: TimelineFragment;
|
||||
related: Array<TaggedNostrEvent>;
|
||||
index: number;
|
||||
noteRenderer?: (ev: TaggedNostrEvent) => ReactNode;
|
||||
noteOnClick?: (ev: TaggedNostrEvent) => void;
|
||||
noteContext?: (ev: TaggedNostrEvent) => ReactNode;
|
||||
@ -41,6 +42,7 @@ export function TimelineFragment(props: TimelineFragProps) {
|
||||
options={{
|
||||
truncate: true,
|
||||
}}
|
||||
waitUntilInView={props.index > 10}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
@ -2,12 +2,14 @@ import { useInView } from "react-intersection-observer";
|
||||
import ProfileImage from "@/Element/User/ProfileImage";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import Icon from "@/Icons/Icon";
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TimelineFragment } from "@/Element/Feed/TimelineFragment";
|
||||
import { DisplayAs } from "@/Element/Feed/DisplayAsSelector";
|
||||
import { SpotlightThreadModal } from "@/Element/Spotlight/SpotlightThreadModal";
|
||||
import ImageGridItem from "@/Element/Feed/ImageGridItem";
|
||||
import ErrorBoundary from "@/Element/ErrorBoundary";
|
||||
import getEventMedia from "@/Element/Event/getEventMedia";
|
||||
|
||||
export interface TimelineRendererProps {
|
||||
frags: Array<TimelineFragment>;
|
||||
@ -23,36 +25,88 @@ export interface TimelineRendererProps {
|
||||
displayAs?: DisplayAs;
|
||||
}
|
||||
|
||||
export function TimelineRenderer(props: TimelineRendererProps) {
|
||||
const { ref, inView } = useInView();
|
||||
const [modalThread, setModalThread] = useState<NostrLink | undefined>(undefined);
|
||||
// filter frags[0].events that have media
|
||||
function Grid({ frags }: { frags: Array<TimelineFragment> }) {
|
||||
const [modalEventIndex, setModalEventIndex] = useState<number | undefined>(undefined);
|
||||
const allEvents = useMemo(() => {
|
||||
return frags.flatMap(frag => frag.events);
|
||||
}, [frags]);
|
||||
const mediaEvents = useMemo(() => {
|
||||
return allEvents.filter(event => getEventMedia(event).length > 0);
|
||||
}, [allEvents]);
|
||||
|
||||
const renderNotes = () => {
|
||||
return props.frags.map(frag => (
|
||||
<TimelineFragment
|
||||
frag={frag}
|
||||
related={props.related}
|
||||
noteRenderer={props.noteRenderer}
|
||||
noteOnClick={props.noteOnClick}
|
||||
noteContext={props.noteContext}
|
||||
/>
|
||||
));
|
||||
};
|
||||
const modalEvent = modalEventIndex !== undefined ? mediaEvents[modalEventIndex] : undefined;
|
||||
const nextModalEvent = modalEventIndex !== undefined ? mediaEvents[modalEventIndex + 1] : undefined;
|
||||
const prevModalEvent = modalEventIndex !== undefined ? mediaEvents[modalEventIndex - 1] : undefined;
|
||||
|
||||
const renderGrid = () => {
|
||||
// TODO Hide images from notes with a content warning, unless otherwise configured
|
||||
|
||||
return props.frags.map(frag => (
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-px md:gap-1">
|
||||
{frag.events.map(event => (
|
||||
<ImageGridItem event={event} onClick={() => setModalThread(NostrLink.fromEvent(event))} />
|
||||
{mediaEvents.map((event, index) => (
|
||||
<ImageGridItem key={event.id} event={event} onClick={() => setModalEventIndex(index)} />
|
||||
))}
|
||||
</div>
|
||||
{modalEvent && (
|
||||
<SpotlightThreadModal
|
||||
key={modalEvent.id}
|
||||
event={modalEvent}
|
||||
onClose={() => setModalEventIndex(undefined)}
|
||||
onBack={() => setModalEventIndex(undefined)}
|
||||
onNext={() => setModalEventIndex(Math.min((modalEventIndex ?? 0) + 1, mediaEvents.length - 1))}
|
||||
onPrev={() => setModalEventIndex(Math.max((modalEventIndex ?? 0) - 1, 0))}
|
||||
/>
|
||||
)}
|
||||
{nextModalEvent && ( // preload next
|
||||
<SpotlightThreadModal className="hidden" key={`${nextModalEvent.id}-next`} event={nextModalEvent} />
|
||||
)}
|
||||
{prevModalEvent && ( // preload previous
|
||||
<SpotlightThreadModal className="hidden" key={`${prevModalEvent.id}-prev`} event={prevModalEvent} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineRenderer(props: TimelineRendererProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const latestNotesFixedRef = useRef<HTMLDivElement | null>(null);
|
||||
const { ref, inView } = useInView();
|
||||
|
||||
const updateLatestNotesPosition = () => {
|
||||
if (containerRef.current && latestNotesFixedRef.current) {
|
||||
const parentRect = containerRef.current.getBoundingClientRect();
|
||||
const childWidth = latestNotesFixedRef.current.offsetWidth;
|
||||
|
||||
const leftPosition = parentRect.left + (parentRect.width - childWidth) / 2;
|
||||
latestNotesFixedRef.current.style.left = `${leftPosition}px`;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateLatestNotesPosition();
|
||||
window.addEventListener("resize", updateLatestNotesPosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateLatestNotesPosition);
|
||||
};
|
||||
}, [inView, props.latest]);
|
||||
|
||||
const renderNotes = () => {
|
||||
return props.frags.map((frag, index) => (
|
||||
<ErrorBoundary key={frag.events[0]?.id + index}>
|
||||
<TimelineFragment
|
||||
frag={frag}
|
||||
related={props.related}
|
||||
noteRenderer={props.noteRenderer}
|
||||
noteOnClick={props.noteOnClick}
|
||||
noteContext={props.noteContext}
|
||||
index={index}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef}>
|
||||
{props.latest.length > 0 && (
|
||||
<>
|
||||
<div className="card latest-notes" onClick={() => props.showLatest(false)} ref={ref}>
|
||||
@ -68,6 +122,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
|
||||
</div>
|
||||
{!inView && (
|
||||
<div
|
||||
ref={latestNotesFixedRef}
|
||||
className="card latest-notes latest-notes-fixed pointer fade-in"
|
||||
onClick={() => props.showLatest(true)}>
|
||||
{props.latest.slice(0, 3).map(p => {
|
||||
@ -91,14 +146,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{props.displayAs === "grid" ? renderGrid() : renderNotes()}
|
||||
{modalThread && (
|
||||
<SpotlightThreadModal
|
||||
thread={modalThread}
|
||||
onClose={() => setModalThread(undefined)}
|
||||
onBack={() => setModalThread(undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{props.displayAs === "grid" ? <Grid frags={props.frags} /> : renderNotes()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -60,7 +60,9 @@ export default function Modal(props: ModalProps) {
|
||||
}, []);
|
||||
|
||||
return createPortal(
|
||||
<div className={`modal${props.className ? ` ${props.className}` : ""}`} onClick={props.onClose}>
|
||||
<div
|
||||
className={props.className === "hidden" ? props.className : `modal ${props.className || ""}`}
|
||||
onClick={props.onClose}>
|
||||
<div
|
||||
className={props.bodyClassName || "modal-body"}
|
||||
onClick={e => {
|
||||
|
@ -1,55 +1,65 @@
|
||||
import useImgProxy from "@/Hooks/useImgProxy";
|
||||
import React, { HTMLProps, ReactNode, useState } from "react";
|
||||
import React, { HTMLProps, ReactNode, forwardRef, useState, useMemo, useEffect } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { getUrlHostname } from "@/SnortUtils";
|
||||
|
||||
type ProxyImgProps = HTMLProps<HTMLImageElement> & {
|
||||
size?: number;
|
||||
sha256?: string;
|
||||
className?: string;
|
||||
promptToLoadDirectly?: boolean;
|
||||
missingImageElement?: ReactNode;
|
||||
};
|
||||
|
||||
export const ProxyImg = ({ size, className, promptToLoadDirectly, missingImageElement, ...props }: ProxyImgProps) => {
|
||||
const { proxy } = useImgProxy();
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [bypass, setBypass] = useState(CONFIG.bypassImgProxyError);
|
||||
export const ProxyImg = forwardRef<HTMLImageElement, ProxyImgProps>(
|
||||
({ size, className, promptToLoadDirectly, missingImageElement, sha256, ...props }: ProxyImgProps, ref) => {
|
||||
const { proxy } = useImgProxy();
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [bypass, setBypass] = useState(CONFIG.media.bypassImgProxyError);
|
||||
const proxiedSrc = useMemo(() => proxy(props.src ?? "", size, sha256), [props.src, size, sha256]);
|
||||
const [src, setSrc] = useState(proxiedSrc);
|
||||
|
||||
if (loadFailed && !bypass && (promptToLoadDirectly ?? true)) {
|
||||
return (
|
||||
<div
|
||||
className="note-invoice error"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setBypass(true);
|
||||
}}>
|
||||
<FormattedMessage
|
||||
defaultMessage="Failed to proxy image from {host}, click here to load directly"
|
||||
id="65BmHb"
|
||||
values={{
|
||||
host: getUrlHostname(props.src),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const src = loadFailed && bypass ? props.src : proxy(props.src ?? "", size);
|
||||
if (!src || (loadFailed && !bypass)) return missingImageElement;
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
src={src}
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
onError={e => {
|
||||
if (props.onError) {
|
||||
props.onError(e);
|
||||
useEffect(() => {
|
||||
setLoadFailed(false);
|
||||
setSrc(proxy(props.src, size, sha256));
|
||||
}, [props.src, size, sha256]);
|
||||
|
||||
if (loadFailed && !bypass && (promptToLoadDirectly ?? true)) {
|
||||
return (
|
||||
<div
|
||||
className="note-invoice error"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setBypass(true);
|
||||
}}>
|
||||
<FormattedMessage
|
||||
defaultMessage="Failed to proxy image from {host}, click here to load directly"
|
||||
id="65BmHb"
|
||||
values={{
|
||||
host: getUrlHostname(props.src),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleImageError = e => {
|
||||
if (props.onError) {
|
||||
props.onError(e);
|
||||
} else {
|
||||
console.error("Failed to load image: ", props.src, e);
|
||||
if (bypass && src === proxiedSrc) {
|
||||
setSrc(props.src ?? "");
|
||||
} else {
|
||||
console.error("Failed to proxy image ", props.src);
|
||||
setLoadFailed(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (!src || loadFailed) return missingImageElement ?? <div>Image not available</div>;
|
||||
|
||||
return (
|
||||
<img {...props} ref={ref} src={src} width={size} height={size} className={className} onError={handleImageError} />
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -8,9 +8,9 @@ import { NostrLink, tryParseNostrLink } from "@snort/system";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { unixNow } from "@snort/shared";
|
||||
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "../Feed/TimelineFeed";
|
||||
import { fuzzySearch, FuzzySearchResult } from "@/index";
|
||||
import ProfileImage from "@/Element/User/ProfileImage";
|
||||
import { socialGraphInstance } from "@snort/system";
|
||||
import fuzzySearch, { FuzzySearchResult } from "@/FuzzySearch";
|
||||
|
||||
const MAX_RESULTS = 3;
|
||||
|
||||
|
@ -19,6 +19,7 @@ import AsyncButton from "@/Element/Button/AsyncButton";
|
||||
import { ZapTarget, ZapTargetResult, Zapper } from "@/Zapper";
|
||||
|
||||
import messages from "./messages";
|
||||
import CloseButton from "@/Element/Button/CloseButton";
|
||||
|
||||
enum ZapType {
|
||||
PublicZap = 1,
|
||||
@ -182,9 +183,7 @@ export default function SendSats(props: SendSatsProps) {
|
||||
<div className="p flex flex-col g12">
|
||||
<div className="flex g12">
|
||||
<div className="flex items-center grow">{props.title || title()}</div>
|
||||
<div onClick={onClose}>
|
||||
<Icon name="close" />
|
||||
</div>
|
||||
<CloseButton onClick={onClose} />
|
||||
</div>
|
||||
{zapper && !invoice && (
|
||||
<SendSatsInput
|
||||
|
@ -1,13 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Modal from "@/Element/Modal";
|
||||
import Icon from "@/Icons/Icon";
|
||||
import { ProxyImg } from "@/Element/ProxyImg";
|
||||
import useImgProxy from "@/Hooks/useImgProxy";
|
||||
|
||||
interface SpotlightMediaProps {
|
||||
images: Array<string>;
|
||||
media: Array<string>;
|
||||
idx: number;
|
||||
className: string;
|
||||
onClose: () => void;
|
||||
onNext?: () => void;
|
||||
onPrev?: () => void;
|
||||
}
|
||||
|
||||
const videoSuffixes = ["mp4", "webm", "ogg", "mov", "avi", "mkv"];
|
||||
@ -17,9 +20,25 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
|
||||
const [idx, setIdx] = useState(props.idx);
|
||||
|
||||
const image = useMemo(() => {
|
||||
return props.images.at(idx % props.images.length);
|
||||
return props.media.at(idx % props.media.length);
|
||||
}, [idx, props]);
|
||||
|
||||
const dec = useCallback(() => {
|
||||
if (idx === 0 && props.onPrev) {
|
||||
props.onPrev();
|
||||
} else {
|
||||
setIdx(s => (s - 1 + props.media.length) % props.media.length);
|
||||
}
|
||||
}, [idx, props.onPrev, props.media.length]); // Add dependencies
|
||||
|
||||
const inc = useCallback(() => {
|
||||
if (idx === props.media.length - 1 && props.onNext) {
|
||||
props.onNext();
|
||||
} else {
|
||||
setIdx(s => (s + 1) % props.media.length);
|
||||
}
|
||||
}, [idx, props.onNext, props.media.length]); // Add dependencies
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
@ -40,27 +59,7 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
function dec() {
|
||||
setIdx(s => {
|
||||
if (s - 1 === -1) {
|
||||
return props.images.length - 1;
|
||||
} else {
|
||||
return s - 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function inc() {
|
||||
setIdx(s => {
|
||||
if (s + 1 === props.images.length) {
|
||||
return 0;
|
||||
} else {
|
||||
return s + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [dec, inc]); // Now dec and inc are stable
|
||||
|
||||
const isVideo = useMemo(() => {
|
||||
return image && videoSuffixes.some(suffix => image.endsWith(suffix));
|
||||
@ -75,11 +74,11 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
|
||||
autoPlay={true}
|
||||
loop={true}
|
||||
controls={true}
|
||||
className="max-h-screen max-w-full"
|
||||
className="max-h-screen max-w-full w-full"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <ProxyImg src={image} className="max-h-screen max-w-full" />;
|
||||
return <ProxyImg src={image} className="max-h-screen max-w-full w-full object-contain" />;
|
||||
}
|
||||
}, [image, isVideo]);
|
||||
|
||||
@ -89,6 +88,10 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const hasMultiple = props.media.length > 1;
|
||||
const hasPrev = hasMultiple || props.onPrev;
|
||||
const hasNext = hasMultiple || props.onNext;
|
||||
|
||||
return (
|
||||
<div className="select-none relative h-screen flex items-center flex-1 justify-center" onClick={onClickBg}>
|
||||
{mediaEl}
|
||||
@ -100,27 +103,27 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute flex flex-row items-center gap-4 right-0 top-0 p-4">
|
||||
{props.images.length > 1 && `${idx + 1}/${props.images.length}`}
|
||||
{props.media.length > 1 && `${idx + 1}/${props.media.length}`}
|
||||
</div>
|
||||
{props.images.length > 1 && (
|
||||
<>
|
||||
<span
|
||||
className="absolute left-0 p-2 top-1/2 rotate-180 cursor-pointer opacity-80 hover:opacity-60"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
dec();
|
||||
}}>
|
||||
<Icon name="arrowFront" size={24} />
|
||||
</span>
|
||||
<span
|
||||
className="absolute right-0 p-2 top-1/2 cursor-pointer opacity-80 hover:opacity-60"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
inc();
|
||||
}}>
|
||||
<Icon name="arrowFront" size={24} />
|
||||
</span>
|
||||
</>
|
||||
{hasPrev && (
|
||||
<span
|
||||
className="absolute left-0 p-2 top-1/2 rotate-180 cursor-pointer opacity-80 hover:opacity-60"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
dec();
|
||||
}}>
|
||||
<Icon name="arrowFront" size={24} />
|
||||
</span>
|
||||
)}
|
||||
{hasNext && (
|
||||
<span
|
||||
className="absolute right-0 p-2 top-1/2 cursor-pointer opacity-80 hover:opacity-60"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
inc();
|
||||
}}>
|
||||
<Icon name="arrowFront" size={24} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,12 +1,21 @@
|
||||
import Modal from "@/Element/Modal";
|
||||
import { ThreadContext, ThreadContextWrapper } from "@/Hooks/useThreadContext";
|
||||
import { ThreadContextWrapper } from "@/Hooks/useThreadContext";
|
||||
import { Thread } from "@/Element/Event/Thread";
|
||||
import { useContext } from "react";
|
||||
import { transformTextCached } from "@/Hooks/useTextTransformCache";
|
||||
import { SpotlightMedia } from "@/Element/Spotlight/SpotlightMedia";
|
||||
import { NostrLink } from "@snort/system";
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import getEventMedia from "@/Element/Event/getEventMedia";
|
||||
|
||||
export function SpotlightThreadModal(props: { thread: NostrLink; onClose?: () => void; onBack?: () => void }) {
|
||||
interface SpotlightThreadModalProps {
|
||||
thread?: NostrLink;
|
||||
event?: TaggedNostrEvent;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
onNext?: () => void;
|
||||
onPrev?: () => void;
|
||||
}
|
||||
|
||||
export function SpotlightThreadModal(props: SpotlightThreadModalProps) {
|
||||
const onClose = () => props.onClose?.();
|
||||
const onBack = () => props.onBack?.();
|
||||
const onClickBg = (e: React.MouseEvent) => {
|
||||
@ -15,12 +24,23 @@ export function SpotlightThreadModal(props: { thread: NostrLink; onClose?: () =>
|
||||
}
|
||||
};
|
||||
|
||||
if (!props.thread && !props.event) {
|
||||
throw new Error("SpotlightThreadModal requires either thread or event");
|
||||
}
|
||||
|
||||
const link = props.event ? NostrLink.fromEvent(props.event) : props.thread;
|
||||
|
||||
return (
|
||||
<Modal id="thread-overlay" onClose={onClose} bodyClassName={"flex flex-1"}>
|
||||
<ThreadContextWrapper link={props.thread}>
|
||||
<Modal className={props.className} onClose={onClose} bodyClassName={"flex flex-1"}>
|
||||
<ThreadContextWrapper link={link!}>
|
||||
<div className="flex flex-row h-screen w-screen">
|
||||
<div className="flex w-full md:w-2/3 items-center justify-center overflow-hidden" onClick={onClickBg}>
|
||||
<SpotlightFromThread onClose={onClose} />
|
||||
<SpotlightFromEvent
|
||||
event={props.event || thread.root}
|
||||
onClose={onClose}
|
||||
onNext={props.onNext}
|
||||
onPrev={props.onPrev}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:flex w-1/3 min-w-[400px] flex-shrink-0 overflow-y-auto bg-bg-color">
|
||||
<Thread onBack={onBack} disableSpotlight={true} />
|
||||
@ -31,13 +51,23 @@ export function SpotlightThreadModal(props: { thread: NostrLink; onClose?: () =>
|
||||
);
|
||||
}
|
||||
|
||||
function SpotlightFromThread({ onClose }: { onClose: () => void }) {
|
||||
const thread = useContext(ThreadContext);
|
||||
|
||||
const parsed = thread.root ? transformTextCached(thread.root.id, thread.root.content, thread.root.tags) : [];
|
||||
const images = parsed.filter(
|
||||
a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")),
|
||||
);
|
||||
if (images.length === 0) return;
|
||||
return <SpotlightMedia images={images.map(a => a.content)} idx={0} onClose={onClose} />;
|
||||
interface SpotlightFromEventProps {
|
||||
event: TaggedNostrEvent;
|
||||
onClose: () => void;
|
||||
onNext?: () => void;
|
||||
onPrev?: () => void;
|
||||
}
|
||||
|
||||
function SpotlightFromEvent({ event, onClose, onNext, onPrev }: SpotlightFromEventProps) {
|
||||
const media = getEventMedia(event);
|
||||
return (
|
||||
<SpotlightMedia
|
||||
className="w-full"
|
||||
media={media.map(a => a.content)}
|
||||
idx={0}
|
||||
onClose={onClose}
|
||||
onNext={onNext}
|
||||
onPrev={onPrev}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { HexKey, NostrPrefix } from "@snort/system";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
@ -9,6 +9,8 @@ import SemisolDevApi from "@/External/SemisolDev";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { hexToBech32 } from "@/SnortUtils";
|
||||
import { ErrorOrOffline } from "./ErrorOrOffline";
|
||||
import useCachedFetch from "@/Hooks/useCachedFetch";
|
||||
import TrendingUsers from "@/Element/Trending/TrendingUsers";
|
||||
|
||||
enum Provider {
|
||||
NostrBand = 1,
|
||||
@ -17,42 +19,45 @@ enum Provider {
|
||||
|
||||
export default function SuggestedProfiles() {
|
||||
const login = useLogin(s => ({ publicKey: s.publicKey, follows: s.follows.item }));
|
||||
const [userList, setUserList] = useState<HexKey[]>();
|
||||
const [provider, setProvider] = useState(Provider.NostrBand);
|
||||
const [error, setError] = useState<Error>();
|
||||
|
||||
async function loadSuggestedProfiles() {
|
||||
if (!login.publicKey) return;
|
||||
setUserList(undefined);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
switch (provider) {
|
||||
case Provider.NostrBand: {
|
||||
const api = new NostrBandApi();
|
||||
const users = await api.sugguestedFollows(hexToBech32(NostrPrefix.PublicKey, login.publicKey));
|
||||
const keys = users.profiles.map(a => a.pubkey);
|
||||
setUserList(keys);
|
||||
break;
|
||||
}
|
||||
case Provider.SemisolDev: {
|
||||
const api = new SemisolDevApi();
|
||||
const users = await api.sugguestedFollows(login.publicKey, login.follows);
|
||||
const keys = users.recommendations.sort(a => a[1]).map(a => a[0]);
|
||||
setUserList(keys);
|
||||
break;
|
||||
}
|
||||
const getUrlAndKey = () => {
|
||||
if (!login.publicKey) return { url: null, key: null };
|
||||
switch (provider) {
|
||||
case Provider.NostrBand: {
|
||||
const api = new NostrBandApi();
|
||||
const url = api.suggestedFollowsUrl(hexToBech32(NostrPrefix.PublicKey, login.publicKey));
|
||||
return { url, key: `nostr-band-${url}` };
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e);
|
||||
case Provider.SemisolDev: {
|
||||
const api = new SemisolDevApi();
|
||||
const url = api.suggestedFollowsUrl(login.publicKey, login.follows);
|
||||
return { url, key: `semisol-dev-${url}` };
|
||||
}
|
||||
default:
|
||||
return { url: null, key: null };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSuggestedProfiles();
|
||||
}, [login.publicKey, login.follows, provider]);
|
||||
const { url, key } = getUrlAndKey();
|
||||
const {
|
||||
data: userList,
|
||||
error,
|
||||
isLoading,
|
||||
} = useCachedFetch(url, key, data => {
|
||||
switch (provider) {
|
||||
case Provider.NostrBand:
|
||||
return data.profiles.map(a => a.pubkey);
|
||||
case Provider.SemisolDev:
|
||||
return data.recommendations.sort(a => a[1]).map(a => a[0]);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
if (error) return <ErrorOrOffline error={error} onRetry={() => {}} />;
|
||||
if (isLoading) return <PageSpinner />;
|
||||
if (userList.length === 0) return <TrendingUsers title={""} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -63,9 +68,7 @@ export default function SuggestedProfiles() {
|
||||
{/*<option value={Provider.SemisolDev}>semisol.dev</option>*/}
|
||||
</select>
|
||||
</div>
|
||||
{error && <ErrorOrOffline error={error} onRetry={loadSuggestedProfiles} />}
|
||||
{userList && <FollowListBase pubkeys={userList} showAbout={true} />}
|
||||
{!userList && !error && <PageSpinner />}
|
||||
<FollowListBase pubkeys={userList as HexKey[]} showAbout={true} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -39,7 +39,13 @@
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.gallery {
|
||||
border-radius: 0.125rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
@ -58,6 +64,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.gallery:not(:first-child),
|
||||
|
@ -1,6 +1,6 @@
|
||||
import "./Text.css";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { HexKey, ParsedFragment } from "@snort/system";
|
||||
import { HexKey, ParsedFragment, parseIMeta } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
|
||||
import Invoice from "@/Element/Embed/Invoice";
|
||||
@ -100,6 +100,7 @@ export default function Text({
|
||||
const elements = useTextTransformer(id, content, tags);
|
||||
|
||||
const images = elements.filter(a => a.type === "media" && a.mimeType?.startsWith("image")).map(a => a.content);
|
||||
const iMeta = parseIMeta(tags);
|
||||
|
||||
function renderContentWithHighlightedText(content: string, textToHighlight: string) {
|
||||
const textToHighlightArray = textToHighlight.trim().toLowerCase().split(" ");
|
||||
@ -119,12 +120,12 @@ export default function Text({
|
||||
|
||||
return (
|
||||
<>
|
||||
{fragments.map(f => {
|
||||
{fragments.map((f, index) => {
|
||||
if (typeof f === "string") {
|
||||
return f;
|
||||
}
|
||||
|
||||
return <HighlightedText content={f.content} />;
|
||||
return <HighlightedText key={index} content={f.content} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
@ -136,22 +137,26 @@ export default function Text({
|
||||
</a>
|
||||
);
|
||||
|
||||
const RevealMediaInstance = ({ content }: { content: string }) => (
|
||||
<RevealMedia
|
||||
key={content}
|
||||
link={content}
|
||||
creator={creator}
|
||||
onMediaClick={e => {
|
||||
if (!disableMediaSpotlight) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowSpotlight(true);
|
||||
const selected = images.findIndex(b => b === content);
|
||||
setImageIdx(selected === -1 ? 0 : selected);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const RevealMediaInstance = ({ content }: { content: string }) => {
|
||||
const imeta = iMeta?.[content];
|
||||
return (
|
||||
<RevealMedia
|
||||
key={content}
|
||||
link={content}
|
||||
creator={creator}
|
||||
meta={imeta}
|
||||
onMediaClick={e => {
|
||||
if (!disableMediaSpotlight) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowSpotlight(true);
|
||||
const selected = images.findIndex(b => b === content);
|
||||
setImageIdx(selected === -1 ? 0 : selected);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
let lenCtr = 0;
|
||||
@ -210,7 +215,7 @@ export default function Text({
|
||||
};
|
||||
});
|
||||
const gallery = (
|
||||
<div className="gallery">
|
||||
<div className="-mx-4 md:mx-0 my-2 gallery">
|
||||
{imagesWithGridConfig.map(img => (
|
||||
<div
|
||||
key={img.content}
|
||||
@ -278,7 +283,7 @@ export default function Text({
|
||||
return (
|
||||
<div dir="auto" className={classNames("text", className)} onClick={onClick}>
|
||||
{renderContent()}
|
||||
{showSpotlight && <SpotlightMediaModal images={images} onClose={() => setShowSpotlight(false)} idx={imageIdx} />}
|
||||
{showSpotlight && <SpotlightMediaModal media={images} onClose={() => setShowSpotlight(false)} idx={imageIdx} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ export default function ShortNote({ event }: { event: TaggedNostrEvent }) {
|
||||
return (
|
||||
<Link to={`/${NostrLink.fromEvent(event).encode(CONFIG.eventLinkPrefix)}`} className="flex flex-col">
|
||||
<div className="flex flex-row justify-between">
|
||||
<ProfileImage pubkey={event.pubkey} size={32} />
|
||||
<ProfileImage pubkey={event.pubkey} size={32} showProfileCard={true} />
|
||||
<NoteTime from={event.created_at * 1000} />
|
||||
</div>
|
||||
<div className="ml-10">
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import PageSpinner from "@/Element/PageSpinner";
|
||||
import { ReactNode } from "react";
|
||||
import NostrBandApi from "@/External/NostrBand";
|
||||
import { ErrorOrOffline } from "../ErrorOrOffline";
|
||||
import { HashTagHeader } from "@/Pages/HashTagsPage";
|
||||
import { useLocale } from "@/IntlProvider";
|
||||
import classNames from "classnames";
|
||||
import { Link } from "react-router-dom";
|
||||
import useCachedFetch from "@/Hooks/useCachedFetch";
|
||||
import PageSpinner from "@/Element/PageSpinner";
|
||||
|
||||
export default function TrendingHashtags({
|
||||
title,
|
||||
@ -17,38 +17,28 @@ export default function TrendingHashtags({
|
||||
count?: number;
|
||||
short?: boolean;
|
||||
}) {
|
||||
const [hashtags, setHashtags] = useState<Array<{ hashtag: string; posts: number }>>();
|
||||
const [error, setError] = useState<Error>();
|
||||
const { lang } = useLocale();
|
||||
const api = new NostrBandApi();
|
||||
const trendingHashtagsUrl = api.trendingHashtagsUrl(lang);
|
||||
const storageKey = `nostr-band-${trendingHashtagsUrl}`;
|
||||
|
||||
async function loadTrendingHashtags() {
|
||||
const api = new NostrBandApi();
|
||||
const rsp = await api.trendingHashtags(lang);
|
||||
setHashtags(rsp.hashtags.slice(0, count)); // Limit the number of hashtags to the count
|
||||
}
|
||||
const {
|
||||
data: hashtags,
|
||||
error,
|
||||
isLoading,
|
||||
} = useCachedFetch(trendingHashtagsUrl, storageKey, data => data.hashtags.slice(0, count));
|
||||
|
||||
useEffect(() => {
|
||||
loadTrendingHashtags().catch(e => {
|
||||
if (e instanceof Error) {
|
||||
setError(e);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (error) return <ErrorOrOffline error={error} onRetry={loadTrendingHashtags} className="p" />;
|
||||
if (!hashtags) return <PageSpinner />;
|
||||
if (error && !hashtags) return <ErrorOrOffline error={error} onRetry={() => {}} className="p" />;
|
||||
if (isLoading) return <PageSpinner />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
{hashtags.map(a => {
|
||||
if (short) {
|
||||
// return just the hashtag (not HashTagHeader) and post count
|
||||
return (
|
||||
<div className="my-1 font-bold" key={a.hashtag}>
|
||||
<Link to={`/t/${a.hashtag}`} key={a.hashtag}>
|
||||
#{a.hashtag}
|
||||
</Link>
|
||||
<Link to={`/t/${a.hashtag}`}>#{a.hashtag}</Link>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { useState } from "react";
|
||||
import { EventExt, NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { useReactions } from "@snort/system-react";
|
||||
|
||||
import PageSpinner from "@/Element/PageSpinner";
|
||||
@ -14,66 +14,79 @@ import { DisplayAs, DisplayAsSelector } from "@/Element/Feed/DisplayAsSelector";
|
||||
import ImageGridItem from "@/Element/Feed/ImageGridItem";
|
||||
import { SpotlightThreadModal } from "@/Element/Spotlight/SpotlightThreadModal";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import useCachedFetch from "@/Hooks/useCachedFetch";
|
||||
import { System } from "@/index";
|
||||
|
||||
export default function TrendingNotes({ count = Infinity, small = false }) {
|
||||
const api = new NostrBandApi();
|
||||
const { lang } = useLocale();
|
||||
const trendingNotesUrl = api.trendingNotesUrl(lang);
|
||||
const storageKey = `nostr-band-${trendingNotesUrl}`;
|
||||
|
||||
const {
|
||||
data: trendingNotesData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useCachedFetch(trendingNotesUrl, storageKey, data => {
|
||||
return data.notes.map(a => {
|
||||
const ev = a.event;
|
||||
const id = EventExt.createId(ev);
|
||||
if (!System.QueryOptimizer.schnorrVerify(id, ev.sig, ev.pubkey)) {
|
||||
console.error(`Event with invalid sig\n\n${ev}\n\nfrom ${trendingNotesUrl}`);
|
||||
return;
|
||||
}
|
||||
System.HandleEvent(ev);
|
||||
return ev;
|
||||
});
|
||||
});
|
||||
|
||||
const login = useLogin();
|
||||
const displayAsInitial = small ? "list" : login.feedDisplayAs ?? "list";
|
||||
// Added count prop with a default value
|
||||
const [posts, setPosts] = useState<Array<NostrEvent>>();
|
||||
const [error, setError] = useState<Error>();
|
||||
const { lang } = useLocale();
|
||||
const { isEventMuted } = useModeration();
|
||||
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
|
||||
const related = useReactions("trending", posts?.map(a => NostrLink.fromEvent(a)) ?? [], undefined, true);
|
||||
const { isEventMuted } = useModeration();
|
||||
const related = useReactions("trending", trendingNotesData?.map(a => NostrLink.fromEvent(a)) ?? [], undefined, true);
|
||||
const [modalThread, setModalThread] = useState<NostrLink | undefined>(undefined);
|
||||
|
||||
async function loadTrendingNotes() {
|
||||
const api = new NostrBandApi();
|
||||
const trending = await api.trendingNotes(lang);
|
||||
setPosts(trending.notes.map(a => a.event));
|
||||
}
|
||||
if (error && !trendingNotesData) return <ErrorOrOffline error={error} className="p" />;
|
||||
if (isLoading) return <PageSpinner />;
|
||||
|
||||
useEffect(() => {
|
||||
loadTrendingNotes().catch(e => {
|
||||
if (e instanceof Error) {
|
||||
setError(e);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (error) return <ErrorOrOffline error={error} onRetry={loadTrendingNotes} className="p" />;
|
||||
if (!posts) return <PageSpinner />;
|
||||
|
||||
// if small, render less stuff
|
||||
const options = {
|
||||
showFooter: !small,
|
||||
showReactionsLink: !small,
|
||||
showMedia: !small,
|
||||
longFormPreview: !small,
|
||||
truncate: small,
|
||||
showContextMenu: !small,
|
||||
};
|
||||
|
||||
const filteredAndLimitedPosts = () => {
|
||||
return posts.filter(a => !isEventMuted(a)).slice(0, count);
|
||||
};
|
||||
const filteredAndLimitedPosts = trendingNotesData
|
||||
? trendingNotesData.filter(a => !isEventMuted(a)).slice(0, count)
|
||||
: [];
|
||||
|
||||
const renderGrid = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-px md:gap-1">
|
||||
{filteredAndLimitedPosts().map(e => (
|
||||
<ImageGridItem event={e as TaggedNostrEvent} onClick={() => setModalThread(NostrLink.fromEvent(e))} />
|
||||
{filteredAndLimitedPosts.map(e => (
|
||||
<ImageGridItem
|
||||
key={e.id}
|
||||
event={e as TaggedNostrEvent}
|
||||
onClick={() => setModalThread(NostrLink.fromEvent(e))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderList = () => {
|
||||
return filteredAndLimitedPosts().map(e =>
|
||||
return filteredAndLimitedPosts.map(e =>
|
||||
small ? (
|
||||
<ShortNote event={e as TaggedNostrEvent} />
|
||||
<ShortNote key={e.id} event={e as TaggedNostrEvent} />
|
||||
) : (
|
||||
<Note data={e as TaggedNostrEvent} related={related?.data ?? []} depth={0} options={options} />
|
||||
<Note
|
||||
key={e.id}
|
||||
data={e as TaggedNostrEvent}
|
||||
related={related?.data ?? []}
|
||||
depth={0}
|
||||
options={{
|
||||
showFooter: !small,
|
||||
showReactionsLink: !small,
|
||||
showMedia: !small,
|
||||
longFormPreview: !small,
|
||||
truncate: small,
|
||||
showContextMenu: !small,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
);
|
||||
};
|
||||
|
@ -1,32 +1,29 @@
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { ReactNode } from "react";
|
||||
import { HexKey } from "@snort/system";
|
||||
|
||||
import FollowListBase from "@/Element/User/FollowListBase";
|
||||
import PageSpinner from "@/Element/PageSpinner";
|
||||
import NostrBandApi from "@/External/NostrBand";
|
||||
import { ErrorOrOffline } from "../ErrorOrOffline";
|
||||
import useCachedFetch from "@/Hooks/useCachedFetch";
|
||||
|
||||
export default function TrendingUsers({ title, count = Infinity }: { title?: ReactNode; count?: number }) {
|
||||
const [userList, setUserList] = useState<HexKey[]>();
|
||||
const [error, setError] = useState<Error>();
|
||||
const api = new NostrBandApi();
|
||||
const trendingProfilesUrl = api.trendingProfilesUrl();
|
||||
const storageKey = `nostr-band-${trendingProfilesUrl}`;
|
||||
|
||||
async function loadTrendingUsers() {
|
||||
const api = new NostrBandApi();
|
||||
const users = await api.trendingProfiles();
|
||||
const keys = users.profiles.map(a => a.pubkey).slice(0, count); // Limit the user list to the count
|
||||
setUserList(keys);
|
||||
const {
|
||||
data: trendingUsersData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useCachedFetch(trendingProfilesUrl, storageKey, data => data.profiles.map(a => a.pubkey));
|
||||
|
||||
if (error && !trendingUsersData) {
|
||||
return <ErrorOrOffline error={error} onRetry={() => {}} className="p" />;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTrendingUsers().catch(e => {
|
||||
if (e instanceof Error) {
|
||||
setError(e);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
if (isLoading) {
|
||||
return <PageSpinner />;
|
||||
}
|
||||
|
||||
if (error) return <ErrorOrOffline error={error} onRetry={loadTrendingUsers} className="p" />;
|
||||
if (!userList) return <PageSpinner />;
|
||||
|
||||
return <FollowListBase pubkeys={userList} showAbout={true} title={title} />;
|
||||
return <FollowListBase pubkeys={trendingUsersData.slice(0, count) as HexKey[]} showAbout={true} title={title} />;
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import "./Avatar.css";
|
||||
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import type { UserMetadata } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
|
||||
@ -31,10 +31,8 @@ const Avatar = ({
|
||||
className,
|
||||
showTitle = true,
|
||||
}: AvatarProps) => {
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setUrl(image ?? user?.picture ?? defaultAvatar(pubkey));
|
||||
const url = useMemo(() => {
|
||||
return image ?? user?.picture ?? defaultAvatar(pubkey);
|
||||
}, [user, image, pubkey]);
|
||||
|
||||
const s = size ?? 120;
|
||||
|
@ -6,7 +6,6 @@ import { FormattedMessage } from "react-intl";
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
import { ProxyImg } from "@/Element/ProxyImg";
|
||||
import Icon from "@/Icons/Icon";
|
||||
import Modal from "@/Element/Modal";
|
||||
import Username from "@/Element/User/Username";
|
||||
import { findTag } from "@/SnortUtils";
|
||||
@ -37,9 +36,7 @@ export default function BadgeList({ badges }: { badges: TaggedNostrEvent[] }) {
|
||||
{showModal && (
|
||||
<Modal id="badges" className="reactions-modal" onClose={() => setShowModal(false)}>
|
||||
<div className="reactions-view">
|
||||
<div className="close" onClick={() => setShowModal(false)}>
|
||||
<Icon name="close" />
|
||||
</div>
|
||||
<CloseButton className="absolute right-2 top-2" onClick={() => setShowModal(false)} />
|
||||
<div className="reactions-header">
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Badges" id="h8XMJL" />
|
||||
|
@ -7,7 +7,7 @@ import classNames from "classnames";
|
||||
|
||||
interface DisplayNameProps {
|
||||
pubkey: HexKey;
|
||||
user: UserMetadata | undefined;
|
||||
user?: UserMetadata | undefined;
|
||||
}
|
||||
|
||||
const DisplayName = ({ pubkey }: DisplayNameProps) => {
|
||||
|
@ -32,13 +32,14 @@ export default function FollowListBase({
|
||||
profileActions,
|
||||
}: FollowListBaseProps) {
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const { id, follows } = useLogin(s => ({ id: s.id, follows: s.follows }));
|
||||
const login = useLogin();
|
||||
|
||||
async function followAll() {
|
||||
if (publisher) {
|
||||
const newFollows = dedupe([...pubkeys, ...login.follows.item]);
|
||||
const newFollows = dedupe([...pubkeys, ...follows.item]);
|
||||
const ev = await publisher.contactList(newFollows.map(a => ["p", a]));
|
||||
setFollows(login, newFollows, ev.created_at);
|
||||
setFollows(id, newFollows, ev.created_at);
|
||||
await system.BroadcastEvent(ev);
|
||||
await FollowsFeed.backFill(system, pubkeys);
|
||||
}
|
||||
@ -57,7 +58,12 @@ export default function FollowListBase({
|
||||
)}
|
||||
<div className={className}>
|
||||
{pubkeys?.map(a => (
|
||||
<ProfilePreview pubkey={a} key={a} options={{ about: showAbout }} actions={profileActions?.(a)} />
|
||||
<ProfilePreview
|
||||
pubkey={a}
|
||||
key={a}
|
||||
options={{ about: showAbout, profileCards: true }}
|
||||
actions={profileActions?.(a)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -19,10 +19,6 @@ a.pfp {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pfp .username {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pfp .profile-name {
|
||||
max-width: stretch;
|
||||
max-width: -webkit-fill-available;
|
||||
|
@ -10,6 +10,8 @@ import DisplayName from "./DisplayName";
|
||||
import { ProfileLink } from "./ProfileLink";
|
||||
import { ProfileCard } from "./ProfileCard";
|
||||
import FollowDistanceIndicator from "@/Element/User/FollowDistanceIndicator";
|
||||
import { useCommunityLeader } from "@/Hooks/useCommunityLeaders";
|
||||
import { LeaderBadge } from "@/Element/CommunityLeaders/LeaderBadge";
|
||||
|
||||
export interface ProfileImageProps {
|
||||
pubkey: HexKey;
|
||||
@ -27,6 +29,7 @@ export interface ProfileImageProps {
|
||||
showFollowDistance?: boolean;
|
||||
icons?: ReactNode;
|
||||
showProfileCard?: boolean;
|
||||
showBadges?: boolean;
|
||||
}
|
||||
|
||||
export default function ProfileImage({
|
||||
@ -42,10 +45,12 @@ export default function ProfileImage({
|
||||
onClick,
|
||||
showFollowDistance = true,
|
||||
icons,
|
||||
showProfileCard = true,
|
||||
showProfileCard = false,
|
||||
showBadges = false,
|
||||
}: ProfileImageProps) {
|
||||
const user = useUserProfile(profile ? "" : pubkey) ?? profile;
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const leader = useCommunityLeader(pubkey);
|
||||
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@ -88,8 +93,9 @@ export default function ProfileImage({
|
||||
</div>
|
||||
{showUsername && (
|
||||
<div className="f-ellipsis">
|
||||
<div className="flex g4 username">
|
||||
<div className="flex gap-2 items-center font-medium">
|
||||
{overrideUsername ? overrideUsername : <DisplayName pubkey={pubkey} user={user} />}
|
||||
{leader && showBadges && CONFIG.features.communityLeaders && <LeaderBadge />}
|
||||
</div>
|
||||
<div className="subheader">{subHeader}</div>
|
||||
</div>
|
||||
|
@ -101,4 +101,9 @@ export default defineMessages({
|
||||
ReBroadcast: { defaultMessage: "Broadcast Again", id: "c3g2hL" },
|
||||
IrisUserNameLengthError: { defaultMessage: "Name must be between 1 and 32 characters", id: "4MBtMa" },
|
||||
IrisUserNameFormatError: { defaultMessage: "Username must only contain lowercase letters and numbers", id: "RSr2uB" },
|
||||
InvalidNip05Address: { defaultMessage: "Invalid Nostr Address", id: "P2o+ZZ" },
|
||||
ErrorValidatingNip05Address: { defaultMessage: "Cannot verify Nostr Address", id: "LmdPXO" },
|
||||
UserNameLengthError: { defaultMessage: "Name must be less than {limit} characters", id: "u9NoC1" },
|
||||
AboutLengthError: { defaultMessage: "About must be less than {limit} characters", id: "DrZqav" },
|
||||
InvalidLud16: { defaultMessage: "Invalid Lightning Address", id: "GqQeu/" },
|
||||
});
|
||||
|
81
packages/app/src/External/NostrBand.ts
vendored
81
packages/app/src/External/NostrBand.ts
vendored
@ -1,83 +1,20 @@
|
||||
import { throwIfOffline } from "@snort/shared";
|
||||
import { NostrEvent } from "@snort/system";
|
||||
|
||||
export interface TrendingUser {
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
export interface TrendingUserResponse {
|
||||
profiles: Array<TrendingUser>;
|
||||
}
|
||||
|
||||
export interface TrendingNote {
|
||||
event: NostrEvent;
|
||||
author: NostrEvent; // kind0 event
|
||||
}
|
||||
|
||||
export interface TrendingNoteResponse {
|
||||
notes: Array<TrendingNote>;
|
||||
}
|
||||
|
||||
export interface TrendingHashtagsResponse {
|
||||
hashtags: Array<{
|
||||
hashtag: string;
|
||||
posts: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SuggestedFollow {
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
export interface SuggestedFollowsResponse {
|
||||
profiles: Array<SuggestedFollow>;
|
||||
}
|
||||
|
||||
export class NostrBandError extends Error {
|
||||
body: string;
|
||||
statusCode: number;
|
||||
|
||||
constructor(message: string, body: string, status: number) {
|
||||
super(message);
|
||||
this.body = body;
|
||||
this.statusCode = status;
|
||||
}
|
||||
}
|
||||
|
||||
export default class NostrBandApi {
|
||||
readonly #url = "https://api.nostr.band";
|
||||
readonly #supportedLangs = ["en", "de", "ja", "zh", "th", "pt", "es", "fr"];
|
||||
async trendingProfiles() {
|
||||
return await this.#json<TrendingUserResponse>("GET", "/v0/trending/profiles");
|
||||
|
||||
trendingProfilesUrl() {
|
||||
return `${this.#url}/v0/trending/profiles`;
|
||||
}
|
||||
|
||||
async trendingNotes(lang?: string) {
|
||||
if (lang && this.#supportedLangs.includes(lang)) {
|
||||
return await this.#json<TrendingNoteResponse>("GET", `/v0/trending/notes?lang=${lang}`);
|
||||
}
|
||||
return await this.#json<TrendingNoteResponse>("GET", "/v0/trending/notes");
|
||||
trendingNotesUrl(lang?: string) {
|
||||
return `${this.#url}/v0/trending/notes${lang && this.#supportedLangs.includes(lang) ? `?lang=${lang}` : ""}`;
|
||||
}
|
||||
|
||||
async sugguestedFollows(pubkey: string) {
|
||||
return await this.#json<SuggestedFollowsResponse>("GET", `/v0/suggested/profiles/${pubkey}`);
|
||||
suggestedFollowsUrl(pubkey: string) {
|
||||
return `${this.#url}/v0/suggested/profiles/${pubkey}`;
|
||||
}
|
||||
|
||||
async trendingHashtags(lang?: string) {
|
||||
if (lang && this.#supportedLangs.includes(lang)) {
|
||||
return await this.#json<TrendingHashtagsResponse>("GET", `/v0/trending/hashtags?lang=${lang}`);
|
||||
}
|
||||
return await this.#json<TrendingHashtagsResponse>("GET", "/v0/trending/hashtags");
|
||||
}
|
||||
|
||||
async #json<T>(method: string, path: string) {
|
||||
throwIfOffline();
|
||||
const res = await fetch(`${this.#url}${path}`, {
|
||||
method: method ?? "GET",
|
||||
});
|
||||
if (res.ok) {
|
||||
return (await res.json()) as T;
|
||||
} else {
|
||||
throw new NostrBandError("Failed to load content from nostr.band", await res.text(), res.status);
|
||||
}
|
||||
trendingHashtagsUrl(lang?: string) {
|
||||
return `${this.#url}/v0/trending/hashtags${lang && this.#supportedLangs.includes(lang) ? `?lang=${lang}` : ""}`;
|
||||
}
|
||||
}
|
||||
|
44
packages/app/src/External/SemisolDev.ts
vendored
44
packages/app/src/External/SemisolDev.ts
vendored
@ -1,46 +1,8 @@
|
||||
import { throwIfOffline } from "@snort/shared";
|
||||
|
||||
export interface RecommendedProfilesResponse {
|
||||
quality: number;
|
||||
recommendations: Array<[pubkey: string, score: number]>;
|
||||
}
|
||||
|
||||
export class SemisolDevApiError extends Error {
|
||||
body: string;
|
||||
statusCode: number;
|
||||
|
||||
constructor(message: string, body: string, status: number) {
|
||||
super(message);
|
||||
this.body = body;
|
||||
this.statusCode = status;
|
||||
}
|
||||
}
|
||||
|
||||
export default class SemisolDevApi {
|
||||
readonly #url = "https://api.semisol.dev";
|
||||
|
||||
async sugguestedFollows(pubkey: string, follows: Array<string>) {
|
||||
return await this.#json<RecommendedProfilesResponse>("POST", "/nosgraph/v1/recommend", {
|
||||
pubkey,
|
||||
exclude: [],
|
||||
following: follows,
|
||||
});
|
||||
}
|
||||
|
||||
async #json<T>(method: string, path: string, body?: unknown) {
|
||||
throwIfOffline();
|
||||
const url = `${this.#url}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method: method ?? "GET",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
...(body ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
return (await res.json()) as T;
|
||||
} else {
|
||||
throw new SemisolDevApiError(`Failed to load content from ${url}`, await res.text(), res.status);
|
||||
}
|
||||
suggestedFollowsUrl(pubkey: string, follows: Array<string>) {
|
||||
const query = new URLSearchParams({ pubkey, follows: JSON.stringify(follows) });
|
||||
return `${this.#url}/nosgraph/v1/recommend?${query.toString()}`;
|
||||
}
|
||||
}
|
||||
|
6
packages/app/src/External/SnortApi.ts
vendored
6
packages/app/src/External/SnortApi.ts
vendored
@ -81,6 +81,8 @@ export interface RelayDistance {
|
||||
export interface RefCodeResponse {
|
||||
code: string;
|
||||
pubkey: string;
|
||||
revShare?: number;
|
||||
leaderState?: "pending" | "approved";
|
||||
}
|
||||
|
||||
export default class SnortApi {
|
||||
@ -148,6 +150,10 @@ export default class SnortApi {
|
||||
return this.#getJson<RefCodeResponse>(`api/v1/referral/${code}`, "GET");
|
||||
}
|
||||
|
||||
applyForLeader() {
|
||||
return this.#getJsonAuthd<RefCodeResponse>("api/v1/referral/leader-apply", "POST");
|
||||
}
|
||||
|
||||
async #getJsonAuthd<T>(
|
||||
path: string,
|
||||
method?: "GET" | string,
|
||||
|
@ -104,7 +104,7 @@ export default function useLoginFeed() {
|
||||
const contactList = getNewest(loginFeed.data.filter(a => a.kind === EventKind.ContactList));
|
||||
if (contactList) {
|
||||
const pTags = contactList.tags.filter(a => a[0] === "p").map(a => a[1]);
|
||||
setFollows(login, pTags, contactList.created_at * 1000);
|
||||
setFollows(login.id, pTags, contactList.created_at * 1000);
|
||||
|
||||
FollowsFeed.backFillIfMissing(system, pTags);
|
||||
}
|
||||
|
@ -1,43 +1,41 @@
|
||||
import Fuse from "fuse.js";
|
||||
import { socialGraphInstance } from "@snort/system";
|
||||
import { System } from ".";
|
||||
|
||||
export type FuzzySearchResult = {
|
||||
pubkey: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
nip05?: string;
|
||||
};
|
||||
|
||||
export const fuzzySearch = new Fuse<FuzzySearchResult>([], {
|
||||
keys: ["name", "username", { name: "nip05", weight: 0.5 }],
|
||||
const fuzzySearch = new Fuse<FuzzySearchResult>([], {
|
||||
keys: ["name", "display_name", { name: "nip05", weight: 0.5 }],
|
||||
threshold: 0.3,
|
||||
// sortFn here?
|
||||
});
|
||||
|
||||
const profileTimestamps = new Map<string, number>(); // is this somewhere in cache?
|
||||
|
||||
System.on("event", ev => {
|
||||
if (ev.kind === 0) {
|
||||
const existing = profileTimestamps.get(ev.pubkey);
|
||||
if (existing) {
|
||||
if (existing > ev.created_at) {
|
||||
return;
|
||||
}
|
||||
fuzzySearch.remove(doc => doc.pubkey === ev.pubkey);
|
||||
}
|
||||
profileTimestamps.set(ev.pubkey, ev.created_at);
|
||||
try {
|
||||
const data = JSON.parse(ev.content);
|
||||
if (ev.pubkey && (data.name || data.username || data.nip05)) {
|
||||
data.pubkey = ev.pubkey;
|
||||
fuzzySearch.add(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
export const addEventToFuzzySearch = ev => {
|
||||
if (ev.kind !== 0) {
|
||||
return;
|
||||
}
|
||||
if (ev.kind === 3) {
|
||||
socialGraphInstance.handleFollowEvent(ev);
|
||||
const existing = profileTimestamps.get(ev.pubkey);
|
||||
if (existing) {
|
||||
if (existing > ev.created_at) {
|
||||
return;
|
||||
}
|
||||
fuzzySearch.remove(doc => doc.pubkey === ev.pubkey);
|
||||
}
|
||||
});
|
||||
profileTimestamps.set(ev.pubkey, ev.created_at);
|
||||
try {
|
||||
const data = JSON.parse(ev.content);
|
||||
if (ev.pubkey && (data.name || data.display_name || data.nip05)) {
|
||||
data.pubkey = ev.pubkey;
|
||||
fuzzySearch.add(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
export default fuzzySearch;
|
||||
|
46
packages/app/src/Hooks/useCachedFetch.ts
Normal file
46
packages/app/src/Hooks/useCachedFetch.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
const useCachedFetch = (url, storageKey, dataProcessor = data => data) => {
|
||||
const cachedData = useMemo(() => {
|
||||
const cached = localStorage.getItem(storageKey);
|
||||
return cached ? JSON.parse(cached) : null;
|
||||
}, [storageKey]);
|
||||
|
||||
const initialData = cachedData ? cachedData.data : null;
|
||||
const [data, setData] = useState(initialData);
|
||||
const [isLoading, setIsLoading] = useState(!cachedData);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
const fetchedData = await res.json();
|
||||
const processedData = dataProcessor(fetchedData);
|
||||
setData(processedData);
|
||||
localStorage.setItem(storageKey, JSON.stringify({ data: processedData, timestamp: new Date().getTime() }));
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
if (cachedData?.data) {
|
||||
setData(cachedData.data);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!cachedData || (new Date().getTime() - cachedData.timestamp) / 1000 / 60 >= 15) {
|
||||
fetchData();
|
||||
}
|
||||
}, [url, storageKey]);
|
||||
|
||||
return { data, isLoading, error };
|
||||
};
|
||||
|
||||
export default useCachedFetch;
|
41
packages/app/src/Hooks/useCommunityLeaders.tsx
Normal file
41
packages/app/src/Hooks/useCommunityLeaders.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { ExternalStore, unwrap } from "@snort/shared";
|
||||
import { EventKind, parseNostrLink } from "@snort/system";
|
||||
import { useLinkList } from "./useLists";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
|
||||
class CommunityLeadersStore extends ExternalStore<Array<string>> {
|
||||
#leaders: Array<string> = [];
|
||||
|
||||
setLeaders(arr: Array<string>) {
|
||||
this.#leaders = arr;
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
takeSnapshot(): string[] {
|
||||
return [...this.#leaders];
|
||||
}
|
||||
}
|
||||
|
||||
const LeadersStore = new CommunityLeadersStore();
|
||||
|
||||
export function useCommunityLeaders() {
|
||||
const link = parseNostrLink(unwrap(CONFIG.communityLeaders).list);
|
||||
|
||||
const list = useLinkList("leaders", rb => {
|
||||
rb.withFilter().kinds([EventKind.FollowSet]).link(link);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("CommunityLeaders", list);
|
||||
LeadersStore.setLeaders(list.map(a => a.id));
|
||||
}, [list]);
|
||||
}
|
||||
|
||||
export function useCommunityLeader(pubkey?: string) {
|
||||
const store = useSyncExternalStore(
|
||||
c => LeadersStore.hook(c),
|
||||
() => LeadersStore.snapshot(),
|
||||
);
|
||||
|
||||
return pubkey && store.includes(pubkey);
|
||||
}
|
@ -13,11 +13,11 @@ export default function useImgProxy() {
|
||||
const settings = useLogin(s => s.appData.item.preferences.imgProxyConfig);
|
||||
|
||||
return {
|
||||
proxy: (url: string, resize?: number) => proxyImg(url, settings, resize),
|
||||
proxy: (url: string, resize?: number, sha256?: string) => proxyImg(url, settings, resize, sha256),
|
||||
};
|
||||
}
|
||||
|
||||
export function proxyImg(url: string, settings?: ImgProxySettings, resize?: number) {
|
||||
export function proxyImg(url: string, settings?: ImgProxySettings, resize?: number, sha256?: string) {
|
||||
const te = new TextEncoder();
|
||||
function urlSafe(s: string) {
|
||||
return s.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
@ -33,10 +33,17 @@ export function proxyImg(url: string, settings?: ImgProxySettings, resize?: numb
|
||||
}
|
||||
if (!settings) return url;
|
||||
if (url.startsWith("data:") || url.startsWith("blob:") || url.length == 0) return url;
|
||||
const opt = resize ? `rs:fit:${resize}:${resize}/dpr:${window.devicePixelRatio}` : "";
|
||||
const opts = [];
|
||||
if (sha256) {
|
||||
opts.push(`hs:sha256:${sha256}`);
|
||||
}
|
||||
if (resize) {
|
||||
opts.push(`rs:fit:${resize}:${resize}`);
|
||||
opts.push(`dpr:${window.devicePixelRatio}`);
|
||||
}
|
||||
const urlBytes = te.encode(url);
|
||||
const urlEncoded = urlSafe(base64.encode(urlBytes));
|
||||
const path = `/${opt}/${urlEncoded}`;
|
||||
const path = `/${opts.join("/")}/${urlEncoded}`;
|
||||
const sig = signUrl(path);
|
||||
return `${new URL(settings.url).toString()}${sig}${path}`;
|
||||
}
|
||||
|
34
packages/app/src/Hooks/useRates.tsx
Normal file
34
packages/app/src/Hooks/useRates.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { bech32ToHex } from "@snort/shared";
|
||||
import { EventKind, ReplaceableNoteStore, RequestBuilder } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
// Snort backend publishes rates
|
||||
const SnortPubkey = "npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
|
||||
|
||||
export function useRates(symbol: string, leaveOpen = true) {
|
||||
const sub = useMemo(() => {
|
||||
const rb = new RequestBuilder(`rates:${symbol}`);
|
||||
rb.withOptions({
|
||||
leaveOpen,
|
||||
});
|
||||
rb.withFilter()
|
||||
.kinds([1009 as EventKind])
|
||||
.authors([bech32ToHex(SnortPubkey)])
|
||||
.tag("d", [symbol])
|
||||
.limit(1);
|
||||
return rb;
|
||||
}, [symbol]);
|
||||
|
||||
const data = useRequestBuilder(ReplaceableNoteStore, sub);
|
||||
|
||||
const tag = data?.data?.tags.find(a => a[0] === "d" && a[1] === symbol);
|
||||
if (!tag) return undefined;
|
||||
return {
|
||||
time: data.data?.created_at,
|
||||
ask: Number(tag[2]),
|
||||
bid: Number(tag[3]),
|
||||
low: Number(tag[4]),
|
||||
hight: Number(tag[5]),
|
||||
};
|
||||
}
|
75
packages/app/src/Icons/Alby.tsx
Normal file
75
packages/app/src/Icons/Alby.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
export default function AlbyIcon(props: { size?: number }) {
|
||||
return (
|
||||
<svg width={props.size ?? 400} height={props.size ?? 578} viewBox="0 0 400 578" fill="none">
|
||||
<path
|
||||
opacity="0.1"
|
||||
d="M201.283 577.511C255.405 577.511 299.281 569.411 299.281 559.419C299.281 549.427 255.405 541.327 201.283 541.327C147.16 541.327 103.285 549.427 103.285 559.419C103.285 569.411 147.16 577.511 201.283 577.511Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M295.75 471.344C346.377 471.344 369.42 359.242 369.42 316.736C369.42 283.606 346.56 263.528 316.507 263.528C286.641 263.528 262.394 276.371 262.093 292.275C262.092 334.246 254.705 471.344 295.75 471.344Z"
|
||||
fill="white"
|
||||
stroke="black"
|
||||
stroke-width="15.0766"
|
||||
/>
|
||||
<path
|
||||
d="M110.837 471.344C60.2098 471.344 37.1665 359.242 37.1665 316.736C37.1665 283.606 60.0269 263.528 90.0803 263.528C119.946 263.528 144.193 276.371 144.494 292.275C144.495 334.246 151.882 471.344 110.837 471.344Z"
|
||||
fill="white"
|
||||
stroke="black"
|
||||
stroke-width="15.0766"
|
||||
/>
|
||||
<path
|
||||
d="M68.8309 303.262L68.8307 303.26C68.7764 302.741 68.8817 302.44 68.9894 302.244C69.1165 302.012 69.3578 301.736 69.7632 301.506C70.6022 301.029 71.7772 300.943 72.8713 301.582C110.474 323.624 153.847 336.26 201.001 336.26C248.164 336.26 292.34 323.379 330.185 300.953C331.272 300.308 332.445 300.388 333.287 300.862C333.694 301.091 333.937 301.366 334.066 301.599C334.175 301.796 334.282 302.098 334.229 302.618C328.375 360.632 296.907 408.595 254.611 430.672C240.642 437.965 231.035 450.634 222.598 461.761C222.447 461.961 222.296 462.16 222.146 462.358L222.144 462.36C215.287 471.406 209.081 479.507 201.496 485.476C193.912 479.507 187.705 471.406 180.848 462.36L180.847 462.358C180.697 462.16 180.546 461.961 180.395 461.761C171.958 450.634 162.352 437.965 148.382 430.672C106.247 408.68 74.8589 360.995 68.8309 303.262Z"
|
||||
fill="#FFDF6F"
|
||||
stroke="black"
|
||||
stroke-width="15"
|
||||
/>
|
||||
<path
|
||||
d="M201.786 346.338C275.06 346.338 334.46 326.538 334.46 302.113C334.46 277.688 275.06 257.888 201.786 257.888C128.512 257.888 69.1118 277.688 69.1118 302.113C69.1118 326.538 128.512 346.338 201.786 346.338Z"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="15.0766"
|
||||
/>
|
||||
<path
|
||||
d="M95.2446 376.491C95.2446 376.491 160.685 398.603 202.791 398.603C244.896 398.603 310.337 376.491 310.337 376.491"
|
||||
stroke="black"
|
||||
stroke-width="15.0766"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M77 143C60.4315 143 47 129.569 47 113C47 96.4315 60.4315 83 77 83C93.5685 83 107 96.4315 107 113C107 129.569 93.5685 143 77 143Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path d="M72 108.5L128 164.5" stroke="black" stroke-width="15" />
|
||||
<path
|
||||
d="M322 143C338.569 143 352 129.569 352 113C352 96.4315 338.569 83 322 83C305.431 83 292 96.4315 292 113C292 129.569 305.431 143 322 143Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path d="M327.5 108.5L271.5 164.5" stroke="black" stroke-width="15" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M85.5155 292.019C69.3466 284.321 59.9364 267.036 63.0886 249.407C76.6177 173.747 133 117 200.5 117C268.163 117 324.655 174.023 338.009 249.958C341.115 267.618 331.628 284.895 315.404 292.53C280.687 308.868 241.91 318 201 318C159.665 318 120.507 308.677 85.5155 292.019Z"
|
||||
fill="#FFDF6F"
|
||||
/>
|
||||
<path
|
||||
d="M70.4715 250.728C83.5443 177.62 137.582 124.5 200.5 124.5V109.5C128.418 109.5 69.6912 169.875 55.7057 248.087L70.4715 250.728ZM200.5 124.5C263.569 124.5 317.718 177.879 330.622 251.257L345.396 248.659C331.592 170.166 272.758 109.5 200.5 109.5V124.5ZM312.21 285.744C278.472 301.621 240.783 310.5 201 310.5V325.5C243.037 325.5 282.902 316.114 318.597 299.317L312.21 285.744ZM201 310.5C160.804 310.5 122.745 301.436 88.7393 285.247L82.2918 298.791C118.269 315.918 158.526 325.5 201 325.5V310.5ZM330.622 251.257C333.112 265.416 325.531 279.476 312.21 285.744L318.597 299.317C337.725 290.315 349.117 269.82 345.396 248.659L330.622 251.257ZM55.7057 248.087C51.9285 269.211 63.2298 289.716 82.2918 298.791L88.7393 285.247C75.4633 278.927 67.9443 264.86 70.4715 250.728L55.7057 248.087Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M114.365 273.209C101.35 267.908 93.6293 254.06 98.1392 240.75C112.047 199.704 152.618 170 200.5 170C248.382 170 288.953 199.704 302.861 240.75C307.371 254.06 299.65 267.908 286.635 273.209C260.053 284.035 230.973 290 200.5 290C170.027 290 140.947 284.035 114.365 273.209Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M235 254C248.807 254 260 245.046 260 234C260 222.954 248.807 214 235 214C221.193 214 210 222.954 210 234C210 245.046 221.193 254 235 254Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M163.432 254.012C177.239 254.012 188.432 245.058 188.432 234.012C188.432 222.966 177.239 214.012 163.432 214.012C149.625 214.012 138.432 222.966 138.432 234.012C138.432 245.058 149.625 254.012 163.432 254.012Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
48
packages/app/src/Icons/Cashu.tsx
Normal file
48
packages/app/src/Icons/Cashu.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
export default function CashuIcon(props: { size?: number }) {
|
||||
return (
|
||||
<svg width={props.size ?? 135} height={props.size ?? 153} viewBox="0 0 135 153">
|
||||
<path
|
||||
d="m 18,0 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 V 8 7 6 5 4 3 2 1 0 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 0,9 H 17 16 15 14 13 12 11 10 9 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 V 17 16 15 14 13 12 11 10 Z M 9,18 H 8 7 6 5 4 3 2 1 0 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 H 1 2 3 4 5 6 7 8 9 V 44 43 42 41 40 39 38 37 36 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 Z M 0,53 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 H 8 7 6 V 60 59 58 57 H 5 4 3 V 56 55 54 53 H 2 1 Z m 9,55 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,18 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,9 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,9 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 81,0 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v 1 1 1 1 1 1 1 1 z"
|
||||
style={{
|
||||
fill: "#b89563",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="m 36,0 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 V 8 7 6 5 4 3 2 1 0 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 45,9 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 V 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 h -1 -1 -1 -1 -1 -1 -1 -1 z m 0,27 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 V 44 43 42 41 40 39 38 37 Z M 63,64 v 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,8 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,18 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 36,9 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,9 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z"
|
||||
style={{
|
||||
fill: "#e2d2b3",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="m 18,9 v 1 1 1 1 1 1 1 1 1 H 17 16 15 14 13 12 11 10 9 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h 1 1 1 1 1 1 1 1 1 V 17 16 15 14 13 12 11 10 9 H 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 Z M 9,61 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 z"
|
||||
style={{
|
||||
fill: "#c5a77f",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="m 36,9 v 1 1 1 1 1 1 1 1 1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h 1 1 1 1 1 1 1 1 1 V 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 9,40 v 1 1 1 1 1 1 h -1 -1 v 1 1 1 h -1 -1 -1 v 1 1 1 h -1 -1 -1 -1 v 1 1 1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 v -1 -1 -1 h -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 z"
|
||||
style={{
|
||||
fill: "#dbbf98",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="m 0,45 v 1 1 1 1 1 1 1 1 h 1 1 1 v 1 1 1 1 h 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 h 1 1 1 1 v -1 -1 -1 h 1 1 1 v -1 -1 -1 h 1 1 v -1 -1 -1 -1 -1 -1 h 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 v 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 h 1 1 1 1 v -1 -1 -1 -1 h 1 1 1 1 v -1 -1 -1 -1 h 1 1 1 1 V 52 51 50 49 48 47 46 45 H 95 94 93 92 91 90 89 88 87 86 85 84 83 82 81 80 79 78 77 76 75 74 73 72 71 70 69 68 67 66 65 64 63 62 61 60 59 58 57 56 55 54 53 52 51 50 49 48 47 46 45 44 43 42 41 40 39 38 37 36 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 Z m 6,4 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h -1 -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 1 H 17 16 15 14 V 60 59 58 57 H 13 12 11 10 V 56 55 54 53 H 9 8 7 6 v -1 -1 -1 z m 8,8 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 z m 44,-8 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h -1 -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 1 h -1 -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v -1 -1 -1 z m 8,8 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 z"
|
||||
style={{
|
||||
fill: "#000000",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="m 6,49 v 1 1 1 1 h 1 1 1 1 V 52 51 50 49 H 9 8 7 Z m 4,4 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,0 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 z m 4,0 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,4 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m -4,0 h -1 -1 -1 -1 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 z m 40,-8 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,4 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,0 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 z m 4,0 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,4 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m -4,0 h -1 -1 -1 -1 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 z"
|
||||
style={{
|
||||
fill: "#ffffff",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="m 99,99 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z"
|
||||
style={{
|
||||
fill: "#f7f8f3",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
@ -12,7 +12,7 @@ import { unixNowMs } from "@snort/shared";
|
||||
import * as secp from "@noble/curves/secp256k1";
|
||||
import * as utils from "@noble/curves/abstract/utils";
|
||||
|
||||
import { Blasters, SnortPubKey } from "@/Const";
|
||||
import { Blasters } from "@/Const";
|
||||
import { LoginStore, UserPreferences, LoginSession, LoginSessionType, SnortAppData, Newest } from "@/Login";
|
||||
import { generateBip39Entropy, entropyToPrivateKey } from "@/nip6";
|
||||
import { bech32ToHex, dedupeById, deleteRefCode, getCountry, sanitizeRelayUrl, unwrap } from "@/SnortUtils";
|
||||
@ -124,7 +124,8 @@ export async function generateNewLogin(
|
||||
const publisher = EventPublisher.privateKey(privateKey);
|
||||
|
||||
// Create new contact list following self and site account
|
||||
const ev = await publisher.contactList([bech32ToHex(SnortPubKey), publicKey].map(a => ["p", a]));
|
||||
const contactList = [publicKey, ...CONFIG.signUp.defaultFollows.map(a => bech32ToHex(a))].map(a => ["p", a]);
|
||||
const ev = await publisher.contactList(contactList);
|
||||
system.BroadcastEvent(ev);
|
||||
|
||||
// Create relay metadata event
|
||||
@ -176,13 +177,15 @@ export function setBlocked(state: LoginSession, blocked: Array<string>, ts: numb
|
||||
LoginStore.updateSession(state);
|
||||
}
|
||||
|
||||
export function setFollows(state: LoginSession, follows: Array<string>, ts: number) {
|
||||
if (state.follows.timestamp >= ts) {
|
||||
return;
|
||||
export function setFollows(id: string, follows: Array<string>, ts: number) {
|
||||
const session = LoginStore.get(id);
|
||||
if (session) {
|
||||
if (ts > session.follows.timestamp) {
|
||||
session.follows.item = follows;
|
||||
session.follows.timestamp = ts;
|
||||
LoginStore.updateSession(session);
|
||||
}
|
||||
}
|
||||
state.follows.item = follows;
|
||||
state.follows.timestamp = ts;
|
||||
LoginStore.updateSession(state);
|
||||
}
|
||||
|
||||
export function setPinned(state: LoginSession, pinned: Array<string>, ts: number) {
|
||||
|
@ -3,9 +3,8 @@ import * as utils from "@noble/curves/abstract/utils";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { HexKey, RelaySettings, EventPublisher, KeyStorage, NotEncrypted, socialGraphInstance } from "@snort/system";
|
||||
import { deepClone, sanitizeRelayUrl, unwrap, ExternalStore } from "@snort/shared";
|
||||
import { deepClone, unwrap, ExternalStore } from "@snort/shared";
|
||||
|
||||
import { DefaultRelays } from "@/Const";
|
||||
import { LoginSession, LoginSessionType, createPublisher } from "@/Login";
|
||||
import { DefaultPreferences, UserPreferences } from "./Preferences";
|
||||
|
||||
@ -39,7 +38,7 @@ const LoggedOut = {
|
||||
timestamp: 0,
|
||||
},
|
||||
relays: {
|
||||
item: Object.fromEntries([...DefaultRelays.entries()].map(a => [unwrap(sanitizeRelayUrl(a[0])), a[1]])),
|
||||
item: CONFIG.defaultRelays,
|
||||
timestamp: 0,
|
||||
},
|
||||
latestNotification: 0,
|
||||
@ -49,6 +48,7 @@ const LoggedOut = {
|
||||
item: {
|
||||
mutedWords: [],
|
||||
preferences: DefaultPreferences,
|
||||
showContentWarningPosts: false,
|
||||
},
|
||||
timestamp: 0,
|
||||
},
|
||||
@ -179,7 +179,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
if (relays && Object.keys(relays).length > 0) {
|
||||
return relays;
|
||||
}
|
||||
return Object.fromEntries(DefaultRelays.entries());
|
||||
return CONFIG.defaultRelays;
|
||||
}
|
||||
|
||||
loginWithPrivateKey(key: KeyStorage, entropy?: string, relays?: Record<string, RelaySettings>) {
|
||||
|
@ -114,6 +114,6 @@ export const DefaultPreferences = {
|
||||
telemetry: true,
|
||||
showBadges: false,
|
||||
showStatus: true,
|
||||
checkSigs: false,
|
||||
checkSigs: true,
|
||||
autoTranslate: true,
|
||||
} as UserPreferences;
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { TaggedNostrEvent, EventKind, MetadataCache } from "@snort/system";
|
||||
import { TaggedNostrEvent, EventKind, MetadataCache, EventPublisher } from "@snort/system";
|
||||
import { MentionRegex } from "@/Const";
|
||||
import { defaultAvatar, tagFilterOfTextRepost, getDisplayName } from "@/SnortUtils";
|
||||
import { UserCache } from "@/Cache";
|
||||
import { LoginSession } from "@/Login";
|
||||
import { removeUndefined } from "@snort/shared";
|
||||
import { removeUndefined, unwrap } from "@snort/shared";
|
||||
import SnortApi from "@/External/SnortApi";
|
||||
import { base64 } from "@scure/base";
|
||||
|
||||
export interface NotificationRequest {
|
||||
title: string;
|
||||
@ -69,3 +71,37 @@ export async function sendNotification(state: LoginSession, req: NotificationReq
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribeToNotifications(publisher: EventPublisher) {
|
||||
// request permissions to send notifications
|
||||
if ("Notification" in window) {
|
||||
try {
|
||||
if (Notification.permission !== "granted") {
|
||||
const res = await Notification.requestPermission();
|
||||
console.debug(res);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
if ("serviceWorker" in navigator) {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
if (reg && publisher) {
|
||||
const api = new SnortApi(undefined, publisher);
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: (await api.getPushNotificationInfo()).publicKey,
|
||||
});
|
||||
await api.registerPushNotifications({
|
||||
endpoint: sub.endpoint,
|
||||
p256dh: base64.encode(new Uint8Array(unwrap(sub.getKey("p256dh")))),
|
||||
auth: base64.encode(new Uint8Array(unwrap(sub.getKey("auth")))),
|
||||
scope: `${location.protocol}//${location.hostname}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
@ -71,7 +71,7 @@ export function SnortDeckLayout() {
|
||||
id="IOu4Xh"
|
||||
values={{
|
||||
app: CONFIG.appNameCapitalized,
|
||||
tier: mapPlanName(CONFIG.deckSubKind),
|
||||
tier: mapPlanName(CONFIG.deckSubKind ?? -1),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -123,9 +123,9 @@ export function SnortDeckLayout() {
|
||||
{deckState.article && (
|
||||
<>
|
||||
<Modal
|
||||
id="thread-overlay-article"
|
||||
id="deck-article"
|
||||
onClose={() => setDeckState({})}
|
||||
className="thread-overlay long-form"
|
||||
className="long-form"
|
||||
onClick={() => setDeckState({})}>
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
<LongFormText ev={deckState.article} isPreview={false} related={[]} />
|
||||
@ -144,11 +144,11 @@ function NotesCol() {
|
||||
return (
|
||||
<div>
|
||||
<div className="deck-col-header flex">
|
||||
<div className="flex f-1 g8">
|
||||
<div className="flex flex-1 g8">
|
||||
<Icon name="rows-01" size={24} />
|
||||
<FormattedMessage defaultMessage="Notes" id="7+Domh" />
|
||||
</div>
|
||||
<div className="f-1">
|
||||
<div className="flex-1">
|
||||
<RootTabs base="/deck" />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,17 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/system";
|
||||
|
||||
import { ApiHost, DeveloperAccounts, SnortPubKey } from "@/Const";
|
||||
import ProfilePreview from "@/Element/User/ProfilePreview";
|
||||
import ZapButton from "@/Element/Event/ZapButton";
|
||||
import { bech32ToHex } from "@/SnortUtils";
|
||||
import { bech32ToHex, unwrap } from "@/SnortUtils";
|
||||
import SnortApi, { RevenueSplit, RevenueToday } from "@/External/SnortApi";
|
||||
import Modal from "@/Element/Modal";
|
||||
import AsyncButton from "@/Element/Button/AsyncButton";
|
||||
import QrCode from "@/Element/QrCode";
|
||||
import Copy from "@/Element/Copy";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ZapPoolController, ZapPoolRecipientType } from "@/ZapPoolController";
|
||||
import { ZapPoolTarget } from "./ZapPool";
|
||||
|
||||
const Contributors = [
|
||||
bech32ToHex("npub10djxr5pvdu97rjkde7tgcsjxzpdzmdguwacfjwlchvj7t88dl7nsdl54nf"), // ivan
|
||||
@ -59,6 +61,10 @@ const DonatePage = () => {
|
||||
const [today, setSumToday] = useState<RevenueToday>();
|
||||
const [onChain, setOnChain] = useState("");
|
||||
const api = new SnortApi(ApiHost);
|
||||
const zapPool = useSyncExternalStore(
|
||||
c => unwrap(ZapPoolController).hook(c),
|
||||
() => unwrap(ZapPoolController).snapshot(),
|
||||
);
|
||||
|
||||
async function getOnChainAddress() {
|
||||
const { address } = await api.onChainDonation();
|
||||
@ -95,14 +101,11 @@ const DonatePage = () => {
|
||||
</h2>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="{site} is an open source project built by passionate people in their free time"
|
||||
id="6TfgXX"
|
||||
defaultMessage="{site} is an open source project built by passionate people in their free time, your donations are greatly appreciated"
|
||||
id="XhpBfA"
|
||||
values={{ site: CONFIG.appNameCapitalized }}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Your donations are greatly appreciated" id="nn1qb3" />
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="Check out the code here: {link}"
|
||||
@ -130,10 +133,9 @@ const DonatePage = () => {
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="Each contributor will get paid a percentage of all donations and NIP-05 orders, you can see the split amounts below"
|
||||
id="mH91FY"
|
||||
/>
|
||||
<a href="https://t.me/irismessenger" target="_blank" rel="noreferrer" className="underline">
|
||||
Telegram
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex flex-col g12">
|
||||
<div className="b br p">
|
||||
@ -173,6 +175,34 @@ const DonatePage = () => {
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
{CONFIG.features.zapPool && (
|
||||
<>
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="ZapPool" id="pRess9" />
|
||||
</h3>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="Fund the services that you use by splitting a portion of all your zaps into a pool of funds!"
|
||||
id="x/Fx2P"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Link to="/zap-pool" className="underline">
|
||||
<FormattedMessage defaultMessage="Configure zap pool" id="kqPQJD" />
|
||||
</Link>
|
||||
</p>
|
||||
<ZapPoolTarget
|
||||
target={
|
||||
zapPool.find(b => b.pubkey === bech32ToHex(SnortPubKey) && b.type === ZapPoolRecipientType.Generic) ?? {
|
||||
type: ZapPoolRecipientType.Generic,
|
||||
pubkey: bech32ToHex(SnortPubKey),
|
||||
split: 0,
|
||||
sum: 0,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Primary Developers" id="4IPzdn" />
|
||||
</h3>
|
||||
|
@ -49,8 +49,8 @@ const Footer = () => {
|
||||
return (
|
||||
<footer className="md:hidden fixed bottom-0 z-10 w-full bg-base-200 pb-safe-area bg-bg-color">
|
||||
<div className="flex">
|
||||
{MENU_ITEMS.map(item => (
|
||||
<FooterNavItem item={item} readonly={readonly} />
|
||||
{MENU_ITEMS.map((item, index) => (
|
||||
<FooterNavItem key={index} item={item} readonly={readonly} />
|
||||
))}
|
||||
{publicKey && (
|
||||
<ProfileLink
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
import { LogoHeader } from "@/Pages/Layout/LogoHeader";
|
||||
import { rootTabItems, RootTabs } from "@/Element/Feed/RootTabs";
|
||||
@ -16,7 +16,15 @@ export function Header() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const pageName = decodeURIComponent(location.pathname.split("/")[1]);
|
||||
const [nostrLink, setNostrLink] = useState<NostrLink | undefined>();
|
||||
|
||||
const nostrLink = useMemo(() => {
|
||||
try {
|
||||
return parseNostrLink(pageName);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}, [pageName]);
|
||||
|
||||
const { publicKey, tags } = useLogin();
|
||||
|
||||
const isRootTab = useMemo(() => {
|
||||
@ -27,14 +35,6 @@ export function Header() {
|
||||
window.scrollTo({ top: 0, behavior: "instant" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setNostrLink(parseNostrLink(pageName));
|
||||
} catch (e) {
|
||||
setNostrLink(undefined);
|
||||
}
|
||||
}, [pageName]);
|
||||
|
||||
const handleBackButtonClick = () => {
|
||||
const idx = window.history.state?.idx;
|
||||
if (idx === undefined || idx > 0) {
|
||||
@ -71,7 +71,7 @@ export function Header() {
|
||||
<header
|
||||
className={classNames(
|
||||
{ "md:hidden": pageName === "messages" },
|
||||
"flex justify-between items-center self-stretch gap-6 sticky top-0 z-10 bg-bg-color md:bg-header md:bg-opacity-50 md:shadow-lg md:backdrop-blur-lg",
|
||||
"flex justify-between items-center self-stretch gap-6 sticky top-0 z-10 bg-bg-color md:bg-header md:bg-opacity-50 md:backdrop-blur-lg",
|
||||
)}>
|
||||
<div
|
||||
onClick={handleBackButtonClick}
|
||||
@ -87,7 +87,7 @@ export function Header() {
|
||||
{!isRootTab && (
|
||||
<div
|
||||
onClick={scrollUp}
|
||||
className="cursor-pointer flex-1 text-center p-2 overflow-hidden whitespace-nowrap truncate">
|
||||
className="cursor-pointer flex-1 text-center p-2 overflow-hidden whitespace-nowrap truncate md:text-lg">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,16 +1,21 @@
|
||||
import { LogoHeader } from "./LogoHeader";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Icon from "@/Icons/Icon";
|
||||
import { ProfileLink } from "../../Element/User/ProfileLink";
|
||||
import Avatar from "../../Element/User/Avatar";
|
||||
import useLogin from "../../Hooks/useLogin";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { NoteCreatorButton } from "../../Element/Event/Create/NoteCreatorButton";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
|
||||
import classNames from "classnames";
|
||||
import { getCurrentSubscription } from "@/Subscription";
|
||||
import { HasNotificationsMarker } from "@/Pages/Layout/HasNotificationsMarker";
|
||||
import NavLink from "@/Element/Button/NavLink";
|
||||
import { subscribeToNotifications } from "@/Notifications";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import { Sats, useWallet } from "@/Wallet";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRates } from "@/Hooks/useRates";
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{
|
||||
@ -34,6 +39,7 @@ const MENU_ITEMS = [
|
||||
label: "Messages",
|
||||
icon: "mail",
|
||||
link: "/messages",
|
||||
hideReadOnly: true,
|
||||
},
|
||||
{
|
||||
label: "Deck",
|
||||
@ -69,6 +75,44 @@ const getNavLinkClass = (isActive: boolean, narrow: boolean) => {
|
||||
});
|
||||
};
|
||||
|
||||
const WalletBalance = () => {
|
||||
const [balance, setBalance] = useState<Sats>();
|
||||
const wallet = useWallet();
|
||||
const rates = useRates("BTCUSD");
|
||||
|
||||
useEffect(() => {
|
||||
setBalance(undefined);
|
||||
if (wallet.wallet && wallet.wallet.canGetBalance()) {
|
||||
wallet.wallet.getBalance().then(setBalance);
|
||||
}
|
||||
}, [wallet]);
|
||||
|
||||
return (
|
||||
<div className="w-max flex flex-col max-xl:hidden">
|
||||
<div className="grow flex items-center justify-between">
|
||||
<div className="flex gap-1 items-center">
|
||||
<Icon name="sats" size={24} />
|
||||
<FormattedNumber value={balance ?? 0} />
|
||||
</div>
|
||||
<Link to="/wallet">
|
||||
<Icon name="dots" className="text-secondary" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-secondary text-sm">
|
||||
<FormattedMessage
|
||||
defaultMessage="~{amount}"
|
||||
id="3QwfJR"
|
||||
values={{
|
||||
amount: (
|
||||
<FormattedNumber style="currency" currency="USD" value={(rates?.ask ?? 0) * (balance ?? 0) * 1e-8} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function NavSidebar({ narrow = false }) {
|
||||
const { publicKey, subscriptions, readonly } = useLogin(s => ({
|
||||
publicKey: s.publicKey,
|
||||
@ -77,6 +121,7 @@ export default function NavSidebar({ narrow = false }) {
|
||||
}));
|
||||
const profile = useUserProfile(publicKey);
|
||||
const navigate = useNavigate();
|
||||
const { publisher } = useEventPublisher();
|
||||
const sub = getCurrentSubscription(subscriptions);
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
@ -100,8 +145,9 @@ export default function NavSidebar({ narrow = false }) {
|
||||
<div
|
||||
className={classNames(
|
||||
{ "xl:items-start": !narrow, "xl:gap-2": !narrow },
|
||||
"gap-1 flex flex-col items-center text-lg",
|
||||
"gap-1 flex flex-col items-center text-lg font-bold",
|
||||
)}>
|
||||
<WalletBalance narrow={narrow} />
|
||||
{MENU_ITEMS.filter(a => {
|
||||
if ((CONFIG.hideFromNavbar ?? []).includes(a.link)) {
|
||||
return false;
|
||||
@ -109,13 +155,25 @@ export default function NavSidebar({ narrow = false }) {
|
||||
if (a.link == "/deck" && !showDeck) {
|
||||
return false;
|
||||
}
|
||||
if (readonly && a.hideReadOnly) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map(item => {
|
||||
if (!item.nonLoggedIn && !publicKey) {
|
||||
return "";
|
||||
}
|
||||
const onClick = () => {
|
||||
if (item.label === "Notifications" && publisher) {
|
||||
subscribeToNotifications(publisher);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<NavLink key={item.link} to={item.link} className={({ isActive }) => getNavLinkClass(isActive, narrow)}>
|
||||
<NavLink
|
||||
onClick={onClick}
|
||||
key={item.link}
|
||||
to={item.link}
|
||||
className={({ isActive }) => getNavLinkClass(isActive, narrow)}>
|
||||
<Icon name={`${item.icon}-outline`} className="icon-outline" size={24} />
|
||||
<Icon name={`${item.icon}-solid`} className="icon-solid" size={24} />
|
||||
{item.label === "Notifications" && <HasNotificationsMarker />}
|
||||
|
@ -1,15 +1,13 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { base64 } from "@scure/base";
|
||||
import { unwrap } from "@snort/shared";
|
||||
import Icon from "@/Icons/Icon";
|
||||
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
|
||||
import { isFormElement } from "@/SnortUtils";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import SnortApi from "@/External/SnortApi";
|
||||
import { HasNotificationsMarker } from "@/Pages/Layout/HasNotificationsMarker";
|
||||
import NavLink from "@/Element/Button/NavLink";
|
||||
import classNames from "classnames";
|
||||
import { subscribeToNotifications } from "@/Notifications";
|
||||
|
||||
const NotificationsHeader = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -27,40 +25,6 @@ const NotificationsHeader = () => {
|
||||
}));
|
||||
const { publisher } = useEventPublisher();
|
||||
|
||||
async function goToNotifications() {
|
||||
// request permissions to send notifications
|
||||
if ("Notification" in window) {
|
||||
try {
|
||||
if (Notification.permission !== "granted") {
|
||||
const res = await Notification.requestPermission();
|
||||
console.debug(res);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
if ("serviceWorker" in navigator) {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
if (reg && publisher) {
|
||||
const api = new SnortApi(undefined, publisher);
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: (await api.getPushNotificationInfo()).publicKey,
|
||||
});
|
||||
await api.registerPushNotifications({
|
||||
endpoint: sub.endpoint,
|
||||
p256dh: base64.encode(new Uint8Array(unwrap(sub.getKey("p256dh")))),
|
||||
auth: base64.encode(new Uint8Array(unwrap(sub.getKey("auth")))),
|
||||
scope: `${location.protocol}//${location.hostname}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!publicKey) {
|
||||
return (
|
||||
<button onClick={() => navigate("/login/sign-up")} className="mr-3 primary p-2">
|
||||
@ -73,7 +37,7 @@ const NotificationsHeader = () => {
|
||||
<NavLink
|
||||
className={({ isActive }) => classNames({ active: isActive }, "px-2 py-3 flex")}
|
||||
to="/notifications"
|
||||
onClick={goToNotifications}>
|
||||
onClick={() => subscribeToNotifications(publisher)}>
|
||||
<Icon name="bell-solid" className="icon-solid" size={24} />
|
||||
<Icon name="bell-outline" className="icon-outline" size={24} />
|
||||
<HasNotificationsMarker />
|
||||
|
@ -7,7 +7,7 @@ import useLogin from "@/Hooks/useLogin";
|
||||
|
||||
export default function RightColumn() {
|
||||
const { pubkey } = useLogin(s => ({ pubkey: s.publicKey }));
|
||||
const hideRightColumnPaths = ["/login", "/new", "/messages", "/settings"];
|
||||
const hideRightColumnPaths = ["/login", "/new", "/messages"];
|
||||
const show = !hideRightColumnPaths.some(path => location.pathname.startsWith(path));
|
||||
|
||||
const getTitleMessage = () => {
|
||||
@ -33,7 +33,7 @@ export default function RightColumn() {
|
||||
<div>
|
||||
<SearchBox />
|
||||
</div>
|
||||
<div className="font-bold text-lg mt-4 mb-2">{getTitleMessage()}</div>
|
||||
<div className="font-bold text-xs mt-4 mb-2 uppercase tracking-wide">{getTitleMessage()}</div>
|
||||
<div className="overflow-y-auto hide-scrollbar flex-grow rounded-lg">{getContent()}</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -2,7 +2,6 @@ import "./Layout.css";
|
||||
import { useCallback } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
import Icon from "@/Icons/Icon";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { isFormElement } from "@/SnortUtils";
|
||||
import Toaster from "@/Toaster";
|
||||
@ -17,6 +16,8 @@ import useLoginFeed from "@/Feed/LoginFeed";
|
||||
import ErrorBoundary from "@/Element/ErrorBoundary";
|
||||
import Footer from "@/Pages/Layout/Footer";
|
||||
import { Header } from "@/Pages/Layout/Header";
|
||||
import CloseButton from "@/Element/Button/CloseButton";
|
||||
import { useCommunityLeaders } from "@/Hooks/useCommunityLeaders";
|
||||
|
||||
export default function Index() {
|
||||
const location = useLocation();
|
||||
@ -25,6 +26,9 @@ export default function Index() {
|
||||
useTheme();
|
||||
useLoginRelays();
|
||||
useLoginFeed();
|
||||
if (CONFIG.features.communityLeaders) {
|
||||
useCommunityLeaders();
|
||||
}
|
||||
|
||||
const hideHeaderPaths = ["/login", "/new"];
|
||||
const shouldHideFooter = location.pathname.startsWith("/messages/");
|
||||
@ -66,9 +70,7 @@ export default function Index() {
|
||||
function StalkerModal({ id }) {
|
||||
return (
|
||||
<div className="stalker" onClick={() => LoginStore.removeSession(id)}>
|
||||
<button type="button" className="circle flex items-center">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
<CloseButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { MetadataCache } from "@snort/system";
|
||||
|
||||
import { ChatParticipant } from "@/chat";
|
||||
import NoteToSelf from "../User/NoteToSelf";
|
||||
import ProfileImage from "../User/ProfileImage";
|
||||
import NoteToSelf from "../../Element/User/NoteToSelf";
|
||||
import ProfileImage from "../../Element/User/ProfileImage";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
|
||||
export function ChatParticipantProfile({ participant }: { participant: ChatParticipant }) {
|
@ -9,9 +9,9 @@ import NoteTime from "@/Element/Event/NoteTime";
|
||||
import Text from "@/Element/Text";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { Chat, ChatMessage, ChatType, setLastReadIn } from "@/chat";
|
||||
import ProfileImage from "../User/ProfileImage";
|
||||
import ProfileImage from "../../Element/User/ProfileImage";
|
||||
|
||||
import messages from "../messages";
|
||||
import messages from "../../Element/messages";
|
||||
|
||||
export interface DMProps {
|
||||
chat: Chat;
|
@ -1,8 +1,8 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import ProfileImage from "@/Element/User/ProfileImage";
|
||||
import DM from "@/Element/Chat/DM";
|
||||
import DM from "@/Pages/Messages/DM";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import WriteMessage from "@/Element/Chat/WriteMessage";
|
||||
import WriteMessage from "@/Pages/Messages/WriteMessage";
|
||||
import { Chat, createEmptyChatObject, useChatSystem } from "@/chat";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { ChatParticipantProfile } from "./ChatParticipant";
|
@ -9,9 +9,9 @@ import NoteToSelf from "@/Element/User/NoteToSelf";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePageWidth from "@/Hooks/usePageWidth";
|
||||
import NoteTime from "@/Element/Event/NoteTime";
|
||||
import DmWindow from "@/Element/Chat/DmWindow";
|
||||
import DmWindow from "@/Pages/Messages/DmWindow";
|
||||
import { Chat, ChatType, useChatSystem } from "@/chat";
|
||||
import { ChatParticipantProfile } from "@/Element/Chat/ChatParticipant";
|
||||
import { ChatParticipantProfile } from "@/Pages/Messages/ChatParticipant";
|
||||
import classNames from "classnames";
|
||||
import NewChatWindow from "@/Pages/Messages/NewChatWindow";
|
||||
|
||||
@ -109,7 +109,7 @@ export default function MessagesPage() {
|
||||
.map(conversation)}
|
||||
</div>
|
||||
)}
|
||||
{chat ? <DmWindow id={chat} /> : pageWidth >= TwoCol && <div className="flex-1"></div>}
|
||||
{chat ? <DmWindow id={chat} /> : pageWidth >= TwoCol && <div className="flex-1 rt-border"></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import Textarea from "../Textarea";
|
||||
import Textarea from "../../Element/Textarea";
|
||||
import { Chat } from "@/chat";
|
||||
import { AsyncIcon } from "@/Element/Button/AsyncIcon";
|
||||
|
@ -1,55 +1,60 @@
|
||||
import { NostrPrefix, tryParseNostrLink } from "@snort/system";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useLocation } from "react-router-dom";
|
||||
import { fetchNip05Pubkey } from "@snort/shared";
|
||||
|
||||
import Spinner from "@/Icons/Spinner";
|
||||
import ProfilePage from "@/Pages/Profile/ProfilePage";
|
||||
import { ThreadRoute } from "@/Element/Event/Thread";
|
||||
import { GenericFeed } from "@/Element/Feed/Generic";
|
||||
import { NostrPrefix, tryParseNostrLink } from "@snort/system";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export default function NostrLinkHandler() {
|
||||
const params = useParams();
|
||||
const { state } = useLocation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [renderComponent, setRenderComponent] = useState<React.ReactNode>(null);
|
||||
const { link } = useParams();
|
||||
|
||||
const link = decodeURIComponent(params["*"] ?? "").toLowerCase();
|
||||
|
||||
async function handleLink(link: string) {
|
||||
const determineInitialComponent = link => {
|
||||
const nav = tryParseNostrLink(link);
|
||||
if (nav) {
|
||||
if (nav.type === NostrPrefix.Event || nav.type === NostrPrefix.Note || nav.type === NostrPrefix.Address) {
|
||||
setRenderComponent(<ThreadRoute key={link} id={nav.encode()} />); // Directly render ThreadRoute
|
||||
} else if (nav.type === NostrPrefix.PublicKey || nav.type === NostrPrefix.Profile) {
|
||||
const id = nav.encode();
|
||||
setRenderComponent(<ProfilePage key={id} id={id} state={state} />); // Directly render ProfilePage
|
||||
} else if (nav.type === NostrPrefix.Req) {
|
||||
setRenderComponent(<GenericFeed key={link} link={nav} />);
|
||||
switch (nav.type) {
|
||||
case NostrPrefix.Event:
|
||||
case NostrPrefix.Note:
|
||||
case NostrPrefix.Address:
|
||||
return <ThreadRoute key={link} id={nav.encode()} />;
|
||||
case NostrPrefix.PublicKey:
|
||||
case NostrPrefix.Profile:
|
||||
return <ProfilePage key={link} id={nav.encode()} state={state} />;
|
||||
case NostrPrefix.Req:
|
||||
return <GenericFeed key={link} link={nav} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
if (state) {
|
||||
setRenderComponent(<ProfilePage key={link} state={state} />); // Directly render ProfilePage from route state
|
||||
} else {
|
||||
try {
|
||||
const pubkey = await fetchNip05Pubkey(link, CONFIG.nip05Domain);
|
||||
if (pubkey) {
|
||||
setRenderComponent(<ProfilePage key={link} id={pubkey} state={state} />); // Directly render ProfilePage
|
||||
}
|
||||
} catch {
|
||||
//ignored
|
||||
}
|
||||
}
|
||||
return state ? <ProfilePage key={link} state={state} /> : null;
|
||||
}
|
||||
};
|
||||
|
||||
const initialRenderComponent = determineInitialComponent(link);
|
||||
const [loading, setLoading] = useState(initialRenderComponent ? false : true);
|
||||
const [renderComponent, setRenderComponent] = useState(initialRenderComponent);
|
||||
|
||||
async function handleLink(link) {
|
||||
if (!tryParseNostrLink(link)) {
|
||||
try {
|
||||
const pubkey = await fetchNip05Pubkey(link, CONFIG.nip05Domain);
|
||||
if (pubkey) {
|
||||
setRenderComponent(<ProfilePage key={link} id={pubkey} state={state} />);
|
||||
}
|
||||
} catch {
|
||||
// Ignored
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (link.length > 0) {
|
||||
handleLink(link).catch(console.error);
|
||||
}
|
||||
}, [link]);
|
||||
setRenderComponent(determineInitialComponent(link));
|
||||
handleLink(link);
|
||||
}, [link]); // Depend only on 'link'
|
||||
|
||||
if (renderComponent) {
|
||||
return renderComponent;
|
||||
|
@ -98,9 +98,11 @@ export default function NotificationsPage({ onClick }: { onClick?: (link: NostrL
|
||||
return (
|
||||
<>
|
||||
<div className="main-content">
|
||||
<Suspense fallback={<PageSpinner />}>
|
||||
<NotificationGraph evs={myNotifications} />
|
||||
</Suspense>
|
||||
{CONFIG.features.notificationGraph && (
|
||||
<Suspense fallback={<PageSpinner />}>
|
||||
<NotificationGraph evs={myNotifications} />
|
||||
</Suspense>
|
||||
)}
|
||||
{login.publicKey &&
|
||||
[...timeGrouped.entries()]
|
||||
.slice(0, showN)
|
||||
|
@ -166,10 +166,6 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
.qr-modal .pfp .username {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qr-modal canvas {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import "./ProfilePage.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
encodeTLVEntries,
|
||||
EventKind,
|
||||
@ -55,8 +55,6 @@ import ProfileTab, {
|
||||
import DisplayName from "@/Element/User/DisplayName";
|
||||
import { UserWebsiteLink } from "@/Element/User/UserWebsiteLink";
|
||||
import { useMuteList, usePinList } from "@/Hooks/useLists";
|
||||
|
||||
import messages from "../messages";
|
||||
import FollowedBy from "@/Element/User/FollowedBy";
|
||||
|
||||
interface ProfilePageProps {
|
||||
@ -323,9 +321,16 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
|
||||
)}
|
||||
{isMe ? (
|
||||
<>
|
||||
<button className="md:hidden" type="button" onClick={() => navigate("/settings")}>
|
||||
<FormattedMessage {...messages.Settings} />
|
||||
</button>
|
||||
<Link className="md:hidden" to="/settings">
|
||||
<button>
|
||||
<FormattedMessage defaultMessage="Settings" id="D3idYv" />
|
||||
</button>
|
||||
</Link>
|
||||
<Link className="hidden md:inline" to="/settings/profile">
|
||||
<button>
|
||||
<FormattedMessage defaultMessage="Edit" id="wEQDC6" />
|
||||
</button>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@ -394,7 +399,7 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="main-content">{tabContent()}</div>
|
||||
{modalImage && <SpotlightMediaModal onClose={() => setModalImage("")} images={[modalImage]} idx={0} />}
|
||||
{modalImage && <SpotlightMediaModal onClose={() => setModalImage("")} media={[modalImage]} idx={0} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -10,7 +10,6 @@ import { TimelineSubject } from "@/Feed/TimelineFeed";
|
||||
import { debounce, getCurrentRefCode, getRelayName, sha256 } from "@/SnortUtils";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import Discover from "@/Pages/Discover";
|
||||
import TrendingUsers from "@/Element/Trending/TrendingUsers";
|
||||
import TrendingNotes from "@/Element/Trending/TrendingPosts";
|
||||
import HashTagsPage from "@/Pages/HashTagsPage";
|
||||
import SuggestedProfiles from "@/Element/SuggestedProfiles";
|
||||
@ -147,6 +146,18 @@ export const GlobalTab = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const FollowedByFriendsTab = () => {
|
||||
const { publicKey } = useLogin();
|
||||
const subject: TimelineSubject = {
|
||||
type: "global",
|
||||
items: [],
|
||||
discriminator: `followed-by-friends-${publicKey}`,
|
||||
streams: true,
|
||||
};
|
||||
|
||||
return <Timeline followDistance={2} subject={subject} postsOnly={true} method={"TIME_RANGE"} />;
|
||||
};
|
||||
|
||||
export const NotesTab = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const deckContext = useContext(DeckContext);
|
||||
@ -209,6 +220,10 @@ export const RootTabRoutes = [
|
||||
path: "notes",
|
||||
element: <NotesTab />,
|
||||
},
|
||||
{
|
||||
path: "followed-by-friends",
|
||||
element: <FollowedByFriendsTab />,
|
||||
},
|
||||
{
|
||||
path: "conversations",
|
||||
element: <ConversationsTab />,
|
||||
@ -225,14 +240,6 @@ export const RootTabRoutes = [
|
||||
path: "trending/notes",
|
||||
element: <TrendingNotes />,
|
||||
},
|
||||
{
|
||||
path: "trending/people",
|
||||
element: (
|
||||
<div className="p">
|
||||
<TrendingUsers />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "trending/hashtags",
|
||||
element: <TrendingHashtags />,
|
||||
|
@ -1,42 +1,42 @@
|
||||
import "./WalletPage.css";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RouteObject, useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
|
||||
|
||||
import NoteTime from "@/Element/Event/NoteTime";
|
||||
import { WalletInvoice, Sats, WalletInfo, WalletInvoiceState, useWallet, LNWallet, Wallets } from "@/Wallet";
|
||||
import { WalletInvoice, Sats, useWallet, LNWallet, Wallets } from "@/Wallet";
|
||||
import AsyncButton from "@/Element/Button/AsyncButton";
|
||||
import { unwrap } from "@/SnortUtils";
|
||||
import { WebLNWallet } from "@/Wallet/WebLN";
|
||||
import Icon from "@/Icons/Icon";
|
||||
import { useRates } from "@/Hooks/useRates";
|
||||
import { AsyncIcon } from "@/Element/Button/AsyncIcon";
|
||||
import classNames from "classnames";
|
||||
|
||||
export const WalletRoutes: RouteObject[] = [
|
||||
{
|
||||
path: "/wallet",
|
||||
element: <WalletPage />,
|
||||
},
|
||||
];
|
||||
|
||||
export default function WalletPage() {
|
||||
export default function WalletPage(props: { showHistory: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const { formatMessage } = useIntl();
|
||||
const [info, setInfo] = useState<WalletInfo>();
|
||||
const [balance, setBalance] = useState<Sats>();
|
||||
const [history, setHistory] = useState<WalletInvoice[]>();
|
||||
const [walletPassword, setWalletPassword] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
const walletState = useWallet();
|
||||
const wallet = walletState.wallet;
|
||||
const rates = useRates("BTCUSD");
|
||||
|
||||
async function loadWallet(wallet: LNWallet) {
|
||||
try {
|
||||
const i = await wallet.getInfo();
|
||||
setInfo(i);
|
||||
const b = await wallet.getBalance();
|
||||
setBalance(b as Sats);
|
||||
const h = await wallet.getInvoices();
|
||||
setHistory((h as WalletInvoice[]).sort((a, b) => b.timestamp - a.timestamp));
|
||||
setError(undefined);
|
||||
setBalance(0);
|
||||
setHistory(undefined);
|
||||
if (wallet.canGetBalance()) {
|
||||
const b = await wallet.getBalance();
|
||||
setBalance(b as Sats);
|
||||
}
|
||||
if (wallet.canGetInvoices() && (props.showHistory ?? true)) {
|
||||
const h = await wallet.getInvoices();
|
||||
setHistory((h as WalletInvoice[]).sort((a, b) => b.timestamp - a.timestamp));
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError((e as Error).message);
|
||||
@ -47,29 +47,11 @@ export default function WalletPage() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (wallet) {
|
||||
if (wallet.isReady()) {
|
||||
loadWallet(wallet).catch(console.warn);
|
||||
} else if (wallet.canAutoLogin()) {
|
||||
wallet
|
||||
.login()
|
||||
.then(async () => await loadWallet(wallet))
|
||||
.catch(console.warn);
|
||||
}
|
||||
if (wallet && wallet.isReady()) {
|
||||
loadWallet(wallet).catch(console.warn);
|
||||
}
|
||||
}, [wallet]);
|
||||
|
||||
function stateIcon(s: WalletInvoiceState) {
|
||||
switch (s) {
|
||||
case WalletInvoiceState.Pending:
|
||||
return <Icon name="clock" className="mr5" size={15} />;
|
||||
case WalletInvoiceState.Paid:
|
||||
return <Icon name="check" className="mr5" size={15} />;
|
||||
case WalletInvoiceState.Expired:
|
||||
return <Icon name="close" className="mr5" size={15} />;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWallet(pw: string) {
|
||||
if (wallet) {
|
||||
await wallet.login(pw);
|
||||
@ -116,11 +98,11 @@ export default function WalletPage() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex w-max">
|
||||
<h4 className="f-1">
|
||||
<div className="flex items-center">
|
||||
<h4 className="grow">
|
||||
<FormattedMessage defaultMessage="Select Wallet" id="G1BGCg" />
|
||||
</h4>
|
||||
<div className="f-1">
|
||||
<div>
|
||||
<select className="w-max" onChange={e => Wallets.switch(e.target.value)} value={walletState.config?.id}>
|
||||
{Wallets.list().map(a => {
|
||||
return <option value={a.id}>{a.info.alias}</option>;
|
||||
@ -132,84 +114,119 @@ export default function WalletPage() {
|
||||
}
|
||||
|
||||
function walletHistory() {
|
||||
if (wallet instanceof WebLNWallet) return null;
|
||||
if (!wallet?.canGetInvoices() || !(props.showHistory ?? true)) return;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="History" id="d6CyG5" description="Wallet transation history" />
|
||||
<FormattedMessage defaultMessage="Payments" id="pukxg/" description="Wallet transation history" />
|
||||
</h3>
|
||||
{history?.map(a => (
|
||||
<div className="card flex wallet-history-item" key={a.timestamp}>
|
||||
<div className="grow flex-col">
|
||||
<NoteTime from={a.timestamp * 1000} fallback={formatMessage({ defaultMessage: "now", id: "kaaf1E" })} />
|
||||
<div>{(a.memo ?? "").length === 0 ? <> </> : a.memo}</div>
|
||||
{history?.map(a => {
|
||||
const dirClassname = {
|
||||
"text-[--success]": a.direction === "in",
|
||||
"text-[--error]": a.direction === "out",
|
||||
};
|
||||
return (
|
||||
<div className="flex gap-4 p-2 hover:bg-[--gray-superdark] rounded-xl items-center" key={a.timestamp}>
|
||||
<div>
|
||||
<div className="rounded-full aspect-square p-2 bg-[--gray-dark]">
|
||||
<Icon
|
||||
name="arrow-up-right"
|
||||
className={classNames(dirClassname, {
|
||||
"rotate-180": a.direction === "in",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow flex justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div>{a.memo?.length === 0 ? CONFIG.appNameCapitalized : a.memo}</div>
|
||||
<div className="text-secondary text-sm">
|
||||
<NoteTime
|
||||
from={a.timestamp * 1000}
|
||||
fallback={formatMessage({ defaultMessage: "now", id: "kaaf1E" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-right">
|
||||
<div className={classNames(dirClassname)}>
|
||||
<FormattedMessage
|
||||
defaultMessage="{sign} {amount} sats"
|
||||
id="tj6kdX"
|
||||
values={{
|
||||
sign: a.direction === "in" ? "+" : "-",
|
||||
amount: <FormattedNumber value={a.amount / 1e3} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-secondary text-sm">
|
||||
<FormattedMessage
|
||||
defaultMessage="~{amount}"
|
||||
id="3QwfJR"
|
||||
values={{
|
||||
amount: (
|
||||
<FormattedNumber
|
||||
style="currency"
|
||||
currency="USD"
|
||||
value={(rates?.ask ?? 0) * a.amount * 1e-11}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`nowrap ${(() => {
|
||||
switch (a.state) {
|
||||
case WalletInvoiceState.Paid:
|
||||
return "success";
|
||||
case WalletInvoiceState.Expired:
|
||||
return "expired";
|
||||
case WalletInvoiceState.Failed:
|
||||
return "failed";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
})()}`}>
|
||||
{stateIcon(a.state)}
|
||||
<FormattedMessage
|
||||
defaultMessage="{amount} sats"
|
||||
id="vrTOHJ"
|
||||
values={{
|
||||
amount: <FormattedNumber value={a.amount / 1e3} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function walletBalance() {
|
||||
if (wallet instanceof WebLNWallet) return null;
|
||||
if (!wallet?.canGetBalance()) return;
|
||||
return (
|
||||
<small>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormattedMessage
|
||||
defaultMessage="Balance: {amount} sats"
|
||||
id="VN0+Fz"
|
||||
defaultMessage="<big>{amount}</big> <small>sats</small>"
|
||||
id="E5ZIPD"
|
||||
values={{
|
||||
big: c => <span className="text-5xl font-bold">{c}</span>,
|
||||
small: c => <span className="text-secondary text-sm">{c}</span>,
|
||||
amount: <FormattedNumber value={balance ?? 0} />,
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
<AsyncIcon size={20} className="text-secondary cursor-pointer" iconName="closedeye" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function walletInfo() {
|
||||
if (!wallet?.isReady()) return null;
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3>{info?.alias}</h3>
|
||||
<div className="flex flex-col items-center px-6 py-4 bg-[--gray-superdark] rounded-2xl gap-1">
|
||||
{walletBalance()}
|
||||
<div className="text-secondary">
|
||||
<FormattedMessage
|
||||
defaultMessage="~{amount}"
|
||||
id="3QwfJR"
|
||||
values={{
|
||||
amount: (
|
||||
<FormattedNumber style="currency" currency="USD" value={(rates?.ask ?? 0) * (balance ?? 0) * 1e-8} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/*<div className="flex wallet-buttons">
|
||||
<AsyncButton onClick={createInvoice}>
|
||||
<FormattedMessage defaultMessage="Receive" description="Receive sats by generating LN invoice" />
|
||||
</AsyncButton>
|
||||
</div>*/}
|
||||
{walletHistory()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-content p">
|
||||
{error && <b className="error">{error}</b>}
|
||||
<div className="main-content">
|
||||
{walletList()}
|
||||
{error && <b className="warning">{error}</b>}
|
||||
{unlockWallet()}
|
||||
{walletInfo()}
|
||||
</div>
|
||||
|
@ -8,7 +8,7 @@ import { SnortPubKey } from "@/Const";
|
||||
import ProfilePreview from "@/Element/User/ProfilePreview";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { UploaderServices } from "@/Upload";
|
||||
import { bech32ToHex, getRelayName, unwrap } from "@/SnortUtils";
|
||||
import { bech32ToHex, getRelayName, trackEvent, unwrap } from "@/SnortUtils";
|
||||
import { ZapPoolController, ZapPoolRecipient, ZapPoolRecipientType } from "@/ZapPoolController";
|
||||
import AsyncButton from "@/Element/Button/AsyncButton";
|
||||
import { useWallet } from "@/Wallet";
|
||||
@ -19,17 +19,9 @@ const DataProviders = [
|
||||
name: "nostr.band",
|
||||
owner: bech32ToHex("npub1sx9rnd03vs34lp39fvfv5krwlnxpl90f3dzuk8y3cuwutk2gdhdqjz6g8m"),
|
||||
},
|
||||
{
|
||||
name: "semisol.dev",
|
||||
owner: bech32ToHex("npub12262qa4uhw7u8gdwlgmntqtv7aye8vdcmvszkqwgs0zchel6mz7s6cgrkj"),
|
||||
},
|
||||
{
|
||||
name: "nostr.directory",
|
||||
owner: bech32ToHex("npub1teawtzxh6y02cnp9jphxm2q8u6xxfx85nguwg6ftuksgjctvavvqnsgq5u"),
|
||||
},
|
||||
];
|
||||
|
||||
function ZapTarget({ target }: { target: ZapPoolRecipient }) {
|
||||
export function ZapPoolTarget({ target }: { target: ZapPoolRecipient }) {
|
||||
if (!ZapPoolController) return;
|
||||
const login = useLogin();
|
||||
const profile = useUserProfile(target.pubkey);
|
||||
@ -156,13 +148,17 @@ export default function ZapPoolPage() {
|
||||
</p>
|
||||
<p>
|
||||
{wallet && (
|
||||
<AsyncButton onClick={() => ZapPoolController?.payout(wallet)}>
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
trackEvent("ZapPool", { manual: true });
|
||||
await ZapPoolController?.payout(wallet);
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="Payout Now" id="+PzQ9Y" />
|
||||
</AsyncButton>
|
||||
)}
|
||||
</p>
|
||||
<div>
|
||||
<ZapTarget
|
||||
<ZapPoolTarget
|
||||
target={
|
||||
zapPool.find(b => b.pubkey === bech32ToHex(SnortPubKey) && b.type === ZapPoolRecipientType.Generic) ?? {
|
||||
type: ZapPoolRecipientType.Generic,
|
||||
@ -179,7 +175,7 @@ export default function ZapPoolPage() {
|
||||
{relayConnections.map(a => (
|
||||
<div>
|
||||
<h4>{getRelayName(a.address)}</h4>
|
||||
<ZapTarget
|
||||
<ZapPoolTarget
|
||||
target={
|
||||
zapPool.find(b => b.pubkey === a.pubkey && b.type === ZapPoolRecipientType.Relay) ?? {
|
||||
type: ZapPoolRecipientType.Relay,
|
||||
@ -197,7 +193,7 @@ export default function ZapPoolPage() {
|
||||
{UploaderServices.map(a => (
|
||||
<div>
|
||||
<h4>{a.name}</h4>
|
||||
<ZapTarget
|
||||
<ZapPoolTarget
|
||||
target={
|
||||
zapPool.find(b => b.pubkey === a.owner && b.type === ZapPoolRecipientType.FileHost) ?? {
|
||||
type: ZapPoolRecipientType.FileHost,
|
||||
@ -215,7 +211,7 @@ export default function ZapPoolPage() {
|
||||
{DataProviders.map(a => (
|
||||
<div>
|
||||
<h4>{a.name}</h4>
|
||||
<ZapTarget
|
||||
<ZapPoolTarget
|
||||
target={
|
||||
zapPool.find(b => b.pubkey === a.owner && b.type === ZapPoolRecipientType.DataProvider) ?? {
|
||||
type: ZapPoolRecipientType.DataProvider,
|
||||
|
@ -24,7 +24,7 @@ export function Profile() {
|
||||
name: state.name,
|
||||
picture,
|
||||
});
|
||||
trackEvent("Login:NewAccount");
|
||||
trackEvent("Login", { newAccount: true });
|
||||
navigate("/login/sign-up/topics");
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
|
@ -28,7 +28,7 @@ export function SignIn() {
|
||||
"getRelays" in unwrap(window.nostr) ? await unwrap(window.nostr?.getRelays).call(window.nostr) : undefined;*/
|
||||
const pubKey = await unwrap(window.nostr).getPublicKey();
|
||||
LoginStore.loginWithPubkey(pubKey, LoginSessionType.Nip7);
|
||||
trackEvent("Login:NIP7");
|
||||
trackEvent("Login", { type: "NIP7" });
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
@ -41,8 +41,7 @@ export function SignIn() {
|
||||
setError("");
|
||||
try {
|
||||
await loginHandler.doLogin(key, key => Promise.resolve(new NotEncrypted(key)));
|
||||
|
||||
trackEvent("Login:Key");
|
||||
trackEvent("Login", { type: "Key" });
|
||||
navigate("/");
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
|
@ -22,7 +22,7 @@ export default function AccountsPage() {
|
||||
about: false,
|
||||
}}
|
||||
actions={
|
||||
<div className="f-1">
|
||||
<div className="flex-1">
|
||||
<button className="mb10" onClick={() => LoginStore.switchAccount(a.id)}>
|
||||
<FormattedMessage defaultMessage="Switch" id="n1Whvj" />
|
||||
</button>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user