forked from Kieran/snort
Compare commits
151 Commits
0043b7e8bd
...
21e1202b97
Author | SHA1 | Date | |
---|---|---|---|
21e1202b97 | |||
ab8121c4b2 | |||
d3c9fef9af | |||
8c8a7c7e88 | |||
cb233f4ccb | |||
326ce2ba68 | |||
8cca297d6d | |||
a3fc25f64c | |||
a1f61e2d13 | |||
51758eaf5e | |||
5baffd00b9 | |||
e6a42db658 | |||
8e37e0fbed | |||
7220435d15 | |||
53488a9c59 | |||
1278867ad0 | |||
be4ee620ad | |||
87386c9950 | |||
baf6cc34ee | |||
071eed0d8c | |||
6f9a1fd706 | |||
90342325fd | |||
b686b8ff26 | |||
835385836f | |||
93608f817f | |||
9e2582ac81 | |||
35d7ec4685 | |||
062212f311 | |||
52adf6fb1f | |||
90b15ee668 | |||
7be4b0bd18 | |||
b8cdb4bf58 | |||
cf6b431d73 | |||
91f0afdb89 | |||
8a5a089b4d | |||
80fa5a132b | |||
1a4a76d7fa | |||
7073e8d9dd | |||
376096c5af | |||
3eeeee4b06 | |||
4455651d47 | |||
8216cb8741 | |||
18beed13c3 | |||
3dbbe5b0f0 | |||
64b0329ffe | |||
2624920c65 | |||
6026ed34a8 | |||
45245153ab | |||
44983068e4 | |||
7a409c1455 | |||
bbdfb43834 | |||
24e145a0a0 | |||
4b5c87acdf | |||
98e671ee45 | |||
a68cdeeb20 | |||
53754a5a69 | |||
898d8bfe02 | |||
3a42ec9029 | |||
927718e236 | |||
5d3abc553a | |||
ca2cb76380 | |||
8a75b5bce8 | |||
c80eb25d29 | |||
88924941a5 | |||
cae865a3e7 | |||
add3b45fcd | |||
d3bc1b1c1d | |||
a20c8dbbf4 | |||
5cae0ffeed | |||
0bb758ae41 | |||
30b21cfe91 | |||
84bda4e33d | |||
2fbe90a39e | |||
d25b6ef5f1 | |||
185aca0442 | |||
08241bbbf6 | |||
a54924f339 | |||
29ca482511 | |||
13da7f822c | |||
e6e7878e31 | |||
7c21747d7f | |||
5adaed2737 | |||
615f3ca504 | |||
79fad227c5 | |||
b6023a8d95 | |||
11e616c612 | |||
e905b4134d | |||
26a3e95086 | |||
5db33314a8 | |||
9ec27bcea3 | |||
27210f91ae | |||
283d5cafb3 | |||
02e6d5c98c | |||
b40a8cb9ad | |||
cb8318df56 | |||
1aadda1c6b | |||
39f4ee9c2b | |||
40912bc979 | |||
5eaffcae7d | |||
e3d282578d | |||
afcee97131 | |||
3a2facd899 | |||
aa938034c5 | |||
dee9a3de2c | |||
cc753d5708 | |||
f9302f3917 | |||
3eb290a594 | |||
f39831dde8 | |||
1a9e571b0f | |||
29a8db28dd | |||
70b85dcc9c | |||
956871e5e5 | |||
c4b74b3cb1 | |||
2f9fee56b1 | |||
b3f04c8cd9 | |||
e73aa303a8 | |||
f82109b6b3 | |||
2fa4065414 | |||
eefbc49384 | |||
db074316d7 | |||
629099670c | |||
2e5295a0f7 | |||
c8215b1408 | |||
3963db1a0a | |||
daad0bbe76 | |||
2e97546ab0 | |||
3fe3c7a98d | |||
046d4d97bd | |||
c612da125e | |||
a976909036 | |||
a622c459d7 | |||
36b9538aa6 | |||
f2bcd1100d | |||
5d259cee95 | |||
47d92fe171 | |||
7bc00b4624 | |||
1e08702072 | |||
afa6d39a56 | |||
5ea2eb711f | |||
e08da34aa8 | |||
c773ea0a0f | |||
eeb6ec9dd8 | |||
9f88b44b91 | |||
0442c3512c | |||
13f4ec3f30 | |||
2a2c713486 | |||
b143520901 | |||
26146106d4 | |||
d31a03a565 | |||
287ce32690 | |||
c2899eac26 |
49
functions/_middleware.ts
Normal file
49
functions/_middleware.ts
Normal 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;
|
||||
};
|
@ -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;
|
||||
};
|
@ -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;
|
||||
};
|
@ -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"],
|
||||
|
@ -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-----
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
12
packages/app/custom.d.ts
vendored
12
packages/app/custom.d.ts
vendored
@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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",
|
||||
|
@ -1,3 +1,4 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Sitemap: https://api.snort.social/api/v1/sitemap/index.xml
|
@ -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> {
|
||||
|
16
packages/app/src/Cache/CommunityLeadersStore.tsx
Normal file
16
packages/app/src/Cache/CommunityLeadersStore.tsx
Normal 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();
|
@ -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]);
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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);
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
6
packages/app/src/Cache/TextCache.tsx
Normal file
6
packages/app/src/Cache/TextCache.tsx
Normal 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,
|
||||
});
|
@ -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);
|
||||
|
@ -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;
|
@ -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;
|
@ -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";
|
||||
|
@ -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
|
@ -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;
|
@ -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() {
|
@ -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;
|
@ -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>
|
@ -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);
|
@ -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;
|
@ -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<{
|
@ -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">
|
@ -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;
|
@ -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";
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { Magnet } from "@/SnortUtils";
|
||||
import { Magnet } from "@/Utils";
|
||||
|
||||
interface MagnetLinkProps {
|
||||
magnet: Magnet;
|
@ -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
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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 }) {
|
@ -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();
|
@ -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>
|
@ -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() {
|
@ -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,
|
@ -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;
|
@ -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} />}
|
@ -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,
|
@ -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();
|
@ -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,
|
@ -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>;
|
||||
}
|
||||
});
|
@ -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>
|
@ -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 ? (
|
@ -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 (
|
@ -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 };
|
@ -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 }) {
|
135
packages/app/src/Components/Event/Note/Note.tsx
Normal file
135
packages/app/src/Components/Event/Note/Note.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
@ -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";
|
@ -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;
|
90
packages/app/src/Components/Event/Note/NoteHeader.tsx
Normal file
90
packages/app/src/Components/Event/Note/NoteHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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} />;
|
||||
}
|
97
packages/app/src/Components/Event/Note/NoteText.tsx
Normal file
97
packages/app/src/Components/Event/Note/NoteText.tsx
Normal 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] && (
|
||||
<>
|
||||
|
||||
<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;
|
||||
};
|
@ -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;
|
116
packages/app/src/Components/Event/Note/ReactionsModal.tsx
Normal file
116
packages/app/src/Components/Event/Note/ReactionsModal.tsx
Normal 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;
|
67
packages/app/src/Components/Event/Note/ReplyTag.tsx
Normal file
67
packages/app/src/Components/Event/Note/ReplyTag.tsx
Normal 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:
|
||||
{(mentions?.length ?? 0) > 0 ? (
|
||||
<>
|
||||
{pubMentions} {others}
|
||||
</>
|
||||
) : (
|
||||
replyLink && <Link to={`/${link}`}>{link?.substring(0, 12)}</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
34
packages/app/src/Components/Event/Note/TranslationInfo.tsx
Normal file
34
packages/app/src/Components/Event/Note/TranslationInfo.tsx
Normal 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;
|
||||
}
|
@ -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])}>
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
34
packages/app/src/Components/Event/Zap.tsx
Normal file
34
packages/app/src/Components/Event/Zap.tsx
Normal 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
Loading…
Reference in New Issue
Block a user