Compare commits

...

151 Commits

Author SHA1 Message Date
21e1202b97
refactor: pass nip5 to api 2024-01-10 22:44:37 +00:00
ab8121c4b2 fix broken proxyimg placeholder 2024-01-10 23:26:51 +02:00
d3c9fef9af smaller avatar cache, exclude gifs from img cache 2024-01-10 23:01:11 +02:00
8c8a7c7e88 avatar image sw cache 2024-01-10 22:49:36 +02:00
cb233f4ccb cacheableresponse plugin fixes img sw cache 2024-01-10 22:16:04 +02:00
326ce2ba68
fix: opengraph middleware 2024-01-10 20:07:06 +00:00
8cca297d6d eslint: react-refresh/only-export-components error 2024-01-10 21:13:08 +02:00
a3fc25f64c show media in replied note 2024-01-10 21:00:39 +02:00
a1f61e2d13 limit replying-to note size 2024-01-10 20:54:48 +02:00
51758eaf5e separator border in replying to note 2024-01-10 20:32:51 +02:00
5baffd00b9 fix the rest of warnings 2024-01-10 20:21:19 +02:00
e6a42db658 fix more warnings, store transformed text in LRUCache of 1000 2024-01-10 19:54:01 +02:00
8e37e0fbed fix some warnings 2024-01-10 19:31:37 +02:00
7220435d15 chore: Update translations 2024-01-10 16:50:28 +00:00
53488a9c59 memoize imagegriditem 2024-01-10 18:48:36 +02:00
1278867ad0 search page routing, resized grid images 2024-01-10 18:25:24 +02:00
be4ee620ad chore: Update translations 2024-01-10 16:18:56 +00:00
87386c9950
feat: use new opengraph functions 2024-01-10 16:16:33 +00:00
baf6cc34ee reorganize, fix some fast refresh warnings 2024-01-10 18:03:52 +02:00
071eed0d8c search page params 2024-01-10 17:44:54 +02:00
6f9a1fd706 middle column pb-[50vh] 2024-01-10 17:34:12 +02:00
90342325fd eslint warning fixes 2024-01-10 17:29:24 +02:00
b686b8ff26 useBadgesFeed fix 2024-01-10 17:23:18 +02:00
835385836f chore: Update translations 2024-01-10 14:51:13 +00:00
93608f817f show zap pool notification only if wallet is ready 2024-01-10 16:36:37 +02:00
9e2582ac81 show only 1 task at a time 2024-01-10 16:36:30 +02:00
35d7ec4685 followed by foafs 2024-01-10 16:36:23 +02:00
062212f311 useHistoryState for TimelineFollows latest time 2024-01-10 16:36:16 +02:00
52adf6fb1f store link previews in lrucache to prevent layout shift 2024-01-10 16:36:09 +02:00
90b15ee668 error page, sw 2024-01-10 16:36:02 +02:00
7be4b0bd18 rm custom sw registration, vite-plugin-pwa handles 2024-01-10 16:35:54 +02:00
b8cdb4bf58 chore: Update translations 2024-01-10 10:10:18 +00:00
cf6b431d73 sw fix 2024-01-10 12:01:45 +02:00
91f0afdb89
fix: build 2024-01-09 18:55:42 +00:00
8a5a089b4d
Merge remote-tracking branch 'origin/main' 2024-01-09 16:40:51 +00:00
80fa5a132b
refactor: reactions grouping and other fixes 2024-01-09 16:40:31 +00:00
1a4a76d7fa chore: Update translations 2024-01-09 13:25:01 +00:00
7073e8d9dd trackEvent in ErrorBoundary, disable trackEvent in dev 2024-01-09 15:22:26 +02:00
376096c5af chore: Update translations 2024-01-09 13:03:40 +00:00
3eeeee4b06 use queueMicrotask instead of setTimeout 0 2024-01-09 14:56:26 +02:00
4455651d47
refactor: Query emits Filters 2024-01-09 12:54:07 +00:00
8216cb8741 note creator style, rm hashtags 2024-01-09 14:52:08 +02:00
18beed13c3 thread inView rendering 2024-01-09 14:07:39 +02:00
3dbbe5b0f0 fix timeline note waitUntilInView rendering 2024-01-09 13:58:33 +02:00
64b0329ffe fix adding profiles to search 2024-01-09 13:36:23 +02:00
2624920c65 setTimeout instead of requestAnimationFrame 2024-01-09 12:50:20 +02:00
6026ed34a8 on init, load cached profiles to fuzzy search 2024-01-09 12:38:39 +02:00
45245153ab
refactor: dont inject analytics script 2024-01-09 10:30:55 +00:00
44983068e4 ProxyImg initial src 2024-01-09 12:15:23 +02:00
7a409c1455 system-react module entrypoint, reaction loading memos 2024-01-09 12:07:14 +02:00
bbdfb43834
system-worker progress 2024-01-09 09:30:09 +00:00
24e145a0a0 chore: Update translations 2024-01-09 08:56:15 +00:00
4b5c87acdf primary button color 2024-01-09 10:53:40 +02:00
98e671ee45 chore: Update translations 2024-01-09 08:34:26 +00:00
a68cdeeb20 navbar wallet layout 2024-01-09 10:32:14 +02:00
53754a5a69 fix build 2024-01-09 10:11:52 +02:00
898d8bfe02 chore: Update translations 2024-01-09 07:46:21 +00:00
3a42ec9029 correct iris username length prompt 2024-01-09 09:41:30 +02:00
927718e236
chore: typo 2024-01-08 15:15:48 +00:00
5d3abc553a
feat: NoteStore event-emitter 2024-01-08 15:10:23 +00:00
ca2cb76380 constant onClick param in some Notes 2024-01-08 16:34:22 +02:00
8a75b5bce8 chore: Update translations 2024-01-08 14:15:39 +00:00
c80eb25d29 MetadataCache -> CachedMetadata, addCachedMetadataToFuzzySearch 2024-01-08 16:12:43 +02:00
88924941a5
feat: NostrQueryManager 2024-01-08 13:56:10 +00:00
cae865a3e7
feat: emit closed 2024-01-08 13:56:10 +00:00
add3b45fcd chore: Update translations 2024-01-08 13:47:40 +00:00
d3bc1b1c1d note options constant param 2024-01-08 15:42:25 +02:00
a20c8dbbf4 add requestAnimationFrame to socialgraph handleevent 2024-01-08 14:44:51 +02:00
5cae0ffeed
chore: upgrade lnc dependency 2024-01-08 12:41:22 +00:00
0bb758ae41 chore: Update translations 2024-01-08 12:26:08 +00:00
30b21cfe91
fix: image meta 2024-01-08 12:23:48 +00:00
84bda4e33d NoteHeader, NoteInner -> Note 2024-01-08 14:06:30 +02:00
2fbe90a39e
chore: formatting 2024-01-08 11:35:56 +00:00
d25b6ef5f1
chore: remove unused config 2024-01-08 11:35:56 +00:00
185aca0442
refactor: remove event caches 2024-01-08 11:35:56 +00:00
08241bbbf6
refactor: prevent duplicate preload 2024-01-08 11:35:56 +00:00
a54924f339
refactor: extract imeta directly in text parser 2024-01-08 11:35:55 +00:00
29ca482511
refactor: remove slow sha256 from interaction cache 2024-01-08 11:35:55 +00:00
13da7f822c
refactor: reduce hexToBech32 calls 2024-01-08 11:35:55 +00:00
e6e7878e31 ReplyTag component 2024-01-08 13:35:34 +02:00
7c21747d7f chore: Update translations 2024-01-08 11:29:23 +00:00
5adaed2737 Note -> EventComponent, split NoteInner 2024-01-08 13:16:42 +02:00
615f3ca504 useReactions leaveOpen false 2024-01-08 13:16:42 +02:00
79fad227c5 smaller inMemoryDb 2024-01-08 13:16:42 +02:00
b6023a8d95 waitUntilInView = index > 5 2024-01-08 13:16:42 +02:00
11e616c612 rm related prop 2024-01-08 13:16:42 +02:00
e905b4134d dont pass related to notes 2024-01-08 13:16:42 +02:00
26a3e95086 add why-did-you-render 2024-01-08 13:16:42 +02:00
5db33314a8 chore: Update translations 2024-01-08 10:31:49 +00:00
9ec27bcea3
feat: track hashtags input usage 2024-01-08 10:25:31 +00:00
27210f91ae
chore: disable toast 2024-01-08 10:20:57 +00:00
283d5cafb3 chore: Update translations 2024-01-07 18:50:05 +00:00
02e6d5c98c
fix: created_at on refresh 2024-01-07 18:44:07 +00:00
b40a8cb9ad do not modify filters in place 2024-01-07 13:40:01 +02:00
cb8318df56
chore: add sitemap 2024-01-06 23:59:19 +00:00
1aadda1c6b try disabling alreadyHave check 2024-01-06 01:05:23 +02:00
39f4ee9c2b
fix: wallet save 2024-01-05 20:54:46 +00:00
40912bc979 chore: Update translations 2024-01-05 20:16:09 +00:00
5eaffcae7d
fix: warn on unused vars 2024-01-05 20:15:06 +00:00
e3d282578d
chore: disable cashu 2024-01-05 20:10:24 +00:00
afcee97131
fix: refresh from getBalance 2024-01-05 20:08:45 +00:00
3a2facd899 send not filter to relays that support nip-113 2024-01-05 14:02:42 +02:00
aa938034c5 fix build 2024-01-05 13:42:50 +02:00
dee9a3de2c add filter.not.ids = [alreadyHave] from memory 2024-01-05 13:21:18 +02:00
cc753d5708 system.HandleEvent 2024-01-05 12:56:46 +02:00
f9302f3917 move InMemoryDB to system 2024-01-05 12:41:48 +02:00
3eb290a594 move InMemoryDB to system 2024-01-05 12:38:04 +02:00
f39831dde8 chore: Update translations 2024-01-05 10:18:57 +00:00
1a9e571b0f use InMemoryDB instead of seenEvents LRUSet 2024-01-05 12:14:53 +02:00
29a8db28dd idb read queue 2024-01-05 01:17:57 +02:00
70b85dcc9c disable seenEvents check in connection 2024-01-05 01:05:46 +02:00
956871e5e5 fix idb unique indexes 2024-01-05 00:09:46 +02:00
c4b74b3cb1 hide broken banners 2024-01-04 23:35:48 +02:00
2f9fee56b1 idb query by all tags 2024-01-04 23:24:37 +02:00
b3f04c8cd9
fix: refresh token 2024-01-04 20:43:51 +00:00
e73aa303a8 fix build 2024-01-04 22:36:31 +02:00
f82109b6b3 fix idb search, only 1 read query at a time 2024-01-04 22:32:51 +02:00
2fa4065414 chore: Update translations 2024-01-04 19:36:46 +00:00
eefbc49384 reactions refactor, fix Tabs 2024-01-04 21:34:07 +02:00
db074316d7 sort reactions by follow distance 2024-01-04 21:26:09 +02:00
629099670c chore: Update translations 2024-01-04 17:43:51 +00:00
2e5295a0f7 format 2024-01-04 19:41:13 +02:00
c8215b1408 NoteTime memo 2024-01-04 19:41:13 +02:00
3963db1a0a chore: Update translations 2024-01-04 17:14:23 +00:00
daad0bbe76
fix: add account:read for alby 2024-01-04 17:12:52 +00:00
2e97546ab0 chore: Update translations 2024-01-04 17:04:09 +00:00
3fe3c7a98d eslint: sort imports & exports 2024-01-04 19:01:18 +02:00
046d4d97bd SortedMap.clear(), V type 2024-01-04 18:49:05 +02:00
c612da125e
fix: build 2024-01-04 16:37:12 +00:00
a976909036
fix: typo 2024-01-04 16:32:26 +00:00
a622c459d7
fix: sorted map 2024-01-04 16:31:04 +00:00
36b9538aa6
chore: fix error 2024-01-04 16:21:50 +00:00
f2bcd1100d chore: Update translations 2024-01-04 16:15:41 +00:00
5d259cee95 store events in SortedMap to avoid sort on render 2024-01-04 18:12:34 +02:00
47d92fe171
chore: formatting 2024-01-04 16:11:10 +00:00
7bc00b4624
feat: cashu wallet setup
feat: wallet send/receive pages
2024-01-04 16:10:52 +00:00
1e08702072 assets dir 2024-01-04 16:47:33 +02:00
afa6d39a56 reorganize code into smaller files & dirs 2024-01-04 15:48:19 +02:00
5ea2eb711f move wasm and system out of index.tsx 2024-01-04 15:15:46 +02:00
e08da34aa8 eslint errors fixed 2024-01-04 14:33:20 +02:00
c773ea0a0f chore: Update translations 2024-01-04 12:27:11 +00:00
eeb6ec9dd8 eslint fixes 2024-01-04 14:21:42 +02:00
9f88b44b91
feat: finish alby wallet 2024-01-04 12:05:13 +00:00
0442c3512c
chore: fix more warnings 2024-01-04 11:04:52 +00:00
13f4ec3f30
chore: fix some eslint warnings 2024-01-04 10:45:58 +00:00
2a2c713486 eslint: react-refresh, react-hooks 2024-01-04 12:32:43 +02:00
b143520901 add eslint-plugin-react 2024-01-04 11:43:17 +02:00
26146106d4 chore: Update translations 2024-01-04 08:50:58 +00:00
d31a03a565 use seenEvents in IndexedDB 2024-01-04 10:44:46 +02:00
287ce32690 use LRUSet to skip already seen event processing 2024-01-04 10:36:23 +02:00
c2899eac26 disable indexedDbEvents in iris for now 2024-01-04 10:19:48 +02:00
488 changed files with 7507 additions and 5471 deletions

49
functions/_middleware.ts Normal file
View File

@ -0,0 +1,49 @@
interface Env {}
const HOST = "snort.social";
export const onRequest: PagesFunction<Env> = async context => {
const u = new URL(context.request.url);
const prefixes = ["npub1", "nprofile1", "naddr1", "nevent1", "note1"];
const isEntityPath = prefixes.some(
a => u.pathname.startsWith(`/${a}`) || u.pathname.startsWith(`/e/${a}`) || u.pathname.startsWith(`/p/${a}`),
);
const nostrAddress = u.pathname.match(/^\/([a-zA-Z0-9_]+)$/i);
const next = await context.next();
if (u.pathname != "/" && (isEntityPath || nostrAddress)) {
//console.log("Handeling path: ", u.pathname, isEntityPath, nostrAddress[1]);
try {
let id = u.pathname.split("/").at(-1);
if (!isEntityPath && nostrAddress) {
id = `${id}@${HOST}`;
}
const fetchApi = `http://nostr.api.v0l.io/api/v1/opengraph/${id}?canonical=${encodeURIComponent(
`https://${HOST}/%s`,
)}`;
console.log("Fetching tags from: ", fetchApi);
const rsp = await fetch(fetchApi, {
method: "POST",
body: await next.arrayBuffer(),
headers: {
"user-agent": `SnortFunctions/1.0 (https://${HOST})`,
"content-type": "text/html",
accept: "text/html",
},
});
if (rsp.ok) {
const body = await rsp.text();
if (body.length > 0) {
return new Response(body, {
headers: {
"content-type": "text/html",
},
});
}
}
} catch (e) {
console.error(e);
}
}
return next;
};

View File

@ -1,30 +0,0 @@
interface Env {}
export const onRequest: PagesFunction<Env> = async context => {
const id = context.params.id as string;
const next = await context.next();
try {
const rsp = await fetch(`https://api.snort.social/api/v1/og/tag/e/${id}`, {
method: "POST",
body: await next.arrayBuffer(),
headers: {
"user-agent": "Snort-Functions/1.0 (https://snort.social)",
"content-type": "text/plain",
},
});
if (rsp.ok) {
const body = await rsp.text();
if (body.length > 0) {
return new Response(body, {
headers: {
"content-type": "text/html",
},
});
}
}
} catch {
// ignore
}
return next;
};

View File

@ -1,30 +0,0 @@
interface Env {}
export const onRequest: PagesFunction<Env> = async context => {
const id = context.params.id as string;
const next = await context.next();
try {
const rsp = await fetch(`https://api.snort.social/api/v1/og/tag/p/${id}`, {
method: "POST",
body: await next.arrayBuffer(),
headers: {
"user-agent": "Snort-Functions/1.0 (https://snort.social)",
"content-type": "text/plain",
},
});
if (rsp.ok) {
const body = await rsp.text();
if (body.length > 0) {
return new Response(body, {
headers: {
"content-type": "text/html",
},
});
}
}
} catch {
// ignore
}
return next;
};

View File

@ -1,7 +1,12 @@
module.exports = {
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "formatjs"],
plugins: ["@typescript-eslint", "formatjs", "react-refresh", "simple-import-sort"],
rules: {
"formatjs/enforce-id": [
"error",
@ -9,6 +14,12 @@ module.exports = {
idInterpolationPattern: "[sha512:contenthash:base64:6]",
},
],
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
"react-refresh/only-export-components": "error",
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"@typescript-eslint/no-unused-vars": "error",
},
root: true,
ignorePatterns: ["build/", "*.test.ts", "*.js"],

View File

@ -1,47 +0,0 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEApyUVkYJVwV7XgluUnllgCtrsdq1ctRICm5gQy8nd+aEdDQjA
CKPOWh5miLl/fAQVZGZy/JxavzXulwXo8238E6n6bmNB1Us2nuw7a0aW4iUSQ1Pt
P4ZhPpcrqeqMf+hp7iBW0nAHFy/aa2UR84d7tBmSk5J3NNrfBsZdUex/7FqF1EVv
mEzlc8kepU9lRXWFQDtZCllEZ1kY3SBJPm10h0g9saI8YIVRxUuNII5GHDYAE3hb
EmoY6fuSEoiXA8u0Yt9soBQxgxIhQVKSRPPoIPjGFOxsGHY6h8R9nx1kxhHKFRuV
nwsn0uWl/7yjhwyHanogJu73/WgelPcgP/hMDQIDAQABAoIBAAru+xU0oGVwzcoi
MXuWPxkWrwcoWfsiPXduIBMklleg+WSD4QPvqyzr9isVb0huf/O8W+M4WxtM7NmG
MnHSDP5ATThxV7obHGyS6WQgDvimEibDU66nHK9adim8RQqM6nkANo23dE9I+xGx
X9Y9U5M5ZQQwPYoAkzw/N5WHUerk+cSEYWYV8jDtO7wJhYOMu5qliPeuNOaWZ1W6
1uwr8A4ih69WwzugPuBSgBrPAW1c84zWIFN+njAugqPF5x8xp2uM3tUO9s5UlHJC
FWEuU40KcDT2utSUY+2HXSHbycF4KLKT5jAKSa4sPziLfo+YifrlN0Y3rhofUlZT
jCaeZ8ECgYEA5/xpk8aVhCEvv5iCghv0p/IHcjdXjx5+PCWh3Adx0fF91UvU5oqn
okdyYZDShZMuLDfJ0lG+OMKZd01JapnbTtiVNceVRMnraIdoWEM2/4bTXTSZGtdA
8gh/Kc/PMbPf5ppVWwqTCbUkPOSyGHyGc7+DQquq1w6yZu04A3x9vHECgYEAuHJk
uz8YKY5ZUR7CZ3y7YFuwq5Lcpl43AfiiCasjRch0P8yLrITc/6fORsXyy64XW9fC
h3YmXvEPaM03W2dxw2aQDvXEvXiEITzmILs7SE3UmZR9m7OMy7Jeqr3+JOc0ckZe
Rz5FfuMt1IvNB6lrpfHVtoVrpCOXpzHgC/k/x10CgYA6lU18GfwL/+107uiWPsUL
3FzxBPTBmau7OK2lSOP/ZoKmaJ39Eiq/GlfSN6ZSQRa55+S5jhcBcnMa45OUrgHp
6VvU1u/lDTC7luZM07yBzuR1dyDq3Ez0Uhz6zBXAsXHrZDIF6ae0HeBm2EH5WQkD
Fevp3DwqTvXSdDle+AMwoQKBgQCBSlaH1rNmNc0wCsK07f8ejUcrDZgz2mjurc1P
v7HK8bdjHUtvE/ciEguLGqiV06O2EmjesZg2Bv4JNYivPrTFBrjGc8qEEd10uw6J
NRVaGoyDV04w/UwdYRvwzZs/XP4reF4PzHvEdRSkH5cJ3t2BhiKLfby1YumkHlbx
rbbiVQKBgB02jyZUiB6pPTCP8vXZCJbZELgqNyS04ALhBBpdfGMcU1+0hRLJFBaE
tClJPGARFXl+MPkY032vmJZOuH3LrcTCm8DmMLzM/hT1EWawQ8BJkkwiIokE4lqc
Bi8CrkvuQs2cuCStK6C3Nkyr1lTkDge46trsb7KTcfHdtLsS7EPj
-----END RSA PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIDWzCCAkOgAwIBAgIJDji8iiceMvQlMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
BAMTCWxvY2FsaG9zdDAeFw0yMzEwMTYwOTI0MThaFw0yMzExMTUxMDI0MThaMBQx
EjAQBgNVBAMTCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBAKclFZGCVcFe14JblJ5ZYAra7HatXLUSApuYEMvJ3fmhHQ0IwAijzloeZoi5
f3wEFWRmcvycWr817pcF6PNt/BOp+m5jQdVLNp7sO2tGluIlEkNT7T+GYT6XK6nq
jH/oae4gVtJwBxcv2mtlEfOHe7QZkpOSdzTa3wbGXVHsf+xahdRFb5hM5XPJHqVP
ZUV1hUA7WQpZRGdZGN0gST5tdIdIPbGiPGCFUcVLjSCORhw2ABN4WxJqGOn7khKI
lwPLtGLfbKAUMYMSIUFSkkTz6CD4xhTsbBh2OofEfZ8dZMYRyhUblZ8LJ9Llpf+8
o4cMh2p6ICbu9/1oHpT3ID/4TA0CAwEAAaOBrzCBrDAMBgNVHRMEBTADAQH/MAsG
A1UdDwQEAwIC9DAxBgNVHSUEKjAoBggrBgEFBQcDAQYIKwYBBQUHAwIGCCsGAQUF
BwMDBggrBgEFBQcDCDBcBgNVHREEVTBTgglsb2NhbGhvc3SCFWxvY2FsaG9zdC5s
b2NhbGRvbWFpboIGbHZoLm1lgggqLmx2aC5tZYIFWzo6MV2HBH8AAAGHEP6AAAAA
AAAAAAAAAAAAAAEwDQYJKoZIhvcNAQELBQADggEBABY0rgWuzLYvVtvoVvWKS9cg
8rVhBRIFvpYO814ocN1iaxYQ9t9uLRsJXj0K+z1BHWf0zBiw4mB3dD9VpiKpuliL
4tRT+vATA96OYCd9G5k7DFQascAau40H3jxckh9rimIWa45FUSd7FIcddo1jeciv
gdAdiNUuHBen82O8KHJb+1PCBdA8RYeO5EGKfJM2yrOovu7dAFilf1ZPkXWgXnfG
nN6YfDDo9rAVDbvNXImrkwmGqEcN3Pq909IHiM/VETlU5lP4AbTNgrDa/aaZ+I+b
1MC1p87MvnibyXs+rTlK5+j8E6noNcD7tsHNd4ufkVCqr+pvSpuA3OvnXjbbm54=
-----END CERTIFICATE-----

View File

@ -30,7 +30,7 @@
"communityLeaders": {
"list": "naddr1qq4xc6tnw3ez6vp58y6rywpjxckngdtyxukngwr9vckkze33vcknzcnrxcenje35xqmn2cczyp3lucccm3v9s087z6qslpkap8schltk427zfgqgrn3g2menq5zw6qcyqqq82vqprpmhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv7rajfl"
},
"noteCreatorToast": true,
"noteCreatorToast": false,
"hideFromNavbar": ["/graph"],
"deckSubKind": 1,
"eventLinkPrefix": "nevent",
@ -40,5 +40,8 @@
"wss://nostr.wine/": { "read": true, "write": false },
"wss://eden.nostr.land/": { "read": true, "write": false }
},
"useIndexedDBEvents": false
"alby": {
"clientId": "pohiJjPhQR",
"clientSecret": "GAl1YKLA3FveK1gLBYok"
}
}

View File

@ -17,7 +17,7 @@
"deck": true,
"zapPool": true,
"notificationGraph": false,
"communityLeaders": false
"communityLeaders": true
},
"signUp": {
"moderation": false,
@ -27,16 +27,19 @@
"bypassImgProxyError": true,
"preferLargeMedia": true
},
"communityLeaders": {
"list": "naddr1qq4xc6tnw3ez6vp58y6rywpjxckngdtyxukngwr9vckkze33vcknzcnrxcenje35xqmn2cczyp3lucccm3v9s087z6qslpkap8schltk427zfgqgrn3g2menq5zw6qcyqqq82vqprpmhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv7rajfl"
},
"noteCreatorToast": false,
"hideFromNavbar": [],
"eventLinkPrefix": "note",
"profileLinkPrefix": "npub",
"defaultRelays": {
"ws://localhost:7777": { "read": true, "write": true },
"wss://relay.snort.social/": { "read": true, "write": true },
"wss://nostr.wine/": { "read": true, "write": false },
"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
}
}

View File

@ -74,17 +74,27 @@ declare const CONFIG: {
communityLeaders?: {
list: string;
};
// Filter urls from nav sidebar
hideFromNavbar: Array<string>;
// Limit deck to certain subscriber tier
deckSubKind?: number;
showDeck?: boolean;
// Create toast notifications when publishing notes
noteCreatorToast: boolean;
eventLinkPrefix: NostrPrefix;
profileLinkPrefix: NostrPrefix;
defaultRelays: Record<string, RelaySettings>;
useIndexedDBEvents: boolean;
// Alby wallet oAuth config
alby?: {
clientId: string;
clientSecret: string;
};
};
/**

View File

@ -2,10 +2,10 @@
"name": "@snort/app",
"version": "0.1.24",
"dependencies": {
"@cashu/cashu-ts": "^0.6.1",
"@lightninglabs/lnc-web": "^0.2.3-alpha",
"@cashu/cashu-ts": "0.6.1",
"@lightninglabs/lnc-web": "^0.2.8-alpha",
"@noble/curves": "^1.0.0",
"@noble/hashes": "^1.2.0",
"@noble/hashes": "^1.3.3",
"@scure/base": "^1.1.1",
"@scure/bip32": "^1.3.0",
"@scure/bip39": "^1.1.1",
@ -40,9 +40,11 @@
"react-textarea-autosize": "^8.4.0",
"recharts": "^2.8.0",
"three": "^0.157.0",
"typescript-lru-cache": "^2.0.0",
"use-long-press": "^3.2.0",
"use-sync-external-store": "^1.2.0",
"uuid": "^9.0.0",
"workbox-cacheable-response": "^7.0.0",
"workbox-core": "^6.4.2",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
@ -95,10 +97,15 @@
"@vitejs/plugin-react": "^4.2.0",
"@webbtc/webln-types": "^2.1.0",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"@welldone-software/why-did-you-render": "^8.0.1",
"autoprefixer": "^10.4.16",
"config": "^3.3.9",
"eslint": "^8.48.0",
"eslint-plugin-formatjs": "^4.11.3",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-simple-import-sort": "^10.0.0",
"postcss": "^8.4.31",
"postcss-preset-env": "^9.2.0",
"prettier": "2.8.3",

View File

@ -1,3 +1,4 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
Sitemap: https://api.snort.social/api/v1/sitemap/index.xml

View File

@ -1,5 +1,6 @@
import { NostrEvent } from "@snort/system";
import { FeedCache } from "@snort/shared";
import { NostrEvent } from "@snort/system";
import { db } from "@/Db";
export class ChatCache extends FeedCache<NostrEvent> {

View File

@ -0,0 +1,16 @@
import { ExternalStore } from "@snort/shared";
class CommunityLeadersStore extends ExternalStore<Array<string>> {
#leaders: Array<string> = [];
setLeaders(arr: Array<string>) {
this.#leaders = arr;
this.notifyChange();
}
takeSnapshot(): string[] {
return [...this.#leaders];
}
}
export const LeadersStore = new CommunityLeadersStore();

View File

@ -1,7 +1,7 @@
import { FeedCache } from "@snort/shared";
import { db, EventInteraction } from "@/Db";
import { LoginStore } from "@/Login";
import { sha256 } from "@/SnortUtils";
import { LoginStore } from "@/Utils/Login";
export class EventInteractionCache extends FeedCache<EventInteraction> {
constructor() {
@ -9,7 +9,7 @@ export class EventInteractionCache extends FeedCache<EventInteraction> {
}
key(of: EventInteraction): string {
return sha256(of.event + of.by);
return `${of.event}:${of.by}`;
}
override async preload(): Promise<void> {
@ -30,7 +30,6 @@ export class EventInteractionCache extends FeedCache<EventInteraction> {
});
await this.bulkSet(toImport);
console.debug(`Imported dumb-zap-cache events: `, toImport.length);
window.localStorage.removeItem("zap-cache");
}
await this.buffer([...this.onTable]);

View File

@ -1,8 +1,10 @@
import { db } from "@/Db";
import { unixNowMs } from "@snort/shared";
import { EventKind, RequestBuilder, socialGraphInstance, TaggedNostrEvent } from "@snort/system";
import { db } from "@/Db";
import { LoginSession } from "@/Utils/Login";
import { RefreshFeedCache } from "./RefreshFeedCache";
import { LoginSession } from "@/Login";
export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
constructor() {
@ -42,6 +44,6 @@ export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
override async preload() {
await super.preload();
this.snapshot().forEach(e => socialGraphInstance.handleEvent(e));
this.cache.forEach(e => socialGraphInstance.handleEvent(e));
}
}

View File

@ -1,11 +1,11 @@
import debug from "debug";
import { EventKind, RequestBuilder, SystemInterface, TaggedNostrEvent } from "@snort/system";
import { unixNow, unixNowMs } from "@snort/shared";
import { EventKind, RequestBuilder, SystemInterface, TaggedNostrEvent } from "@snort/system";
import { db } from "@/Db";
import { Day, Hour } from "@/Utils/Const";
import { LoginSession } from "@/Utils/Login";
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
import { LoginSession } from "@/Login";
import { Day, Hour } from "@/Const";
const WindowSize = Hour * 6;
const MaxCacheWindow = Day * 7;
@ -42,7 +42,10 @@ export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
const filtered = evs.filter(a => this.#kinds.includes(a.kind));
if (filtered.length > 0) {
await this.bulkSet(filtered);
this.notifyChange(filtered.map(a => this.key(a)));
this.emit(
"change",
filtered.map(a => this.key(a)),
);
}
}
@ -63,9 +66,8 @@ export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
const oldest = await this.table?.orderBy("created_at").first();
this.#oldest = oldest?.created_at;
this.notifyChange(latest?.map(a => this.key(a)) ?? []);
debug(this.name)(`Loaded %d/%d in %d ms`, latest?.length ?? 0, keys.length, (unixNowMs() - start).toLocaleString());
this.emit("change", latest?.map(a => this.key(a)) ?? []);
this.log(`Loaded %d/%d in %d ms`, latest?.length ?? 0, keys.length, (unixNowMs() - start).toLocaleString());
}
async loadMore(system: SystemInterface, session: LoginSession, before: number) {
@ -95,7 +97,7 @@ export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
this.onTable.add(k);
});
this.notifyChange(latest?.map(a => this.key(a)) ?? []);
this.emit("change", latest?.map(a => this.key(a)) ?? []);
}
}
@ -128,7 +130,7 @@ export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
const allKeys = new Set(everything?.map(a => a.pubkey));
const missingKeys = keys.filter(a => !allKeys.has(a));
await this.backFill(system, missingKeys);
debug(this.name)(`Backfilled %d keys in %d ms`, missingKeys.length, (unixNowMs() - start).toLocaleString());
this.log(`Backfilled %d keys in %d ms`, missingKeys.length, (unixNowMs() - start).toLocaleString());
}
}
}

View File

@ -1,8 +1,10 @@
import { EventKind, EventPublisher, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { UnwrappedGift, db } from "@/Db";
import { findTag, unwrap } from "@/SnortUtils";
import { db, UnwrappedGift } from "@/Db";
import { findTag, unwrap } from "@/Utils";
import { LoginSession, LoginSessionType } from "@/Utils/Login";
import { RefreshFeedCache } from "./RefreshFeedCache";
import { LoginSession, LoginSessionType } from "@/Login";
export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
constructor() {

View File

@ -1,222 +0,0 @@
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);

View File

@ -1,9 +1,11 @@
import { EventKind, NostrEvent, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
import { LoginSession } from "@/Login";
import { NostrEventForSession, db } from "@/Db";
import { Day } from "@/Const";
import { unixNow } from "@snort/shared";
import { EventKind, NostrEvent, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { db, NostrEventForSession } from "@/Db";
import { Day } from "@/Utils/Const";
import { LoginSession } from "@/Utils/Login";
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
export class NotificationsCache extends RefreshFeedCache<NostrEventForSession> {
#kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt];
@ -31,7 +33,10 @@ export class NotificationsCache extends RefreshFeedCache<NostrEventForSession> {
forSession: pubKey,
})),
);
this.notifyChange(filtered.map(v => this.key(v)));
this.emit(
"change",
filtered.map(v => this.key(v)),
);
}
}

View File

@ -1,6 +1,7 @@
import { Payment, db } from "@/Db";
import { FeedCache } from "@snort/shared";
import { db, Payment } from "@/Db";
export class Payments extends FeedCache<Payment> {
constructor() {
super("PaymentsCache", db.payments);

View File

@ -1,6 +1,7 @@
import { FeedCache } from "@snort/shared";
import { EventPublisher, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { LoginSession } from "@/Login";
import { LoginSession } from "@/Utils/Login";
export type TWithCreated<T> = (T | Readonly<T>) & { created_at: number };
@ -23,7 +24,6 @@ export abstract class RefreshFeedCache<T> extends FeedCache<TWithCreated<T>> {
override async preload(): Promise<void> {
await super.preload();
// load all dms to memory
await this.buffer([...this.onTable]);
}
}

View File

@ -0,0 +1,6 @@
import { ParsedFragment } from "@snort/system";
import { LRUCache } from "typescript-lru-cache";
export const TextCache = new LRUCache<string, Array<ParsedFragment>>({
maxSize: 1000,
});

View File

@ -1,13 +1,13 @@
import { UserProfileCache, UserRelaysCache, RelayMetricCache } from "@snort/system";
import { RelayMetricCache, UserProfileCache, UserRelaysCache } from "@snort/system";
import { SnortSystemDb } from "@snort/system-web";
import { EventInteractionCache } from "./EventInteractionCache";
import { ChatCache } from "./ChatCache";
import { Payments } from "./PaymentsCache";
import { EventInteractionCache } from "./EventInteractionCache";
import { FollowListCache } from "./FollowListCache";
import { FollowsFeedCache } from "./FollowsFeed";
import { GiftWrapCache } from "./GiftWrapCache";
import { NotificationsCache } from "./Notifications";
import { FollowsFeedCache } from "./FollowsFeed";
import { FollowListCache } from "./FollowListCache";
import { Payments } from "./PaymentsCache";
export const SystemDb = new SnortSystemDb();
export const UserCache = new UserProfileCache(SystemDb.users);

View File

@ -1,8 +1,10 @@
import "./AsyncButton.css";
import React, { ForwardedRef } from "react";
import Spinner from "../../Icons/Spinner";
import useLoading from "@/Hooks/useLoading";
import classNames from "classnames";
import React, { ForwardedRef } from "react";
import Spinner from "@/Components/Icons/Spinner";
import useLoading from "@/Hooks/useLoading";
export interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
onClick?: (e: React.MouseEvent) => Promise<void> | void;
@ -29,4 +31,6 @@ const AsyncButton = React.forwardRef<HTMLButtonElement, AsyncButtonProps>((props
);
});
AsyncButton.displayName = "AsyncButton";
export default AsyncButton;

View File

@ -1,6 +1,6 @@
import Icon from "@/Icons/Icon";
import Icon from "@/Components/Icons/Icon";
import Spinner from "@/Components/Icons/Spinner";
import useLoading from "@/Hooks/useLoading";
import Spinner from "@/Icons/Spinner";
export type AsyncIconProps = React.HTMLProps<HTMLDivElement> & {
iconName: string;

View File

@ -1,7 +1,8 @@
import "./BackButton.css";
import { useIntl } from "react-intl";
import Icon from "@/Icons/Icon";
import Icon from "@/Components/Icons/Icon";
import messages from "../messages";

View File

@ -1,6 +1,7 @@
import Icon from "@/Icons/Icon";
import classNames from "classnames";
import Icon from "@/Components/Icons/Icon";
export default function CloseButton({ onClick, className }: { onClick?: () => void; className?: string }) {
return (
<div

View File

@ -1,7 +1,8 @@
import classNames from "classnames";
import Icon, { IconProps } from "@/Icons/Icon";
import type { ReactNode } from "react";
import Icon, { IconProps } from "@/Components/Icons/Icon";
interface IconButtonProps {
onClick?: () => void;
icon: IconProps;

View File

@ -1,8 +1,9 @@
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { logout } from "@/Login";
import useLogin from "@/Hooks/useLogin";
import { logout } from "@/Utils/Login";
import messages from "../messages";
export default function LogoutButton() {

View File

@ -1,8 +1,8 @@
import { useState, ReactNode } from "react";
import classNames from "classnames";
import { ReactNode, useState } from "react";
import Icon from "@/Icons/Icon";
import ShowMore from "@/Element/Event/ShowMore";
import ShowMore from "@/Components/Event/ShowMore";
import Icon from "@/Components/Icons/Icon";
interface CollapsedProps {
text?: string;

View File

@ -9,8 +9,8 @@ export default function AwardIcon({ size }: { size?: number }) {
x2="31"
y2="58.4286"
gradientUnits="userSpaceOnUse">
<stop stop-color="#5B2CB3" />
<stop offset="1" stop-color="#811EFF" />
<stop stopColor="#5B2CB3" />
<stop offset="1" stopColor="#811EFF" />
</linearGradient>
<linearGradient
id="paint1_linear_2660_40043"
@ -19,12 +19,12 @@ export default function AwardIcon({ size }: { size?: number }) {
x2="46.433"
y2="24.305"
gradientUnits="userSpaceOnUse">
<stop stop-color="#AC88FF" />
<stop offset="1" stop-color="#7234FF" />
<stop stopColor="#AC88FF" />
<stop offset="1" stopColor="#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" fill="#AC88FF" fillOpacity="0.2" />
<rect
x="1.85713"
y="1.85714"
@ -46,7 +46,7 @@ export default function AwardIcon({ size }: { size?: number }) {
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"
fillOpacity="0.1"
/>
</g>
</svg>

View File

@ -1,9 +1,10 @@
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";
import Modal from "../Modal/Modal";
import AwardIcon from "./Award";
export function LeaderBadge() {
const [showModal, setShowModal] = useState(false);

View File

@ -1,7 +1,9 @@
import "./Copy.css";
import classNames from "classnames";
import Icon from "@/Icons/Icon";
import { useCopy } from "@/useCopy";
import Icon from "@/Components/Icons/Icon";
import { useCopy } from "@/Hooks/useCopy";
export interface CopyProps {
text: string;

View File

@ -1,10 +1,11 @@
import "./CashuNuts.css";
import { useUserProfile } from "@snort/system-react";
import { useEffect, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { useUserProfile } from "@snort/system-react";
import Icon from "@/Components/Icons/Icon";
import useLogin from "@/Hooks/useLogin";
import Icon from "@/Icons/Icon";
interface Token {
token: Array<{

View File

@ -1,6 +1,7 @@
import { Link } from "react-router-dom";
import "./Hashtag.css";
import { Link } from "react-router-dom";
const Hashtag = ({ tag }: { tag: string }) => {
return (
<span className="hashtag">

View File

@ -1,27 +1,28 @@
import {
YoutubeUrlRegex,
TidalRegex,
SoundCloudRegex,
MixCloudRegex,
SpotifyRegex,
TwitchRegex,
AppleMusicRegex,
NostrNestsRegex,
WavlakeRegex,
} from "@/Const";
import { magnetURIDecode } from "@/SnortUtils";
import SoundCloudEmbed from "@/Element/Embed/SoundCloudEmded";
import MixCloudEmbed from "@/Element/Embed/MixCloudEmbed";
import SpotifyEmbed from "@/Element/Embed/SpotifyEmbed";
import TidalEmbed from "@/Element/Embed/TidalEmbed";
import TwitchEmbed from "@/Element/Embed/TwitchEmbed";
import AppleMusicEmbed from "@/Element/Embed/AppleMusicEmbed";
import WavlakeEmbed from "@/Element/Embed/WavlakeEmbed";
import LinkPreview from "@/Element/Embed/LinkPreview";
import NostrLink from "@/Element/Embed/NostrLink";
import MagnetLink from "@/Element/Embed/MagnetLink";
import { ReactNode } from "react";
import AppleMusicEmbed from "@/Components/Embed/AppleMusicEmbed";
import LinkPreview from "@/Components/Embed/LinkPreview";
import MagnetLink from "@/Components/Embed/MagnetLink";
import MixCloudEmbed from "@/Components/Embed/MixCloudEmbed";
import NostrLink from "@/Components/Embed/NostrLink";
import SoundCloudEmbed from "@/Components/Embed/SoundCloudEmded";
import SpotifyEmbed from "@/Components/Embed/SpotifyEmbed";
import TidalEmbed from "@/Components/Embed/TidalEmbed";
import TwitchEmbed from "@/Components/Embed/TwitchEmbed";
import WavlakeEmbed from "@/Components/Embed/WavlakeEmbed";
import { magnetURIDecode } from "@/Utils";
import {
AppleMusicRegex,
MixCloudRegex,
NostrNestsRegex,
SoundCloudRegex,
SpotifyRegex,
TidalRegex,
TwitchRegex,
WavlakeRegex,
YoutubeUrlRegex,
} from "@/Utils/Const";
interface HypeTextProps {
link: string;
children?: ReactNode | Array<ReactNode> | null;

View File

@ -1,12 +1,13 @@
import "./Invoice.css";
import { useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useMemo } from "react";
import { decodeInvoice } from "@snort/shared";
import classNames from "classnames";
import { useState } from "react";
import { useMemo } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import SendSats from "@/Element/SendSats";
import Icon from "@/Icons/Icon";
import Icon from "@/Components/Icons/Icon";
import SendSats from "@/Components/SendSats/SendSats";
import { useWallet } from "@/Wallet";
import messages from "../messages";

View File

@ -1,10 +1,12 @@
import "./LinkPreview.css";
import { CSSProperties, useEffect, useState } from "react";
import Spinner from "@/Icons/Spinner";
import { CSSProperties, useEffect, useState } from "react";
import { LRUCache } from "typescript-lru-cache";
import { MediaElement } from "@/Components/Embed/MediaElement";
import Spinner from "@/Components/Icons/Spinner";
import SnortApi, { LinkPreviewData } from "@/External/SnortApi";
import useImgProxy from "@/Hooks/useImgProxy";
import { MediaElement } from "@/Element/Embed/MediaElement";
async function fetchUrlPreviewInfo(url: string) {
const api = new SnortApi();
@ -15,18 +17,24 @@ async function fetchUrlPreviewInfo(url: string) {
}
}
const cache = new LRUCache<string, LinkPreviewData>({
maxSize: 100,
});
const LinkPreview = ({ url }: { url: string }) => {
const [preview, setPreview] = useState<LinkPreviewData | null>();
const [preview, setPreview] = useState<LinkPreviewData | null>(cache.get(url));
const { proxy } = useImgProxy();
useEffect(() => {
(async () => {
if (preview) return;
const data = await fetchUrlPreviewInfo(url);
if (data) {
const type = data.og_tags?.find(a => a[0].toLowerCase() === "og:type");
const canPreviewType = type?.[1].startsWith("image") || type?.[1].startsWith("video") || false;
if (canPreviewType || data.image) {
setPreview(data);
cache.set(url, data);
return;
}
}

View File

@ -1,6 +1,6 @@
import { FormattedMessage } from "react-intl";
import { Magnet } from "@/SnortUtils";
import { Magnet } from "@/Utils";
interface MagnetLinkProps {
magnet: Magnet;

View File

@ -1,10 +1,11 @@
import { ProxyImg } from "@/Element/ProxyImg";
import useImgProxy from "@/Hooks/useImgProxy";
import { IMeta } from "@snort/system";
import React, { CSSProperties, useEffect, useMemo, useRef } from "react";
import classNames from "classnames";
import React, { CSSProperties, useEffect, useMemo, useRef } from "react";
import { useInView } from "react-intersection-observer";
import { ProxyImg } from "@/Components/ProxyImg";
import useImgProxy from "@/Hooks/useImgProxy";
interface MediaElementProps {
mime: string;
url: string;
@ -40,7 +41,7 @@ const ImageElement = ({ url, meta, onMediaClick }: ImageElementProps) => {
style.height = `${Math.min(document.body.clientHeight * 0.8, meta.height * scale)}px`;
}
return style;
}, [imageRef.current, meta]);
}, [imageRef?.current, meta]);
return (
<div

View File

@ -1,11 +1,11 @@
import { NostrLink, NostrPrefix } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import DisplayName from "@/Element/User/DisplayName";
import { ProfileCard } from "@/Element/User/ProfileCard";
import { ProfileLink } from "@/Element/User/ProfileLink";
import { useCallback, useRef, useState } from "react";
import DisplayName from "@/Components/User/DisplayName";
import { ProfileCard } from "@/Components/User/ProfileCard";
import { ProfileLink } from "@/Components/User/ProfileLink";
export default function Mention({ link }: { link: NostrLink }) {
const profile = useUserProfile(link.id);
const [isHovering, setIsHovering] = useState(false);

View File

@ -1,5 +1,5 @@
import { MixCloudRegex } from "@/Const";
import useLogin from "@/Hooks/useLogin";
import { MixCloudRegex } from "@/Utils/Const";
const MixCloudEmbed = ({ link }: { link: string }) => {
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2);

View File

@ -1,8 +1,8 @@
import { Link } from "react-router-dom";
import { NostrPrefix, tryParseNostrLink } from "@snort/system";
import { Link } from "react-router-dom";
import Mention from "@/Element/Embed/Mention";
import NoteQuote from "@/Element/Event/NoteQuote";
import Mention from "@/Components/Embed/Mention";
import NoteQuote from "@/Components/Event/Note/NoteQuote";
export default function NostrLink({ link, depth }: { link: string; depth?: number }) {
const nav = tryParseNostrLink(link);

View File

@ -1,15 +1,15 @@
import { LNURL } from "@snort/shared";
import { NostrEvent } from "@snort/system";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { LNURL } from "@snort/shared";
import { dedupe, findTag, hexToBech32, getDisplayName } from "@/SnortUtils";
import FollowListBase from "@/Element/User/FollowListBase";
import AsyncButton from "@/Element/Button/AsyncButton";
import { useWallet } from "@/Wallet";
import { Toastore } from "@/Toaster";
import { UserCache } from "@/Cache";
import useLogin from "@/Hooks/useLogin";
import AsyncButton from "@/Components/Button/AsyncButton";
import { Toastore } from "@/Components/Toaster/Toaster";
import FollowListBase from "@/Components/User/FollowListBase";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { dedupe, findTag, getDisplayName, hexToBech32 } from "@/Utils";
import { useWallet } from "@/Wallet";
import { WalletInvoiceState } from "@/Wallet";
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { TidalRegex } from "@/Const";
import { TidalRegex } from "@/Utils/Const";
// Re-use dom parser across instances of TidalEmbed
const domParser = new DOMParser();

View File

@ -1,10 +1,11 @@
import "./ZapstrEmbed.css";
import { Link } from "react-router-dom";
import { NostrEvent, NostrLink } from "@snort/system";
import { ProxyImg } from "@/Element/ProxyImg";
import ProfileImage from "@/Element/User/ProfileImage";
import { NostrEvent, NostrLink } from "@snort/system";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import { ProxyImg } from "@/Components/ProxyImg";
import ProfileImage from "@/Components/User/ProfileImage";
export default function ZapstrEmbed({ ev }: { ev: NostrEvent }) {
const media = ev.tags.find(a => a[0] === "media");
@ -24,7 +25,7 @@ export default function ZapstrEmbed({ ev }: { ev: NostrEvent }) {
<audio src={media?.[1] ?? ""} controls={true} />
<div>
{refPersons.map(a => (
<ProfileImage pubkey={a[1]} subHeader={<>{a[2] ?? ""}</>} className="" defaultNip=" " />
<ProfileImage key={a[1]} pubkey={a[1]} subHeader={<>{a[2] ?? ""}</>} className="" defaultNip=" " />
))}
</div>
</div>

View File

@ -1,5 +1,7 @@
import React from "react";
import { trackEvent } from "@/Utils";
interface ErrorBoundaryState {
hasError: boolean;
errorMessage?: string;
@ -21,6 +23,7 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Caught an error:", error, errorInfo);
trackEvent("error", { error: error.message, errorInfo: JSON.stringify(errorInfo) });
}
render() {

View File

@ -1,7 +1,9 @@
import { OfflineError } from "@snort/shared";
import { Offline } from "./Offline";
import classNames from "classnames";
import Icon from "@/Icons/Icon";
import Icon from "@/Components/Icons/Icon";
import { Offline } from "./Offline";
export function ErrorOrOffline({
error,

View File

@ -1,12 +1,3 @@
.note-creator {
border: 1px solid transparent;
border-radius: 12px;
box-shadow: 0px 0px 6px 1px rgba(182, 108, 156, 0.3);
background:
linear-gradient(var(--gray-superdark), var(--gray-superdark)) padding-box,
linear-gradient(90deg, #ef9644, #fd7c49, #ff5e58, #ff3b70, #ff088e, #eb00b1, #c31ed5, #7b41f6) border-box;
}
.note-creator-modal .modal-body > div {
display: flex;
flex-direction: column;
@ -86,17 +77,6 @@
height: 32px;
}
.light .note-creator textarea {
background-color: var(--gray-superdark);
}
.light .note-creator {
box-shadow: 0px 0px 6px 1px rgba(182, 108, 156, 0.3);
background:
linear-gradient(var(--gray-superdark), var(--gray-superdark)) padding-box,
linear-gradient(90deg, #ef9644, #fd7c49, #ff5e58, #ff3b70, #ff088e, #eb00b1, #c31ed5, #7b41f6) border-box;
}
.note-creator-modal .rti--container {
background-color: unset !important;
box-shadow: unset !important;

View File

@ -1,33 +1,56 @@
import "./NoteCreator.css";
import { FormattedMessage, useIntl } from "react-intl";
import { fetchNip05Pubkey, unixNow } from "@snort/shared";
import { EventBuilder, EventKind, NostrLink, NostrPrefix, TaggedNostrEvent, tryParseNostrLink } from "@snort/system";
import classNames from "classnames";
import { TagsInput } from "react-tag-input-component";
import Icon from "@/Icons/Icon";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { appendDedupe, openFile, trackEvent } from "@/SnortUtils";
import Textarea from "@/Element/Textarea";
import Modal from "@/Element/Modal";
import ProfileImage from "@/Element/User/ProfileImage";
import useFileUpload from "@/Upload";
import Note from "@/Element/Event/Note";
import { ClipboardEventHandler, DragEvent, useEffect } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import { AsyncIcon } from "@/Components/Button/AsyncIcon";
import CloseButton from "@/Components/Button/CloseButton";
import { sendEventToRelays } from "@/Components/Event/Create/util";
import Note from "@/Components/Event/EventComponent";
import Icon from "@/Components/Icons/Icon";
import { ToggleSwitch } from "@/Components/Icons/Toggle";
import Modal from "@/Components/Modal/Modal";
import Textarea from "@/Components/Textarea/Textarea";
import { Toastore } from "@/Components/Toaster/Toaster";
import ProfileImage from "@/Components/User/ProfileImage";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { GetPowWorker } from "@/index";
import AsyncButton from "@/Element/Button/AsyncButton";
import { AsyncIcon } from "@/Element/Button/AsyncIcon";
import { fetchNip05Pubkey, unixNow } from "@snort/shared";
import { ZapTarget } from "@/Zapper";
import { useNoteCreator } from "@/State/NoteCreator";
import { openFile, trackEvent } from "@/Utils";
import useFileUpload from "@/Utils/Upload";
import { GetPowWorker } from "@/Utils/wasm";
import { ZapTarget } from "@/Utils/Zapper";
import FileUploadProgress from "../FileUpload";
import { ToggleSwitch } from "@/Icons/Toggle";
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";
const previewNoteOptions = {
showContextMenu: false,
showFooter: false,
canClick: false,
showTime: false,
};
const replyToNoteOptions = {
showFooter: false,
showContextMenu: false,
showProfileCard: false,
showTime: false,
canClick: false,
longFormPreview: true,
};
const quoteNoteOptions = {
showFooter: false,
showContextMenu: false,
showTime: false,
canClick: false,
longFormPreview: true,
};
export function NoteCreator() {
const { formatMessage } = useIntl();
@ -172,6 +195,16 @@ export function NoteCreator() {
props ??= {};
props["zap-split"] = true;
}
if (note.hashTags.length > 0) {
props ??= {};
props["hashtags"] = true;
}
if (props) {
props["content-warning"] ??= false;
props["poll"] ??= false;
props["zap-split"] ??= false;
props["hashtags"] ??= false;
}
trackEvent("PostNote", props);
const events = (note.otherEvents ?? []).concat(ev);
@ -282,18 +315,7 @@ export function NoteCreator() {
function getPreviewNote() {
if (note.preview) {
return (
<Note
data={note.preview as TaggedNostrEvent}
related={[]}
options={{
showContextMenu: false,
showFooter: false,
canClick: false,
showTime: false,
}}
/>
);
return <Note data={note.preview as TaggedNostrEvent} options={previewNoteOptions} />;
}
}
@ -345,7 +367,7 @@ export function NoteCreator() {
{Object.keys(relays.item || {})
.filter(el => relays.item[el].write)
.map((r, i, a) => (
<div className="p flex justify-between note-creator-relay">
<div className="p flex justify-between note-creator-relay" key={r}>
<div>{r}</div>
<div>
<input
@ -406,8 +428,8 @@ export function NoteCreator() {
</h4>
<FormattedMessage defaultMessage="Zaps on this note will be split to the following users." id="LwYmVi" />
<div className="flex flex-col g8">
{[...(note.zapSplits ?? [])].map((v, i, arr) => (
<div className="flex items-center g8">
{[...(note.zapSplits ?? [])].map((v: ZapTarget, i, arr) => (
<div className="flex items-center g8" key={`${v.name}-${v.value}`}>
<div className="flex flex-col flex-4 g4">
<h4>
<FormattedMessage defaultMessage="Recipient" id="8Rkoyb" />
@ -590,19 +612,10 @@ export function NoteCreator() {
<h4>
<FormattedMessage defaultMessage="Reply To" id="8ED/4u" />
</h4>
<Note
data={note.replyTo}
related={[]}
options={{
showFooter: false,
showContextMenu: false,
showProfileCard: false,
showTime: false,
canClick: false,
showMedia: false,
longFormPreview: true,
}}
/>
<div className="h-64 overflow-y-auto">
<Note data={note.replyTo} options={replyToNoteOptions} />
</div>
<hr className="border-border-color border-1 -mx-6" />
</>
)}
{note.quote && (
@ -610,18 +623,10 @@ export function NoteCreator() {
<h4>
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
</h4>
<Note
data={note.quote}
related={[]}
options={{
showFooter: false,
showContextMenu: false,
showTime: false,
canClick: false,
showMedia: false,
longFormPreview: true,
}}
/>
<div className="h-64 overflow-y-auto">
<Note data={note.quote} options={quoteNoteOptions} />
</div>
<hr className="border-border-color border-1 -mx-6" />
</>
)}
{note.preview && getPreviewNote()}
@ -633,7 +638,6 @@ export function NoteCreator() {
onDragLeave={handleDragLeave}
onDrop={handleDrop}
autoFocus
className={classNames("textarea", { "textarea--focused": note.active })}
onChange={c => onChange(c)}
value={note.note}
onFocus={() => note.update(v => (v.active = true))}
@ -645,23 +649,6 @@ export function NoteCreator() {
/>
{renderPollOptions()}
</div>
<div className="flex flex-col g4">
<TagsInput
value={note.hashTags}
onChange={e => note.update(s => (s.hashTags = e))}
placeHolder={formatMessage({
defaultMessage: "Add up to 4 hashtags",
id: "AIgmDy",
})}
separators={["Enter", ","]}
/>
{note.hashTags.length > 4 && (
<small className="warning">
<FormattedMessage defaultMessage="Try to use less than 5 hashtags to stay on topic 🙏" id="d8gpCh" />
</small>
)}
<TrendingHashTagsLine onClick={t => note.update(s => (s.hashTags = appendDedupe(s.hashTags, [t])))} />
</div>
</>
)}
{uploader.progress.length > 0 && <FileUploadProgress progress={uploader.progress} />}

View File

@ -1,14 +1,15 @@
import { useRef, useMemo } from "react";
import { useLocation } from "react-router-dom";
import classNames from "classnames";
import { useMemo, useRef } from "react";
import { FormattedMessage } from "react-intl";
import { useLocation } from "react-router-dom";
import { isFormElement } from "@/SnortUtils";
import Icon from "@/Components/Icons/Icon";
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
import useLogin from "@/Hooks/useLogin";
import Icon from "@/Icons/Icon";
import { useNoteCreator } from "@/State/NoteCreator";
import { isFormElement } from "@/Utils";
import { NoteCreator } from "./NoteCreator";
import { FormattedMessage } from "react-intl";
export const NoteCreatorButton = ({
className,

View File

@ -1,16 +1,17 @@
import AsyncButton from "@/Element/Button/AsyncButton";
import IconButton from "@/Element/Button/IconButton";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import Icon from "@/Icons/Icon";
import { removeRelay } from "@/Login";
import { saveRelays } from "@/Pages/settings/Relays";
import { getRelayName } from "@/SnortUtils";
import { unwrap, sanitizeRelayUrl } from "@snort/shared";
import { sanitizeRelayUrl, unwrap } from "@snort/shared";
import { OkResponse } from "@snort/system";
import { useState } from "react";
import { useIntl } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import IconButton from "@/Components/Button/IconButton";
import Icon from "@/Components/Icons/Icon";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { saveRelays } from "@/Pages/settings/saveRelays";
import { getRelayName } from "@/Utils";
import { removeRelay } from "@/Utils/Login";
export function OkResponseRow({ rsp, close }: { rsp: OkResponse; close: () => void }) {
const [r, setResult] = useState(rsp);
const { formatMessage } = useIntl();

View File

@ -1,5 +1,5 @@
import { NostrEvent, OkResponse, SystemInterface } from "@snort/system";
import { removeUndefined } from "@snort/shared";
import { NostrEvent, OkResponse, SystemInterface } from "@snort/system";
export async function sendEventToRelays(
system: SystemInterface,

View File

@ -1,21 +1,42 @@
import "./Note.css";
import { ReactNode } from "react";
import "./EventComponent.css";
import { EventKind, NostrEvent, TaggedNostrEvent } from "@snort/system";
import { NostrFileElement } from "@/Element/Event/NostrFileHeader";
import ZapstrEmbed from "@/Element/Embed/ZapstrEmbed";
import PubkeyList from "@/Element/Embed/PubkeyList";
import { LiveEvent } from "@/Element/LiveEvent";
import { ZapGoal } from "@/Element/Event/ZapGoal";
import NoteReaction from "@/Element/Event/NoteReaction";
import ProfilePreview from "@/Element/User/ProfilePreview";
import { NoteInner } from "./NoteInner";
import { memo, ReactNode } from "react";
import PubkeyList from "@/Components/Embed/PubkeyList";
import ZapstrEmbed from "@/Components/Embed/ZapstrEmbed";
import ErrorBoundary from "@/Components/ErrorBoundary";
import { NostrFileElement } from "@/Components/Event/NostrFileHeader";
import NoteReaction from "@/Components/Event/NoteReaction";
import { ZapGoal } from "@/Components/Event/ZapGoal";
import { LiveEvent } from "@/Components/LiveStream/LiveEvent";
import ProfilePreview from "@/Components/User/ProfilePreview";
import { LongFormText } from "./LongFormText";
import ErrorBoundary from "@/Element/ErrorBoundary";
import { Note } from "./Note/Note";
export interface NotePropsOptions {
isRoot?: boolean;
showHeader?: boolean;
showContextMenu?: boolean;
showProfileCard?: boolean;
showTime?: boolean;
showPinned?: boolean;
showBookmarked?: boolean;
showFooter?: boolean;
showReactionsLink?: boolean;
showMedia?: boolean;
canUnpin?: boolean;
canUnbookmark?: boolean;
canClick?: boolean;
showMediaSpotlight?: boolean;
longFormPreview?: boolean;
truncate?: boolean;
}
export interface NoteProps {
data: TaggedNostrEvent;
className?: string;
related: readonly TaggedNostrEvent[];
highlight?: boolean;
ignoreModeration?: boolean;
onClick?: (e: TaggedNostrEvent) => void;
@ -23,28 +44,11 @@ export interface NoteProps {
searchedValue?: string;
threadChains?: Map<string, Array<NostrEvent>>;
context?: ReactNode;
options?: {
isRoot?: boolean;
showHeader?: boolean;
showContextMenu?: boolean;
showProfileCard?: boolean;
showTime?: boolean;
showPinned?: boolean;
showBookmarked?: boolean;
showFooter?: boolean;
showReactionsLink?: boolean;
showMedia?: boolean;
canUnpin?: boolean;
canUnbookmark?: boolean;
canClick?: boolean;
showMediaSpotlight?: boolean;
longFormPreview?: boolean;
truncate?: boolean;
};
options?: NotePropsOptions;
waitUntilInView?: boolean;
}
export default function Note(props: NoteProps) {
export default memo(function EventComponent(props: NoteProps) {
const { data: ev, className } = props;
let content;
@ -75,7 +79,6 @@ export default function Note(props: NoteProps) {
content = (
<LongFormText
ev={ev}
related={props.related}
isPreview={props.options?.longFormPreview ?? false}
onClick={() => props.onClick?.(ev)}
truncate={props.options?.truncate}
@ -83,8 +86,8 @@ export default function Note(props: NoteProps) {
);
break;
default:
content = <NoteInner {...props} />;
content = <Note {...props} />;
}
return <ErrorBoundary>{content}</ErrorBoundary>;
}
});

View File

@ -1,11 +1,11 @@
import Progress from "@/Element/Progress";
import { UploadProgress } from "@/Upload";
import Progress from "@/Components/Progress/Progress";
import { UploadProgress } from "@/Utils/Upload";
export default function FileUploadProgress({ progress }: { progress: Array<UploadProgress> }) {
return (
<div className="flex flex-col g8">
{progress.map(p => (
<div className="flex flex-col g2" id={p.id}>
<div key={p.id} className="flex flex-col g2" id={p.id}>
{p.file.name}
<Progress value={p.progress} status={p.stage} />
</div>

View File

@ -1,7 +1,8 @@
import messages from "../messages";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import messages from "../messages";
const HiddenNote = ({ children }: { children: React.ReactNode }) => {
const [show, setShow] = useState(false);
return show ? (

View File

@ -1,22 +1,23 @@
import "./LongFormText.css";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react";
import classNames from "classnames";
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";
import { findTag } from "@/SnortUtils";
import Text from "@/Element/Text";
import { Markdown } from "./Markdown";
import Text from "@/Components/Text/Text";
import ProfilePreview from "@/Components/User/ProfilePreview";
import useImgProxy from "@/Hooks/useImgProxy";
import ProfilePreview from "@/Element/User/ProfilePreview";
import NoteFooter from "./NoteFooter";
import NoteTime from "./NoteTime";
import classNames from "classnames";
import { findTag } from "@/Utils";
import { Markdown } from "./Markdown";
import NoteFooter from "./Note/NoteFooter";
import NoteTime from "./Note/NoteTime";
interface LongFormTextProps {
ev: TaggedNostrEvent;
isPreview: boolean;
related: ReadonlyArray<TaggedNostrEvent>;
onClick?: () => void;
truncate?: boolean;
}
@ -31,7 +32,8 @@ export function LongFormText(props: LongFormTextProps) {
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);
const related = useReactions("note:reactions", [NostrLink.fromEvent(props.ev)], undefined, false);
const { reactions, reposts, zaps } = useEventReactions(NostrLink.fromEvent(props.ev), related);
function previewText() {
return (

View File

@ -1,13 +1,13 @@
import "./Markdown.css";
import { ReactNode, forwardRef, useMemo } from "react";
import { transformText } from "@snort/system";
import { marked, Token } from "marked";
import markedFootnote, { Footnote, FootnoteRef, Footnotes } from "marked-footnote";
import { forwardRef, ReactNode, useMemo } from "react";
import { Link } from "react-router-dom";
import markedFootnote, { Footnotes, Footnote, FootnoteRef } from "marked-footnote";
import { ProxyImg } from "@/Element/ProxyImg";
import NostrLink from "@/Element/Embed/NostrLink";
import NostrLink from "@/Components/Embed/NostrLink";
import { ProxyImg } from "@/Components/ProxyImg";
interface MarkdownProps {
content: string;
@ -119,7 +119,7 @@ function renderToken(t: Token | Footnotes | Footnote | FootnoteRef, tags: Array<
}
}
export const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: MarkdownProps, ref) => {
const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: MarkdownProps, ref) => {
const parsed = useMemo(() => {
return marked.use(markedFootnote()).lexer(props.content);
}, [props.content, props.tags]);
@ -130,3 +130,7 @@ export const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: Markdo
</div>
);
});
Markdown.displayName = "Markdown";
export { Markdown };

View File

@ -1,17 +1,17 @@
import { FormattedMessage } from "react-intl";
import { NostrEvent, NostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import { FormattedMessage } from "react-intl";
import { findTag } from "@/SnortUtils";
import PageSpinner from "@/Element/PageSpinner";
import Reveal from "@/Element/Event/Reveal";
import { MediaElement } from "@/Element/Embed/MediaElement";
import { MediaElement } from "@/Components/Embed/MediaElement";
import Reveal from "@/Components/Event/Reveal";
import PageSpinner from "@/Components/PageSpinner";
import { findTag } from "@/Utils";
export default function NostrFileHeader({ link }: { link: NostrLink }) {
const ev = useEventFeed(link);
if (!ev.data) return <PageSpinner />;
return <NostrFileElement ev={ev.data} />;
if (!ev) return <PageSpinner />;
return <NostrFileElement ev={ev} />;
}
export function NostrFileElement({ ev }: { ev: NostrEvent }) {

View File

@ -0,0 +1,135 @@
import { EventKind, NostrLink } from "@snort/system";
import classNames from "classnames";
import React, { useCallback, useState } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import NoteHeader from "@/Components/Event/Note/NoteHeader";
import { NoteText } from "@/Components/Event/Note/NoteText";
import { TranslationInfo } from "@/Components/Event/Note/TranslationInfo";
import useModeration from "@/Hooks/useModeration";
import { findTag } from "@/Utils";
import { chainKey } from "@/Utils/Thread/ChainKey";
import messages from "../../messages";
import Text from "../../Text/Text";
import { NoteProps } from "../EventComponent";
import HiddenNote from "../HiddenNote";
import Poll from "../Poll";
import { NoteTranslation } from "./NoteContextMenu";
import NoteFooter from "./NoteFooter";
const defaultOptions = {
showHeader: true,
showTime: true,
showFooter: true,
canUnpin: false,
canUnbookmark: false,
showContextMenu: true,
};
const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls];
export function Note(props: NoteProps) {
const { data: ev, highlight, options: opt, ignoreModeration = false, className, waitUntilInView } = props;
const baseClassName = classNames("note min-h-[110px] flex flex-col gap-4 card", className ?? "");
const { isEventMuted } = useModeration();
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const [showTranslation, setShowTranslation] = useState(true);
const [translated, setTranslated] = useState<NoteTranslation>();
const optionsMerged = { ...defaultOptions, ...opt };
const goToEvent = useGoToEvent(props, optionsMerged);
if (!canRenderAsTextNote.includes(ev.kind)) {
return handleNonTextNote(ev);
}
function content() {
if (waitUntilInView && !inView) return null;
return (
<>
{optionsMerged.showHeader && <NoteHeader ev={ev} options={optionsMerged} setTranslated={setTranslated} />}
<div className="body" onClick={e => goToEvent(e, ev)}>
<NoteText {...props} translated={translated} showTranslation={showTranslation} />
{translated && <TranslationInfo translated={translated} setShowTranslation={setShowTranslation} />}
{ev.kind === EventKind.Polls && <Poll ev={ev} />}
</div>
{optionsMerged.showFooter && <NoteFooter ev={ev} replies={props.threadChains?.get(chainKey(ev))?.length} />}
</>
);
}
const noteElement = (
<div
className={classNames(baseClassName, {
active: highlight,
"hover:bg-nearly-bg-color cursor-pointer": !opt?.isRoot,
})}
onClick={e => goToEvent(e, ev)}
ref={ref}>
{content()}
</div>
);
return !ignoreModeration && isEventMuted(ev) ? <HiddenNote>{noteElement}</HiddenNote> : noteElement;
}
function useGoToEvent(props, options) {
const navigate = useNavigate();
return useCallback(
(e, eTarget) => {
if (options?.canClick === false) {
return;
}
let target = e.target as HTMLElement | null;
while (target) {
if (
target.tagName === "A" ||
target.tagName === "BUTTON" ||
target.classList.contains("reaction-pill") ||
target.classList.contains("szh-menu-container")
) {
return;
}
target = target.parentElement;
}
e.stopPropagation();
if (props.onClick) {
props.onClick(eTarget);
return;
}
const link = NostrLink.fromEvent(eTarget);
if (e.metaKey) {
window.open(`/${link.encode(CONFIG.eventLinkPrefix)}`, "_blank");
} else {
navigate(`/${link.encode(CONFIG.eventLinkPrefix)}`, { state: eTarget });
}
},
[navigate, props, options],
);
}
function handleNonTextNote(ev) {
const alt = findTag(ev, "alt");
if (alt) {
return (
<div className="note-quote">
<Text id={ev.id} content={alt} tags={[]} creator={ev.pubkey} />
</div>
);
} else {
return (
<>
<h4>
<FormattedMessage {...messages.UnknownEventKind} values={{ kind: ev.kind }} />
</h4>
<pre>{JSON.stringify(ev, undefined, " ")}</pre>
</>
);
}
}

View File

@ -1,17 +1,18 @@
import { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import Icon from "@/Icons/Icon";
import { setPinned, setBookmarked } from "@/Login";
import messages from "@/Element/messages";
import Icon from "@/Components/Icons/Icon";
import messages from "@/Components/messages";
import SnortApi from "@/External/SnortApi";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { ReBroadcaster } from "../ReBroadcaster";
import SnortApi from "@/External/SnortApi";
import { SubscriptionType, getCurrentSubscription } from "@/Subscription";
import { setBookmarked, setPinned } from "@/Utils/Login";
import { getCurrentSubscription, SubscriptionType } from "@/Utils/Subscription";
import { ReBroadcaster } from "../../ReBroadcaster";
export interface NoteTranslation {
text: string;

View File

@ -1,52 +1,45 @@
import React, { forwardRef, useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
import { TaggedNostrEvent, ParsedZap, countLeadingZeros, NostrLink } from "@snort/system";
import { normalizeReaction } from "@snort/shared";
import { useUserProfile } from "@snort/system-react";
import { barrierQueue, normalizeReaction, processWorkQueue, WorkQueueItem } from "@snort/shared";
import { countLeadingZeros, NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions, useUserProfile } from "@snort/system-react";
import { Menu, MenuItem } from "@szhsin/react-menu";
import classNames from "classnames";
import React, { forwardRef, useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
import { formatShort } from "@/Number";
import { AsyncIcon, AsyncIconProps } from "@/Components/Button/AsyncIcon";
import { ZapsSummary } from "@/Components/Event/ZapsSummary";
import Icon from "@/Components/Icons/Icon";
import SendSats from "@/Components/SendSats/SendSats";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { delay, findTag, getDisplayName } from "@/SnortUtils";
import SendSats from "@/Element/SendSats";
import { ZapsSummary } from "@/Element/Event/Zap";
import { AsyncIcon, AsyncIconProps } from "@/Element/Button/AsyncIcon";
import { useWallet } from "@/Wallet";
import useLogin from "@/Hooks/useLogin";
import { useInteractionCache } from "@/Hooks/useInteractionCache";
import { ZapPoolController } from "@/ZapPoolController";
import { Zapper, ZapTarget } from "@/Zapper";
import useLogin from "@/Hooks/useLogin";
import { useNoteCreator } from "@/State/NoteCreator";
import Icon from "@/Icons/Icon";
import { findTag, getDisplayName } from "@/Utils";
import { formatShort } from "@/Utils/Number";
import { Zapper, ZapTarget } from "@/Utils/Zapper";
import { ZapPoolController } from "@/Utils/ZapPoolController";
import { useWallet } from "@/Wallet";
import messages from "../messages";
import messages from "../../messages";
let isZapperBusy = false;
const barrierZapper = async <T,>(then: () => Promise<T>): Promise<T> => {
while (isZapperBusy) {
await delay(100);
}
isZapperBusy = true;
try {
return await then();
} finally {
isZapperBusy = false;
}
};
const ZapperQueue: Array<WorkQueueItem> = [];
processWorkQueue(ZapperQueue);
export interface NoteFooterProps {
reposts: TaggedNostrEvent[];
zaps: ParsedZap[];
positive: TaggedNostrEvent[];
replies?: number;
ev: TaggedNostrEvent;
}
export default function NoteFooter(props: NoteFooterProps) {
const { ev, positive, reposts, zaps } = props;
const { ev } = props;
const link = useMemo(() => NostrLink.fromEvent(ev), [ev.id]);
const ids = useMemo(() => [link], [link]);
const related = useReactions("note:reactions", ids, undefined, false);
const { reactions, zaps, reposts } = useEventReactions(link, related);
const { positive } = reactions;
const { formatMessage } = useIntl();
const {
publicKey,
@ -120,7 +113,7 @@ export default function NoteFooter(props: NoteFooterProps) {
name: getDisplayName(author, ev.pubkey),
zap: {
pubkey: ev.pubkey,
event: NostrLink.fromEvent(ev),
event: link,
},
} as ZapTarget,
];
@ -151,7 +144,7 @@ export default function NoteFooter(props: NoteFooterProps) {
async function fastZapInner(targets: Array<ZapTarget>, amount: number) {
if (wallet) {
// only allow 1 invoice req/payment at a time to avoid hitting rate limits
await barrierZapper(async () => {
await barrierQueue(ZapperQueue, async () => {
const zapper = new Zapper(system, publisher);
const result = await zapper.send(wallet, targets, amount);
const totalSent = result.reduce((acc, v) => (acc += v.sent), 0);
@ -325,3 +318,5 @@ const AsyncFooterIcon = forwardRef((props: AsyncIconProps & { value: number }, r
</AsyncIcon>
);
});
AsyncFooterIcon.displayName = "AsyncFooterIcon";

View File

@ -1,5 +1,6 @@
import "./Note.css";
import ProfileImage from "@/Element/User/ProfileImage";
import "../EventComponent.css";
import ProfileImage from "@/Components/User/ProfileImage";
interface NoteGhostProps {
className?: string;

View File

@ -0,0 +1,90 @@
import { HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { NotePropsOptions } from "@/Components/Event/EventComponent";
import { NoteContextMenu, NoteTranslation } from "@/Components/Event/Note/NoteContextMenu";
import NoteTime from "@/Components/Event/Note/NoteTime";
import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
import ReplyTag from "@/Components/Event/Note/ReplyTag";
import Icon from "@/Components/Icons/Icon";
import messages from "@/Components/messages";
import ProfileImage from "@/Components/User/ProfileImage";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { setBookmarked, setPinned } from "@/Utils/Login";
export default function NoteHeader(props: {
ev: TaggedNostrEvent;
options: NotePropsOptions;
setTranslated: (t: NoteTranslation) => void;
context?: React.ReactNode;
}) {
const [showReactions, setShowReactions] = useState(false);
const { ev, options, setTranslated } = props;
const { formatMessage } = useIntl();
const { pinned, bookmarked } = useLogin();
const { publisher, system } = useEventPublisher();
const login = useLogin();
async function unpin(id: HexKey) {
if (options.canUnpin && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
const es = pinned.item.filter(e => e !== id);
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
}
async function unbookmark(id: HexKey) {
if (options.canUnbookmark && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
const es = bookmarked.item.filter(e => e !== id);
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}
}
return (
<div className="header flex">
<ProfileImage
pubkey={ev.pubkey}
subHeader={<ReplyTag ev={ev} />}
link={options.canClick === undefined ? undefined : ""}
showProfileCard={options.showProfileCard ?? true}
showBadges={true}
/>
<div className="info">
{props.context}
{(options.showTime || options.showBookmarked) && (
<>
{options.showBookmarked && (
<div className={`saved ${options.canUnbookmark ? "pointer" : ""}`} onClick={() => unbookmark(ev.id)}>
<Icon name="bookmark" /> <FormattedMessage {...messages.Bookmarked} />
</div>
)}
{!options.showBookmarked && <NoteTime from={ev.created_at * 1000} />}
</>
)}
{options.showPinned && (
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.id)}>
<Icon name="pin" /> <FormattedMessage {...messages.Pinned} />
</div>
)}
{options.showContextMenu && (
<NoteContextMenu
ev={ev}
react={async () => {}}
onTranslated={t => setTranslated(t)}
setShowReactions={setShowReactions}
/>
)}
</div>
<ReactionsModal show={showReactions} setShow={setShowReactions} event={ev} />
</div>
);
}

View File

@ -1,27 +1,21 @@
import { NostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import Note from "@/Element/Event/Note";
import PageSpinner from "@/Element/PageSpinner";
import Note from "@/Components/Event/EventComponent";
import PageSpinner from "@/Components/PageSpinner";
const options = {
showFooter: false,
truncate: true,
};
export default function NoteQuote({ link, depth }: { link: NostrLink; depth?: number }) {
const ev = useEventFeed(link);
if (!ev.data)
if (!ev)
return (
<div className="note-quote flex items-center justify-center h-[110px]">
<PageSpinner />
</div>
);
return (
<Note
data={ev.data}
related={[]}
className="note-quote"
depth={(depth ?? 0) + 1}
options={{
showFooter: false,
truncate: true,
}}
/>
);
return <Note data={ev} className="note-quote" depth={(depth ?? 0) + 1} options={options} />;
}

View File

@ -0,0 +1,97 @@
import React, { useState } from "react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import { NoteProps } from "@/Components/Event/EventComponent";
import { NoteTranslation } from "@/Components/Event/Note/NoteContextMenu";
import Reveal from "@/Components/Event/Reveal";
import Text from "@/Components/Text/Text";
import useLogin from "@/Hooks/useLogin";
const TEXT_TRUNCATE_LENGTH = 400;
export const NoteText = function InnerContent(
props: NoteProps & { translated: NoteTranslation; showTranslation?: boolean },
) {
const { data: ev, options, translated, showTranslation } = props;
const appData = useLogin(s => s.appData);
const [showMore, setShowMore] = useState(false);
const body = translated && showTranslation ? translated.text : ev?.content ?? "";
const id = translated && showTranslation ? `${ev.id}-translated` : ev.id;
const shouldTruncate = options?.truncate && body.length > TEXT_TRUNCATE_LENGTH;
const ToggleShowMore = () => (
<a
className="highlight"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setShowMore(!showMore);
}}>
{showMore ? (
<FormattedMessage defaultMessage="Show less" id="qyJtWy" />
) : (
<FormattedMessage defaultMessage="Show more" id="aWpBzj" />
)}
</a>
);
const innerContent = (
<>
{shouldTruncate && showMore && <ToggleShowMore />}
<Text
id={id}
highlighText={props.searchedValue}
content={body}
tags={ev.tags}
creator={ev.pubkey}
depth={props.depth}
disableMedia={!(options?.showMedia ?? true)}
disableMediaSpotlight={!(props.options?.showMediaSpotlight ?? true)}
truncate={shouldTruncate && !showMore ? TEXT_TRUNCATE_LENGTH : undefined}
/>
{shouldTruncate && !showMore && <ToggleShowMore />}
</>
);
if (!appData.item.showContentWarningPosts) {
const contentWarning = ev.tags.find(a => a[0] === "content-warning");
if (contentWarning) {
return (
<Reveal
message={
<>
<FormattedMessage
defaultMessage="The author has marked this note as a <i>sensitive topic</i>"
id="StKzTE"
values={{
i: c => <i>{c}</i>,
}}
/>
{contentWarning[1] && (
<>
&nbsp;
<FormattedMessage
defaultMessage="Reason: <i>{reason}</i>"
id="6OSOXl"
values={{
i: c => <i>{c}</i>,
reason: contentWarning[1],
}}
/>
</>
)}
. <FormattedMessage defaultMessage="Click here to load anyway" id="IoQq+a" />.{" "}
<Link to="/settings/moderation">
<i>
<FormattedMessage defaultMessage="Settings" id="D3idYv" />
</i>
</Link>
</>
}>
{innerContent}
</Reveal>
);
}
}
return innerContent;
};

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import React, { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
export interface NoteTimeProps {
@ -10,25 +10,10 @@ const secondsInAMinute = 60;
const secondsInAnHour = secondsInAMinute * 60;
const secondsInADay = secondsInAnHour * 24;
export default function NoteTime(props: NoteTimeProps) {
const { from, fallback } = props;
const [time, setTime] = useState<string | JSX.Element>(calcTime());
const absoluteTime = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "long",
}).format(from),
[from],
);
const isoDate = new Date(from).toISOString();
function calcTime() {
const fromDate = new Date(from);
const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback }) => {
const calcTime = useCallback((fromTime: number) => {
const currentTime = new Date();
const timeDifference = Math.floor((currentTime.getTime() - fromDate.getTime()) / 1000);
const timeDifference = Math.floor((currentTime.getTime() - fromTime) / 1000);
if (timeDifference < secondsInAMinute) {
return <FormattedMessage defaultMessage="now" id="kaaf1E" />;
@ -37,6 +22,7 @@ export default function NoteTime(props: NoteTimeProps) {
} else if (timeDifference < secondsInADay) {
return `${Math.floor(timeDifference / secondsInAnHour)}h`;
} else {
const fromDate = new Date(fromTime);
if (fromDate.getFullYear() === currentTime.getFullYear()) {
return fromDate.toLocaleDateString(undefined, {
month: "short",
@ -50,19 +36,27 @@ export default function NoteTime(props: NoteTimeProps) {
});
}
}
}
}, []);
const [time, setTime] = useState<string | ReactNode>(calcTime(from));
const absoluteTime = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "long",
}).format(from),
[from],
);
const isoDate = useMemo(() => new Date(from).toISOString(), [from]);
useEffect(() => {
setTime(calcTime());
const t = setInterval(() => {
setTime(s => {
const newTime = calcTime();
if (newTime !== s) {
return newTime;
}
return s;
});
const newTime = calcTime(from);
setTime(s => (s !== newTime ? newTime : s));
}, 60_000); // update every minute
return () => clearInterval(t);
}, [from]);
@ -71,4 +65,6 @@ export default function NoteTime(props: NoteTimeProps) {
{time || fallback}
</time>
);
}
};
export default NoteTime;

View File

@ -0,0 +1,116 @@
import "./ReactionsModal.css";
import { NostrLink, socialGraphInstance, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react";
import { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import CloseButton from "@/Components/Button/CloseButton";
import Icon from "@/Components/Icons/Icon";
import Modal from "@/Components/Modal/Modal";
import Tabs from "@/Components/Tabs/Tabs";
import ProfileImage from "@/Components/User/ProfileImage";
import { formatShort } from "@/Utils/Number";
import messages from "../../messages";
interface ReactionsModalProps {
show: boolean;
setShow(b: boolean): void;
event: TaggedNostrEvent;
}
const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
const { formatMessage } = useIntl();
const onClose = () => setShow(false);
const link = NostrLink.fromEvent(event);
const related = useReactions("note:reactions", [link], undefined, false);
const { reactions, zaps, reposts } = useEventReactions(link, related);
const { positive, negative } = reactions;
const sortEvents = (events: Array<TaggedNostrEvent>) =>
events.sort(
(a, b) => socialGraphInstance.getFollowDistance(a.pubkey) - socialGraphInstance.getFollowDistance(b.pubkey),
);
const likes = useMemo(() => sortEvents([...positive]), [positive]);
const dislikes = useMemo(() => sortEvents([...negative]), [negative]);
const sortedReposts = useMemo(() => sortEvents([...reposts]), [reposts]);
const total = positive.length + negative.length + zaps.length + reposts.length;
const createTab = (message, count, value, disabled = false) => ({
text: formatMessage(message, { n: count }),
value,
disabled,
});
const tabs = useMemo(() => {
const baseTabs = [
createTab(messages.Likes, likes.length, 0),
createTab(messages.Zaps, zaps.length, 1, zaps.length === 0),
createTab(messages.Reposts, reposts.length, 2, reposts.length === 0),
];
return dislikes.length !== 0 ? baseTabs.concat(createTab(messages.Dislikes, dislikes.length, 3)) : baseTabs;
}, [likes.length, zaps.length, reposts.length, dislikes.length, formatMessage]);
const [tab, setTab] = useState(tabs[0]);
useEffect(() => {
if (!show) {
setTab(tabs[0]);
}
}, [show, tabs]);
const renderReactionItem = (ev, icon, size) => (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
<Icon name={icon} size={size} />
</div>
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
);
return show ? (
<Modal id="reactions" className="reactions-modal" onClose={onClose}>
<CloseButton onClick={onClose} className="absolute right-4 top-3" />
<div className="reactions-header">
<h2>
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
</h2>
</div>
<Tabs tabs={tabs} tab={tab} setTab={setTab} />
<div className="reactions-body" key={tab.value}>
{tab.value === 0 && likes.map(ev => renderReactionItem(ev, "heart"))}
{tab.value === 1 &&
zaps.map(
z =>
z.sender && (
<div key={z.id} className="reactions-item">
<div className="zap-reaction-icon">
<Icon name="zap" size={20} />
<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}
overrideUsername={
z.anonZap ? formatMessage({ defaultMessage: "Anonymous", id: "LXxsbk" }) : undefined
}
/>
</div>
),
)}
{tab.value === 2 && sortedReposts.map(ev => renderReactionItem(ev, "repost", 16))}
{tab.value === 3 && dislikes.map(ev => renderReactionItem(ev, "dislike"))}
</div>
</Modal>
) : null;
};
export default ReactionsModal;

View File

@ -0,0 +1,67 @@
import { EventExt, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import React, { ReactNode } from "react";
import { useIntl } from "react-intl";
import { Link } from "react-router-dom";
import { UserCache } from "@/Cache";
import messages from "@/Components/messages";
import DisplayName from "@/Components/User/DisplayName";
import { ProfileLink } from "@/Components/User/ProfileLink";
import { hexToBech32 } from "@/Utils";
export default function ReplyTag({ ev }: { ev: TaggedNostrEvent }) {
const { formatMessage } = useIntl();
const thread = EventExt.extractThread(ev);
if (thread === undefined) {
return undefined;
}
const maxMentions = 2;
const replyTo = thread?.replyTo ?? thread?.root;
const replyLink = replyTo
? NostrLink.fromTag(
[replyTo.key, replyTo.value ?? "", replyTo.relay ?? "", replyTo.marker ?? ""].filter(a => a.length > 0),
)
: undefined;
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
for (const pk of thread?.pubKeys ?? []) {
const u = UserCache.getFromCache(pk);
const npub = hexToBech32(NostrPrefix.PublicKey, pk);
const shortNpub = npub.substring(0, 12);
mentions.push({
pk,
name: u?.name ?? shortNpub,
link: (
<ProfileLink pubkey={pk} user={u}>
<DisplayName pubkey={pk} user={u} />{" "}
</ProfileLink>
),
});
}
mentions.sort(a => (a.name.startsWith(NostrPrefix.PublicKey) ? 1 : -1));
const othersLength = mentions.length - maxMentions;
const renderMention = (m: { link: React.ReactNode; pk: string; name: string }, idx: number) => {
return (
<React.Fragment key={m.pk}>
{idx > 0 && ", "}
{m.link}
</React.Fragment>
);
};
const pubMentions =
mentions.length > maxMentions ? mentions?.slice(0, maxMentions).map(renderMention) : mentions?.map(renderMention);
const others = mentions.length > maxMentions ? formatMessage(messages.Others, { n: othersLength }) : "";
const link = replyLink?.encode(CONFIG.eventLinkPrefix);
return (
<div className="reply">
re:&nbsp;
{(mentions?.length ?? 0) > 0 ? (
<>
{pubMentions} {others}
</>
) : (
replyLink && <Link to={`/${link}`}>{link?.substring(0, 12)}</Link>
)}
</div>
);
}

View File

@ -0,0 +1,34 @@
import React from "react";
import { FormattedMessage } from "react-intl";
import { NoteTranslation } from "@/Components/Event/Note/NoteContextMenu";
import messages from "@/Components/messages";
interface TranslationInfoProps {
translated: NoteTranslation;
setShowTranslation: React.Dispatch<React.SetStateAction<boolean>>;
}
export function TranslationInfo({ translated, setShowTranslation }: TranslationInfoProps) {
if (translated && translated.confidence > 0.5) {
return (
<>
<span
className="text-xs font-semibold text-gray-light select-none"
onClick={e => {
e.stopPropagation();
setShowTranslation(show => !show);
}}>
<FormattedMessage {...messages.TranslatedFrom} values={{ lang: translated.fromLanguage }} />
</span>
</>
);
} else if (translated) {
return (
<p className="text-xs font-semibold text-gray-light">
<FormattedMessage {...messages.TranslationFailed} />
</p>
);
}
return null;
}

View File

@ -1,15 +1,16 @@
import "./NoteReaction.css";
import { Link } from "react-router-dom";
import { useMemo } from "react";
import { EventKind, NostrEvent, TaggedNostrEvent, NostrPrefix, EventExt } from "@snort/system";
import Note from "@/Element/Event/Note";
import { eventLink, hexToBech32, getDisplayName } from "@/SnortUtils";
import useModeration from "@/Hooks/useModeration";
import { FormattedMessage } from "react-intl";
import Icon from "@/Icons/Icon";
import { EventExt, EventKind, NostrEvent, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { useMemo } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import Note from "@/Components/Event/EventComponent";
import Icon from "@/Components/Icons/Icon";
import useModeration from "@/Hooks/useModeration";
import { eventLink, getDisplayName, hexToBech32 } from "@/Utils";
export interface NoteReactionProps {
data: TaggedNostrEvent;
@ -21,6 +22,16 @@ export default function NoteReaction(props: NoteReactionProps) {
const { isMuted } = useModeration();
const { inView, ref } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const profile = useUserProfile(inView ? ev.pubkey : "");
const root = useMemo(() => extractRoot(), [ev, props.root, inView]);
const opt = useMemo(
() => ({
showHeader: ev?.kind === EventKind.Repost || ev?.kind === EventKind.TextNote,
showFooter: false,
truncate: true,
}),
[ev],
);
const refEvent = useMemo(() => {
if (ev) {
@ -32,15 +43,6 @@ export default function NoteReaction(props: NoteReactionProps) {
return null;
}, [ev]);
if (
ev.kind !== EventKind.Reaction &&
ev.kind !== EventKind.Repost &&
(ev.kind !== EventKind.TextNote ||
ev.tags.every((a, i) => a[1] !== refEvent?.[1] || a[3] !== "mention" || ev.content !== `#[${i}]`))
) {
return null;
}
/**
* Some clients embed the reposted note in the content
*/
@ -62,17 +64,20 @@ export default function NoteReaction(props: NoteReactionProps) {
return props.root;
}
const root = useMemo(() => extractRoot(), [ev, props.root, inView]);
if (
ev.kind !== EventKind.Reaction &&
ev.kind !== EventKind.Repost &&
(ev.kind !== EventKind.TextNote ||
ev.tags.every((a, i) => a[1] !== refEvent?.[1] || a[3] !== "mention" || ev.content !== `#[${i}]`))
) {
return null;
}
if (!inView) {
return <div className="card reaction" ref={ref}></div>;
}
const isOpMuted = root && isMuted(root.pubkey);
const shouldNotBeRendered = isOpMuted || root?.kind !== EventKind.TextNote;
const opt = {
showHeader: ev?.kind === EventKind.Repost || ev?.kind === EventKind.TextNote,
showFooter: false,
};
return shouldNotBeRendered ? null : (
<div className="card reaction">
@ -86,7 +91,7 @@ export default function NoteReaction(props: NoteReactionProps) {
}}
/>
</div>
{root ? <Note truncate={true} data={root} options={opt} related={[]} depth={props.depth} /> : null}
{root ? <Note data={root} options={opt} depth={props.depth} /> : null}
{!root && refEvent ? (
<p>
<Link to={eventLink(refEvent[1] ?? "", refEvent[2])}>

View File

@ -1,16 +1,16 @@
import { TaggedNostrEvent, ParsedZap, NostrLink } from "@snort/system";
import { LNURL } from "@snort/shared";
import { NostrLink, ParsedZap, TaggedNostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { useState } from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { useUserProfile } from "@snort/system-react";
import Spinner from "@/Components/Icons/Spinner";
import SendSats from "@/Components/SendSats/SendSats";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { useWallet } from "@/Wallet";
import { unwrap } from "@/SnortUtils";
import { formatShort } from "@/Number";
import Spinner from "@/Icons/Spinner";
import SendSats from "@/Element/SendSats";
import useLogin from "@/Hooks/useLogin";
import { unwrap } from "@/Utils";
import { formatShort } from "@/Utils/Number";
import { useWallet } from "@/Wallet";
interface PollProps {
ev: TaggedNostrEvent;

View File

@ -1,6 +1,7 @@
import { WarningNotice } from "@/Element/WarningNotice";
import { useState } from "react";
import { WarningNotice } from "@/Components/WarningNotice/WarningNotice";
interface RevealProps {
message: React.ReactNode;
children: React.ReactNode;

View File

@ -1,11 +1,11 @@
import { FormattedMessage } from "react-intl";
import { FileExtensionRegex } from "@/Const";
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";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import { MediaElement } from "@/Components/Embed/MediaElement";
import Reveal from "@/Components/Event/Reveal";
import useLogin from "@/Hooks/useLogin";
import { FileExtensionRegex } from "@/Utils/Const";
interface RevealMediaProps {
creator: string;

View File

@ -1,8 +1,9 @@
import "./ShowMore.css";
import { FormattedMessage } from "react-intl";
import { useInView } from "react-intersection-observer";
import { useEffect } from "react";
import classNames from "classnames";
import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage } from "react-intl";
interface ShowMoreProps {
text?: string;

View File

@ -1,16 +1,18 @@
import "./Thread.css";
import { useMemo, useState, ReactNode, useContext } from "react";
import { EventExt, NostrPrefix, parseNostrLink, TaggedNostrEvent, u256 } from "@snort/system";
import classNames from "classnames";
import { Fragment, ReactNode, useCallback, useContext, useMemo, useState } from "react";
import { useIntl } from "react-intl";
import { useNavigate, useParams } from "react-router-dom";
import { TaggedNostrEvent, u256, NostrPrefix, EventExt, parseNostrLink, NostrLink } from "@snort/system";
import classNames from "classnames";
import { getAllLinkReactions, getLinkReactions } from "@/SnortUtils";
import BackButton from "@/Element/Button/BackButton";
import Note from "@/Element/Event/Note";
import NoteGhost from "@/Element/Event/NoteGhost";
import Collapsed from "@/Element/Collapsed";
import { ThreadContext, ThreadContextWrapper, chainKey } from "@/Hooks/useThreadContext";
import BackButton from "@/Components/Button/BackButton";
import Collapsed from "@/Components/Collapsed";
import Note from "@/Components/Event/EventComponent";
import NoteGhost from "@/Components/Event/Note/NoteGhost";
import { chainKey } from "@/Utils/Thread/ChainKey";
import { ThreadContext } from "@/Utils/Thread/ThreadContext";
import { ThreadContextWrapper } from "@/Utils/Thread/ThreadContextWrapper";
import messages from "../messages";
@ -31,17 +33,16 @@ interface SubthreadProps {
isLastSubthread?: boolean;
active: u256;
notes: readonly TaggedNostrEvent[];
related: readonly TaggedNostrEvent[];
chains: Map<u256, Array<TaggedNostrEvent>>;
onNavigate: (e: TaggedNostrEvent) => void;
}
const Subthread = ({ active, notes, related, chains, onNavigate }: SubthreadProps) => {
const Subthread = ({ active, notes, chains, onNavigate }: SubthreadProps) => {
const renderSubthread = (a: TaggedNostrEvent, idx: number) => {
const isLastSubthread = idx === notes.length - 1;
const replies = getReplies(a.id, chains);
return (
<>
<Fragment key={a.id}>
<div className={`subthread-container ${replies.length > 0 ? "subthread-multi" : ""}`}>
<Divider />
<Note
@ -49,9 +50,9 @@ const Subthread = ({ active, notes, related, chains, onNavigate }: SubthreadProp
className={`thread-note ${isLastSubthread && replies.length === 0 ? "is-last-note" : ""}`}
data={a}
key={a.id}
related={related}
onClick={onNavigate}
threadChains={chains}
waitUntilInView={idx > 5}
/>
<div className="line-container"></div>
</div>
@ -60,12 +61,11 @@ const Subthread = ({ active, notes, related, chains, onNavigate }: SubthreadProp
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
</>
</Fragment>
);
};
@ -75,9 +75,10 @@ const Subthread = ({ active, notes, related, chains, onNavigate }: SubthreadProp
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
note: TaggedNostrEvent;
isLast: boolean;
idx: number;
}
const ThreadNote = ({ active, note, isLast, isLastSubthread, related, chains, onNavigate }: ThreadNoteProps) => {
const ThreadNote = ({ active, note, isLast, isLastSubthread, chains, onNavigate, idx }: ThreadNoteProps) => {
const { formatMessage } = useIntl();
const replies = getReplies(note.id, chains);
const activeInReplies = replies.map(r => r.id).includes(active);
@ -97,9 +98,9 @@ const ThreadNote = ({ active, note, isLast, isLastSubthread, related, chains, on
className={classNames("thread-note", { "is-last-note": isLastVisibleNote })}
data={note}
key={note.id}
related={related}
onClick={onNavigate}
threadChains={chains}
waitUntilInView={idx > 5}
/>
<div className="line-container"></div>
</div>
@ -109,7 +110,6 @@ const ThreadNote = ({ active, note, isLast, isLastSubthread, related, chains, on
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
@ -119,7 +119,7 @@ const ThreadNote = ({ active, note, isLast, isLastSubthread, related, chains, on
);
};
const TierTwo = ({ active, isLastSubthread, notes, related, chains, onNavigate }: SubthreadProps) => {
const TierTwo = ({ active, isLastSubthread, notes, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes;
return (
@ -129,22 +129,23 @@ const TierTwo = ({ active, isLastSubthread, notes, related, chains, onNavigate }
onNavigate={onNavigate}
note={first}
chains={chains}
related={related}
isLastSubthread={isLastSubthread}
isLast={rest.length === 0}
idx={0}
/>
{rest.map((r: TaggedNostrEvent, idx: number) => {
const lastReply = idx === rest.length - 1;
return (
<ThreadNote
key={r.id}
active={active}
onNavigate={onNavigate}
note={r}
chains={chains}
related={related}
isLastSubthread={isLastSubthread}
isLast={lastReply}
idx={idx}
/>
);
})}
@ -152,7 +153,7 @@ const TierTwo = ({ active, isLastSubthread, notes, related, chains, onNavigate }
);
};
const TierThree = ({ active, isLastSubthread, notes, related, chains, onNavigate }: SubthreadProps) => {
const TierThree = ({ active, isLastSubthread, notes, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes;
const replies = getReplies(first.id, chains);
const hasMultipleNotes = rest.length > 0 || replies.length > 0;
@ -171,8 +172,8 @@ const TierThree = ({ active, isLastSubthread, notes, related, chains, onNavigate
className={classNames("thread-note", { "is-last-note": isLastSubthread && isLast })}
data={first}
key={first.id}
related={related}
threadChains={chains}
waitUntilInView={true}
/>
<div className="line-container"></div>
</div>
@ -182,7 +183,6 @@ const TierThree = ({ active, isLastSubthread, notes, related, chains, onNavigate
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
@ -205,9 +205,9 @@ const TierThree = ({ active, isLastSubthread, notes, related, chains, onNavigate
highlight={active === r.id}
data={r}
key={r.id}
related={related}
onClick={onNavigate}
threadChains={chains}
waitUntilInView={idx > 5}
/>
<div className="line-container"></div>
</div>
@ -236,10 +236,18 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
const isSingleNote = thread.chains?.size === 1 && [thread.chains.values].every(v => v.length === 0);
const { formatMessage } = useIntl();
function navigateThread(e: TaggedNostrEvent) {
thread.setCurrent(e.id);
//router.navigate(`/${NostrLink.fromEvent(e).encode()}`, { replace: true })
}
const rootOptions = useMemo(
() => ({ showReactionsLink: true, showMediaSpotlight: !props.disableSpotlight, isRoot: true }),
[props.disableSpotlight],
);
const navigateThread = useCallback(
(e: TaggedNostrEvent) => {
thread.setCurrent(e.id);
// navigate(`/${NostrLink.fromEvent(e).encode()}`, { replace: true });
},
[thread],
);
const parent = useMemo(() => {
if (thread.root) {
@ -260,10 +268,10 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
className={className}
key={note.id}
data={note}
related={getLinkReactions(thread.reactions, NostrLink.fromEvent(note))}
options={{ showReactionsLink: true, showMediaSpotlight: !props.disableSpotlight, isRoot: true }}
options={rootOptions}
onClick={navigateThread}
threadChains={thread.chains}
waitUntilInView={false}
/>
);
} else {
@ -277,18 +285,7 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
}
const replies = thread.chains.get(from);
if (replies && thread.current) {
return (
<Subthread
active={thread.current}
notes={replies}
related={getAllLinkReactions(
thread.reactions,
replies.map(a => NostrLink.fromEvent(a)),
)}
chains={thread.chains}
onNavigate={navigateThread}
/>
);
return <Subthread active={thread.current} notes={replies} chains={thread.chains} onNavigate={navigateThread} />;
}
}

View File

@ -0,0 +1,34 @@
import "./Zap.css";
import { ParsedZap } from "@snort/system";
import { FormattedMessage } from "react-intl";
import Text from "@/Components/Text/Text";
import ProfileImage from "@/Components/User/ProfileImage";
import useLogin from "@/Hooks/useLogin";
import { unwrap } from "@/Utils";
import { formatShort } from "@/Utils/Number";
import messages from "../messages";
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean }) => {
const { amount, content, sender, valid, receiver } = zap;
const pubKey = useLogin().publicKey;
return valid && sender ? (
<div className="card">
<div className="flex justify-between">
<ProfileImage pubkey={sender} showProfileCard={true} />
{receiver !== pubKey && showZapped && <ProfileImage pubkey={unwrap(receiver)} />}
<h3>
<FormattedMessage {...messages.Sats} values={{ n: formatShort(amount ?? 0) }} />
</h3>
</div>
{(content?.length ?? 0) > 0 && sender && (
<Text id={zap.id} creator={sender} content={unwrap(content)} tags={[]} />
)}
</div>
) : null;
};
export default Zap;

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