Compare commits

..

843 Commits

Author SHA1 Message Date
Keyan
eaa15b3b43
Update awards.csv with all 3 search issue closes 2025-03-24 14:41:23 -05:00
Keyan
9b4f1643a7
Update awards.csv 2025-03-24 14:35:22 -05:00
Keyan
411c8317b0
Update awards.csv using failed dangling github actions 2025-03-24 14:25:54 -05:00
github-actions[bot]
c39bfcecb6
Extending awards.csv (#2013)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-03-24 13:31:46 -05:00
Edward Kung
f20ebad772
allow sort for search queries with only filters (#2012) 2025-03-23 11:53:45 -05:00
ekzyis
3ff03960eb
Remove unused addAccount, removeAccount (#2009) 2025-03-23 11:53:04 -05:00
ekzyis
9b08988402
Refactor login cookie with cookieOptions function (#2003) 2025-03-22 19:36:04 -05:00
ekzyis
b54268a88f
normalized wallet logs (#1826)
* Add invoiceId, withdrawalId to wallet logs

* Truncate wallet logs

* Fix extra db dips per log line

* Fix leak of invoice for sender
2025-03-22 17:31:10 -05:00
ekzyis
e7eece744f
Use __Secure- cookie prefix (#1998) 2025-03-22 16:59:57 -05:00
M Ʌ R C
54d3b11fbc
Update faq.md (#2008)
Adding links to guides
2025-03-22 10:51:10 -05:00
k00b
06b877e3d3 bump nextjs 2025-03-22 10:13:32 -05:00
ekzyis
5ff1334722
Remove unnecessary exports (#2006) 2025-03-21 19:56:01 -05:00
ekzyis
5e2185c18f
Use cookieOptions for pointer cookie (#2005) 2025-03-21 19:53:49 -05:00
k00b
bce4053b72 update boost explainer 2025-03-21 19:49:53 -05:00
k00b
2d619438b1 make hot_score_view editable through parameters of another view 2025-03-21 19:47:12 -05:00
k00b
5164258df8 remove independence threshold 2025-03-20 19:18:56 -05:00
ekzyis
f96b3bf19a
Fix useQuery lifecycle anti-pattern (#2001) 2025-03-20 17:46:19 -05:00
ekzyis
a83783f008
Fix missing max-age cookie option (#2000) 2025-03-20 16:38:13 -05:00
soxa
dbbd9477fd
don't send a verification email during sign in if no match (#1999)
* don't send verification email on signin if there's no matching user, update email.js message

* conditional magic code message; signup/signin button

* unnecessary useCallback

* switch to cookie parsing lib

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-03-20 15:32:31 -05:00
Keyan
a669ec832b
Update README.md 2025-03-20 13:28:26 -05:00
k00b
7dccd383c3 fix hot score on limited comment queries fix #1996 2025-03-20 12:21:20 -05:00
ekzyis
271563efbd
Fix space before question mark in delete prompt (#1995) 2025-03-20 10:48:17 -05:00
ekzyis
ada230597d
Fix anon dropdown button width (#1997) 2025-03-20 10:46:07 -05:00
Edward Kung
08501583df
reset nym editting state on page change (#1993) 2025-03-19 18:55:22 -05:00
ekzyis
74d99e9b74
Reset multi_auth cookies on error (#1957)
* multi_auth cookies check + reset

* multi_auth cookies refresh

* Expire cookies after 30 days

This is the actual default for next-auth.session-token.

* Collapse issues by default

* Only refresh session cookie manually as anon

* fix mangled merge

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-03-19 18:54:43 -05:00
soxa
71caa6d0fe
Prevent new account creation on login (#1976)
* Prevent account creation if we're not signin up

* remove cookie once logged in, 24 hours expiry, comment

* adjust error messages

* check signin instead of signup

* appendHeader to avoid overwrites, fix typo, use NodeNextRequest to handle cookies

* expire cookie if signup
2025-03-19 16:55:38 -05:00
k00b
4f17615291 fix expireBoost boolean condition 2025-03-19 11:48:36 -05:00
k00b
0e4b467b3c make expire boost exclude unpaid invoices 2025-03-19 11:44:35 -05:00
Edward Kung
9905e6eafe
Top cowboys territory selector fix (#1972)
* fix territory selector when in top/cowboys

* redirect /~sub/top/cowboys to /top/cowboys

* check if pathname ends with /top/cowboys

Co-authored-by: ekzyis <ek@stacker.news>

* fix territory selector in top/stackers and top/territories

* better routing logic

---------

Co-authored-by: ekzyis <ek@stacker.news>
2025-03-19 08:19:19 -05:00
github-actions[bot]
fc6cbba40c
Extending awards.csv (#1988)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-03-18 18:18:58 -05:00
ekzyis
60f628e77e
Fix no pw manager autofill for device sync password (#1953)
* Fix no pw manager autofill for device sync password

* Remove unused rows property
2025-03-18 18:17:23 -05:00
soxa
63704a5f0f
Hide pull-to-refresh when not pulling (#1986) 2025-03-18 18:16:36 -05:00
ekzyis
964cdc1d61
Fix warning about missing key for children (#1987) 2025-03-18 17:48:13 -05:00
Scroogey-SN
e31f8e9c69
Support .onion for phoenixd (#1975)
* fix #1964 support .onion urls like lnbits does

* fix lint: const hostname

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-03-18 15:39:53 -05:00
k00b
b7dfef41c0 make search query work with os2.17 and upgrade containers 2025-03-18 14:01:00 -05:00
k00b
344c23ed5c use max zap creation 2025-03-17 20:03:05 -05:00
Edward Kung
b71398a06c
Search improvements: Add relevance search and make recent searches less strict (#1962)
* reconfigured search pipeline

* remove console debug messages

* log1p for comments

* improve relevance of non-relevance sorted queries

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-03-17 19:25:20 -05:00
github-actions[bot]
1a52ff7784
Extending awards.csv (#1978)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-03-17 14:31:00 -05:00
Scroogey-SN
a3762f70b0
fix #1959 strip trailing slashes from url (#1973) 2025-03-17 13:16:31 -05:00
ekzyis
c492618d31
Fix weeks not support in reminder command (#1977) 2025-03-17 13:15:09 -05:00
k00b
4be7f12119 add another hot_score index without nulls clause 2025-03-15 15:29:01 -05:00
Keyan
b672d015e2
territory specific trust (#1965)
* territory specific trust

* functional parity with master

* revert back to materialized view for ranking

* update query for populating subWeightedVotes

* fix anon hot comments

* fix zap denormalization, change weightedComments to be for zaps, order updates of ancestors to prevent deadlocks

* reduce weight of comment zaps for hot score

* do zap ancestor updates together

* initialize trust in new/unpopular territories

* simplify denormalization of zap/downzaps

* recompute all scores
2025-03-15 08:11:33 -05:00
Keyan
53f6c34ee7
Merge pull request #1974 from stackernews/rename-to-next-account
Fix missing rename to nextAccount
2025-03-15 08:06:21 -05:00
ekzyis
54e7793668 Fix missing rename to nextAccount 2025-03-14 22:01:55 -05:00
Keyan
0d93c92e30
Merge pull request #1971 from stackernews/fix-anon-failed-invoices
Don't poll failed invoices if anon
2025-03-12 20:25:12 -05:00
ekzyis
6f50b485b4 Don't poll failed invoices if anon 2025-03-12 20:10:15 -05:00
Keyan
6715943862
Merge pull request #1970 from stackernews/reply-cost-check
Add check for Sub.replyCost > 0
2025-03-12 19:46:47 -05:00
Keyan
dbce0a7517
Merge pull request #1969 from stackernews/fix-saloon-replies
Fix saloon replies
2025-03-12 19:46:21 -05:00
ekzyis
4e62053b45 Fix saloon replies
The query will return { replyCost: null } if no sub was found because of the left join.
2025-03-12 17:53:39 -05:00
ekzyis
a9943981fd Add check for Sub.replyCost > 0 2025-03-12 17:53:15 -05:00
ekzyis
2038701ecf
Merge pull request #1968 from stackernews/faq-update
Update FAQ with wallet questions
2025-03-12 17:44:59 -05:00
ekzyis
b46418e71e Shorten question 2025-03-12 15:14:57 -05:00
ekzyis
907a7aabbd Update FAQ with wallet questions 2025-03-12 15:13:27 -05:00
Keyan
8f8b2e4496
Merge pull request #1966 from stackernews/rename-to-next-account
Rename to /api/next-account
2025-03-11 07:56:46 -05:00
ekzyis
69e62c1e6e Rename to /api/next-account 2025-03-10 20:24:13 -05:00
Keyan
9aad2fd903
Merge pull request #1963 from stackernews/fix-item-cost
Fix item cost in details
2025-03-10 20:03:48 -05:00
k00b
ed9fa5f823 fix fragment when comment visited directly + one db dip 2025-03-10 20:02:55 -05:00
ekzyis
65b1db23a7 Fix item cost in details 2025-03-10 18:13:33 -05:00
Keyan
d8c604172a
Update awards.csv - pay up 2025-03-10 14:48:35 -05:00
ekzyis
3a93b04e07
Merge pull request #1960 from stackernews/fix-welcome
Fix fetching of user items in welcome script
2025-03-09 23:29:14 -05:00
ekzyis
0801191b9e Fix fetching of user items in welcome script 2025-03-09 23:06:50 -05:00
ekzyis
7b725746b0
Remove unnecessary wrapping with NodeNext(Request|Response) (#1956) 2025-03-09 10:53:14 -05:00
ekzyis
5b7ff24955
Allow any admin to toggle SNL banner (#1955) 2025-03-07 18:01:34 -06:00
github-actions[bot]
8ad352ee0f
Extending awards.csv (#1954)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-03-07 18:00:55 -06:00
Edward Kung
2c79472c69
Update semantic search documentation (#1952)
* update semantic search setup documentation

* instructions to modify .env.local instead of .env.development

* fix typo
2025-03-07 15:46:34 -06:00
Keyan
e269a8eb5b
Update awards.csv 2025-03-06 18:26:31 -06:00
k00b
19e3b65edd store extracted links 2025-03-06 18:14:10 -06:00
k00b
b7130b68fd sort notes 2025-03-06 18:14:10 -06:00
Keyan
9292d4f991
Update awards.csv - add known lnaddrs 2025-03-06 14:49:46 -06:00
Keyan
7c98a32452
Update awards.csv 2025-03-06 13:20:51 -06:00
github-actions[bot]
3659b4555e
Extending awards.csv (#1949)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-03-06 13:09:00 -06:00
Scroogey-SN
6363550123
change trigger from pull_request to pull_request_target (#1948) 2025-03-06 13:06:51 -06:00
k00b
53e4683d98 overflow hidden on media container, don't revert-layer on video 2025-03-06 13:04:32 -06:00
k00b
4dd9088f25 fix #1945 2025-03-06 09:27:23 -06:00
soxa
b03e02e4cf
Expose env-dependent constant to service worker; Add Brave Browser-specific push registration error and guidance; Remove unused fetch debug plugin (#1947) 2025-03-06 09:24:49 -06:00
ekzyis
75d0a8e3d9
Fix undefined db for wallet logs (#1944) 2025-03-05 07:29:21 -06:00
k00b
d3e5af40ec fix trust after mathjs upgrade 2025-03-05 07:26:59 -06:00
ekzyis
bf54044a96
Also check for user before setting multi auth cookies (#1941)
* Move multi auth init

* Store same token as we return in jwt callback
2025-03-04 08:58:48 -06:00
k00b
8eb5a51fd6 move sanctioned country codes to env var 2025-03-03 18:14:36 -06:00
k00b
5a8804de79 disallow buying CCs through lnurl-pay and lightning address 2025-03-03 18:03:34 -06:00
Keyan
090203b579
Update extend-awards.yml adding issues: read 2025-03-03 15:41:04 -06:00
soxa
8baf725a37
fix safari videos regression, overflow clipping images (#1938) 2025-03-03 14:53:02 -06:00
Keyan
20b2f9d4c6
Update extend-awards.yml with contents write 2025-03-03 14:39:31 -06:00
Scroogey-SN
65cb25e28c
Extend awards action (#1939)
* remove debug job, restrict create-pull-request to only awards.txt, add documentation

* make create-pull-request use a custom branch, and filter that out, so PRs generated by action don't invoke action again

* add permissions: line

* pull-requests write instead of all

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-03-03 14:35:22 -06:00
ekzyis
dfe0c4ad23
Fix footnotes and overflow (#1940)
* Fix missing uncollapse on footnote click

* Add comments to variables
2025-03-03 14:31:10 -06:00
Keyan
0d57dce068
Update awards.csv 2025-03-03 13:33:24 -06:00
Scroogey-SN
b1cdc76eec
fix #1167: allow pointer events in linkBoxParent pre for scrollbar (#1930)
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-03-03 12:47:54 -06:00
Scroogey-SN
27104302d5
Extend awards action (#1937)
* remove debug job, restrict create-pull-request to only awards.txt, add documentation

* make create-pull-request use a custom branch, and filter that out, so PRs generated by action don't invoke action again
2025-03-03 12:47:31 -06:00
Keyan
8a764f0f75
Revert "Extend awards action (#1933)" (#1936)
This reverts commit dfc297436b451cb14cdb57f42ea47990dd586464.
2025-03-01 16:57:56 -06:00
soxa
f72af08882
fix: WebLN QR fallback for anon users (#1858)
* fix: WebLN QR fallback for anon users

* wip: clear zap color on payment fail

* reverse clearItemMeAnonSats

* webln-specific retry bypass

* cleanup

* send WebLN payment when user is Anon AND on QR

* skip wallet checking on anon

* Use WalletError for all errors in webln.sendPayment

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-03-01 16:56:18 -06:00
soxa
5e7fd693f1
Redirect to top cowboys page if there's a time descriptor (#1913)
* redirect to cowboys.js if there's a time descriptor

* add comment for future reference

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-03-01 16:52:37 -06:00
Scroogey-SN
dfc297436b
Extend awards action (#1933)
* add an action that extends awards.csv on PR merges

* change trigger to pull_request, add unfiltered step for logging
2025-03-01 16:51:39 -06:00
Edward Kung
34c7218eba
Add SimpleStacker to contributors.txt (#1935)
* Add SimpleStacker to contributors list

* remove empty line
2025-03-01 10:38:39 -06:00
Keyan
5a7593f2a7
Revert "add an action that extends awards.csv on PR merges (#1931)" (#1932)
This reverts commit 5de9d92af25be1c8c757b344843f70fb82ffbd6a.
2025-02-28 19:19:07 -06:00
Edward Kung
73170ba8a2
Territory analytics (#1926)
* add territory to analytics selectors

* implement territory analytics, revert user satistics header

* fix linting errors

* disallow some territory names

* fix linting error

* minor adjustments to header

* escape input

* 404 on non-existant sub

* exclude unused queries depending on sub select

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-02-28 19:15:18 -06:00
Scroogey-SN
5de9d92af2
add an action that extends awards.csv on PR merges (#1931) 2025-02-28 19:13:14 -06:00
k00b
4e113c267b moar link extraction vibe coding: config file, batching, log level 2025-02-28 11:35:41 -06:00
Keyan
d1bbfd5339
Update awards.csv 2025-02-27 15:48:10 -06:00
Scroogey-SN
b6618dd66a
Fix issue #1924, require nostr prefixes start with slash (#1928) 2025-02-27 15:24:09 -06:00
Scroogey-SN
33db3b2c79
Add self to contributors.txt (#1929) 2025-02-27 15:16:40 -06:00
Keyan
f271926665
Update awards.csv 2025-02-27 13:13:27 -06:00
Scroogey-SN
f97c1b04e6
Fix issue #1905, item popover with commentId (#1911)
* Fix issue #1905, item popover with commentId

* Fix issue #1924, require nostr prefixes start with slash

* revert hacks and unrelated changes, use commentId in rehype

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-02-27 13:10:42 -06:00
k00b
43edef55eb vibe coded nostr link extractor script 2025-02-27 11:58:02 -06:00
soxa
31532ff830
Dynamic loading optimizations (#1925)
* dynamic: React QR Scanner, React Datepicker; placeholder for syntax highlighting

* Loading placeholders, prevent layout shifting
2025-02-24 15:06:40 -06:00
k00b
31b58baf51 decrease nofollow limit 2025-02-23 11:08:05 -06:00
soxa
7b988b87d9
hotfix: email address should be case insensitive (#1923) 2025-02-23 11:03:32 -06:00
soxa
bc3c008a6d
Dynamically import MathJax (#1910)
* Dynamically import MathJax

* Only load if there's math content; cleanup

* avoid loading RSH on Math, we have MathJax for that; cleanup

* support multiline mathjax

---------

Co-authored-by: k00b <k00b@stacker.news>
2025-02-21 15:41:27 -06:00
Keyan
c571ba0cb7
README.md typo 2025-02-15 20:16:34 -06:00
Keyan
46f87e98b6
Orbstack encouragement 2025-02-15 20:15:51 -06:00
soxa
868847cb43
Dynamically import React Syntax Highlighter; correct theme (#1909) 2025-02-15 16:33:44 -06:00
k00b
d8de6255fe Merge divergent deploy branch 2025-02-15 13:56:41 -06:00
Keyan
4651b36944
Update awards.csv 2025-02-14 22:09:54 -06:00
ekzyis
fca5193beb
Fix autowithdrawal error handling (#1908)
* Fix autowithdrawal error message

* Fix no error thrown if autowithdrawal failed
2025-02-14 20:55:17 -06:00
Keyan
f0d7eaf446
Update awards.csv 2025-02-14 20:09:28 -06:00
ekzyis
5e85147578
Fix receiver fallback on caller error (#1907)
* Rename to createUserInvoice

* Fix no receiver fallback on wrap, direct or autowithdrawal error

* Fix missing error logs for direct payments
2025-02-14 20:01:14 -06:00
ekzyis
0032e064b2
Automated retries (#1776)
* Poll failed invoices with visibility timeout

* Don't return intermediate failed invoices

* Don't retry too old invoices

* Retry invoices on client

* Only attempt payment 3 times

* Fix fallbacks during last retry

* Rename retry column to paymentAttempt

* Fix no index used

* Resolve TODOs

* Use expiring locks

* Better comments for constants

* Acquire lock during retry

* Use expiring lock in retry mutation

* Use now() instead of CURRENT_TIMESTAMP

* Cosmetic changes

* Immediately show failed post payments in notifications

* Update hasNewNotes

* Never retry on user cancel

For a consistent UX and less mental overhead, I decided to remove the exception for ITEM_CREATE where it would still retry in the background even though we want to show the payment failure immediately in notifications.

* Fix notifications without pending retries missing if no send wallets

If a stacker has no send wallets, they would miss notifications about failed payments because they would never get retried.

This commit fixes this by making the notifications query aware if the stacker has send wallets. This way, it can tell if a notification will be retried or not.

* Stop hiding userCancel in notifications

As mentioned in a previous commit, I want to show anything that will not be attempted anymore in notifications.

Before, I wanted to hide manually cancelled invoices but to not change experience unnecessarily and to decrease mental overhead, I changed my mind.

* Also consider invoice.cancelledAt in notifications

* Always retry failed payments, even without send wallets

* Fix notification indicator on retry timeout

* Set invoice.updated_at to date slightly in the future

* Use default job priority

* Stop retrying after one hour

* Remove special case for ITEM_CREATE

* Replace retryTimeout job with notification indicator query

* Fix sortTime

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-02-14 19:25:11 -06:00
k00b
f4040756b3 fix graphql errors in notifications 2025-02-14 15:10:15 -06:00
ekzyis
87b5bb80fd
Fix missing cleanup of dark mode listeners (#1906) 2025-02-14 11:37:24 -06:00
Edward Kung
15bd1c3fc5
Fix the check for misleading links (#1901)
* Fix the check for misleading links

* replace tabs with spaces

* remove trailing spaces

* move isMisleadingLinks to lib/url.js and create unit tests

* Add comments to test cases

* URLs can contain hyphens

---------

Co-authored-by: ekzyis <ek@stacker.news>
2025-02-14 09:43:08 -06:00
ekzyis
77781e07ed
Don't parse content in code blocks (#1899) 2025-02-13 11:46:53 -06:00
ekzyis
3cdf5c9451
Fix comment not outlined again (#1902) 2025-02-13 11:46:35 -06:00
k00b
a4cce7afed only record landing of referree if they don't have referrer 2025-02-12 10:10:25 -06:00
soxa
1afadbdf3b
enhance: referral notifications with source (#1862)
* wip: referral notification shows source of referral

* simpler approach for source info gathering

* fix territory representation; fix fragment field

* cleanup; fix UI

* better margin approach

* hotfix: null check

* add support for comments

* use Union to represent ReferralSource; clarify with switch statements

* cleanup: compact switch statement on Referral resolver

* wip use refereeLanding

* add comments; cleanup

* hotfix: backwards compatibility for Earnings calculation

* small copy and semantics changes

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-02-11 20:02:04 -06:00
k00b
bdd87e7d39 add retry link back 2025-02-11 16:29:37 -06:00
soxa
e6081ebef3
fix: THREAD notification type for noteAllDescendants (#1894)
* THREAD notifications to distinguish direct replies from follow-ups

* hotfix: typo

* hotfix: avoid subquery when we already have a JOIN
2025-02-11 13:45:04 -06:00
k00b
5af61f415f monospace font for edit countdowns 2025-02-10 19:19:22 -06:00
ekzyis
1ce88a216a
Merge pull request #1893 from stackernews/fix-welcome-script
Fix NaN in welcome script
2025-02-10 16:04:20 +01:00
ekzyis
a913c2d452 Fix NaN in welcome script 2025-02-10 01:39:13 +01:00
ekzyis
54afe67558
Merge pull request #1892 from stackernews/fix-welcome-script
Fix bios missed in welcome script
2025-02-10 01:33:21 +01:00
ekzyis
34aadba352 Fix bios missed in welcome script 2025-02-10 01:32:30 +01:00
Keyan
a8b3ee37bf
Update awards.csv 2025-02-08 18:42:41 -06:00
Keyan
64bbd2e1b8
Update awards.csv 2025-02-08 15:14:07 -06:00
soxa
c01f4865dc
apply flex rules after images are loaded (#1886) 2025-02-08 13:14:42 -06:00
k00b
1e673cab77 monospace font for edit countdowns 2025-02-07 18:49:22 -06:00
ekzyis
2732013da3
FAQ feedback (#1888) 2025-02-07 17:37:15 -06:00
ekzyis
f90ed8d294
Add territory stats answer to FAQ (#1887) 2025-02-07 14:51:03 -06:00
soxa
5c2aa979ea
feat: comment fee control (#1768)
* feat: comment fee control

* update typeDefs for unarchiving territories

* review: move functions to top level; consider saloon items

* ux: cleaner post/reply cost section

* hotfix: handle salon replies

* bios don't have subs + simplify root query

* move reply cost to accordian

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-02-07 13:38:57 -06:00
ekzyis
ac321be3cd
Big FAQ update (#1800) 2025-02-07 12:53:11 -06:00
k00b
95e98501ec reduce max incoming invoice expiration and expiration buffer 2025-02-05 12:47:35 -06:00
k00b
ee8fe6e72a add boost badge fix #1860 2025-02-03 19:56:29 -06:00
k00b
9885bcf209 make sure comments specify time zone for invoicePaidAt fix #1859 2025-02-03 19:40:33 -06:00
jason-me
2dfde257d2
Update price button accessibility in header (#1857)
* Update price button accessibility

* Updated accName and accDescription for speech dictation and screen reader users.

* Update price.js

Replace double quote with single

* Update price.js

Remove trailing spaces

* make .visually-hidden global, use className rather than class

* make accessible button component

---------

Co-authored-by: Jason Hester <jhester@TPGLPT-LTC23.attlocal.net>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-02-03 19:22:02 -06:00
soxa
be7c702602
Login with magic code (#1818)
* fix: cannot login with email on PWA

* adjust other email templates

* restore manual url on new user email

* no padding on button section

* cleanup

* generate 6-digit bechh32 token

* token needs to be fed as lower case; validator case insensitive

* delete token if user has failed 3 times

* proposal: context-independent error page

* include expiration time on email page message

* add expiration time to emails

* independent checkPWA function

* restore token deletion if successful auth

* final cleanup: remove unused function

* compact useVerificationToken

* email.js: magic code for non-PWA users

* adjust email templates

* MultiInput component; magic code via MultiInput

* hotfix: revert length testing; larger width for inputs

* manual bech32 token generation; no upperCase

* reverting to string concatenation

* layout tweaks, fix error placement

* pastable inputs

* small nit fixes

* less ambiguous error path

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-02-03 18:41:01 -06:00
k00b
89187db1ea fix failing jobs 2025-02-01 15:36:48 -06:00
Keyan
074f0c0634
If we are a hop hint, use alternate form of estimateRouteFee (#1854)
* untested draft

* handle empty routes
2025-01-31 20:19:21 -06:00
k00b
efdcbef733 fixes related to comment paging 2025-01-30 19:19:49 -06:00
k00b
33beb1dc52 fix history race on flat comment click 2025-01-30 17:08:16 -06:00
k00b
bb916b8669 fix comments in notifications 2025-01-30 10:54:51 -06:00
k00b
312f4defb0 fix recursive limited comments call 2025-01-30 10:10:29 -06:00
Keyan
01b021a337
comment pagination with limit/offset (#1824)
* basic query with limit/offset

* roughly working increment

* working limiting/pageable queries

* denormalize direct comments + full comments below threshold

* notifications in megathread + working nest view more buttons

* fix empty comment footer

* make comments nested resolver again

* use time in cursor to avoid duplicates

* squash migrations

* do not need item.comments undefined checks
2025-01-29 19:00:05 -06:00
soxa
bd84b8bf88
fix: Images on iOS are cropped weird (#1840)
* force sync decoding on images

* use decode() to load the image

* add comment
2025-01-28 15:30:54 -06:00
k00b
965e482ea3 stimulus to 50k 2025-01-28 10:57:45 -06:00
Keyan
f8fa0f65e7
Update awards.csv 2025-01-27 18:49:58 -06:00
Keyan
8059945f82
Update awards.csv 2025-01-27 18:18:24 -06:00
ekzyis
ee2d076d1b
Welcome series script update (#1848)
* Parse args in welcome script

* Refactor welcome script

* Show sats/ccs

* Add sat standard
2025-01-27 15:14:03 -06:00
ekzyis
156b895fb6
Add createrune info for v24.11 (#1847) 2025-01-26 12:28:51 -06:00
ekzyis
53b8f6f956
FAQ formatting changes (#1842)
* Fix inconsistent markdown formatting in FAQ

- remove usage of &lrm;
- don't skip headers (##### instead of ###)

* Use hash links in FAQ
2025-01-25 13:48:51 -06:00
ekzyis
c023e8d7d5
Fix missing push notifications for thread subscriptions (#1843)
* Fix missing push notifications for thread subscriptions

* Filter by comments in calling context

* Fix mutes not considered

* Fix duplicate push notification (reply+thread subscription) sent
2025-01-25 13:47:58 -06:00
k00b
b28407ee99 remove changes from footer 2025-01-23 16:54:50 -06:00
soxa
78533bda1b
fix: downzappable pinned posts (#1841)
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-01-22 19:15:03 -06:00
soxa
47faef872d
Subscribe unarchiver to unarchived territory (#1839)
* enhance: subscribe unarchiver to unarchived territory

* use upsert and fix #1517

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-01-22 19:11:19 -06:00
ekzyis
ca7726fda5
Fix recent sort order for retried items (#1829)
* Fix recent sort order for retried items

* Also fix for comments

* don't hide createdAt, order item query inner subquery

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-01-22 17:42:18 -06:00
soxa
ae1942ada7
fix: duplicate push notification on subscribed user and territory (#1820)
* fix: duplicate notification on subscribed user and territory

* fix comments not showing up, adjust query

* use  and tagged template helpers

---------

Co-authored-by: k00b <k00b@stacker.news>
2025-01-22 16:12:41 -06:00
soxa
a92215ccf6
fix: globally pinned items rank in global (#1814)
* fix: globally pinned items rank in global; use query to filter global pinned items

* cleanup

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-01-22 15:23:21 -06:00
Keyan
382714e422
Merge pull request #1837 from stackernews/fix-possible-silent-push
Fix possible silent push
2025-01-21 09:37:10 -06:00
ekzyis
1942c79193 Fix possible silent push 2025-01-21 10:23:48 +01:00
Keyan
355abc7221
Merge pull request #1831 from stackernews/remove-msats-warning
Remove msats warning
2025-01-20 19:01:09 -06:00
ekzyis
181cb87c18 Remove warning if wallet does not support msats 2025-01-21 00:28:11 +01:00
Keyan
0c0fdfb63b
Update awards.csv 2025-01-20 15:51:04 -06:00
Keyan
020b914d0d
Update awards.csv 2025-01-20 15:32:18 -06:00
Keyan
0a83a88e06
Merge pull request #1828 from stackernews/fix-cc-info-typo
Fix typo in CC info
2025-01-20 12:04:56 -06:00
ekzyis
ac7bd5df7e Fix typo in CC info 2025-01-20 15:02:03 +01:00
Keyan
1057fcc04d
Merge pull request #1827 from stackernews/welcome
Script for welcome series
2025-01-19 19:30:49 -06:00
ekzyis
c6de7a1081 Script for welcome series 2025-01-19 23:56:19 +01:00
Keyan
566a5f9675
Merge pull request #1825 from Soxasora/fix_sw_uncaught_promise
fix: uncaught promise on getOS causes onMessage event to end early
2025-01-18 11:43:17 -06:00
Soxasora
1cbf5ab871 fix: uncaught promise on getOS causes event to end early 2025-01-18 18:10:32 +01:00
Keyan
a895a91277
Merge pull request #1823 from stackernews/update-forward-limits
Update forward limits
2025-01-18 09:29:16 -06:00
ekzyis
630af6bc40 Update forward limits 2025-01-18 09:53:17 +01:00
k00b
d9932e0a27 add max fee bullet to cc modal 2025-01-14 14:43:43 -06:00
Keyan
3ce9d7339c
Merge pull request #1817 from stackernews/daily-rewards-refill-job
bounty updates and auto-rewards refill
2025-01-14 10:57:20 -06:00
k00b
d688e9fce8 automate daily rewards 2025-01-13 19:59:05 -06:00
k00b
5c1129c98f update bounty bots 2025-01-13 17:47:42 -06:00
k00b
8905868a62 fix typo in CC modal 2025-01-13 15:06:03 -06:00
k00b
c40ef5a1c1 fix daily rewards link in reward sats modal 2025-01-12 15:47:21 -06:00
Keyan
4520c91179
cowboy credit modal explainers wherever they are referenced (#1815) 2025-01-12 15:15:53 -06:00
Keyan
90ccbc58b1
Update awards.csv 2025-01-11 19:14:27 -06:00
Keyan
8d548ce152
Update awards.csv 2025-01-11 19:13:29 -06:00
Keyan
2137dacf14
Update awards.csv 2025-01-11 19:11:54 -06:00
k00b
7daf688ea3 remove p2p zap notification indicator fixing 'phantom' notifications 2025-01-08 18:10:14 -06:00
ekzyis
5261c83f4d
Add FAQ to version control (#1799)
* Add FAQ to version control

* Add script to deploy markdown content

* Move faq.md into docs/user
2025-01-08 13:47:34 -06:00
soxa
466620ff05
fix: zapping too fast causes duplicate notifications (#1812)
* fix: zapping too fast causes duplicate notifications

* add comment

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-01-08 12:51:07 -06:00
soxa
44d3748d5f
fix: iOS PWA push notifications (#1794)
* fix: iOS pwa push notifications

* fix lint

* align onNotificationClick promises to event.waitUntil

* align CLEAR_NOTIFICATIONS promises to event.waitUntil

* include notifications url for merged payloads

* hotfix: track amount via notification.length

* better comments

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-01-08 12:40:25 -06:00
Riccardo Balbo
511b0eea40
fix tooltip blinking at the edges (#1813) 2025-01-07 08:18:35 -06:00
ekzyis
b37a12bf71
Use sats / ccs format in item details (#1805)
* Use sats / ccs format in item details

* Use row-gap and line-height
2025-01-06 17:22:56 -06:00
ekzyis
c74107269d
Fix job edits (#1811) 2025-01-06 17:20:14 -06:00
k00b
29c31e3d4a double allowed outgoing cltv delta 2025-01-05 16:17:22 -06:00
Riccardo Balbo
0750a7b197
add hasSendWallet to bounty pay (#1806) 2025-01-05 11:26:18 -06:00
Riccardo Balbo
95950cdff8
pass hasSendWallet to ItemAct actor (#1804) 2025-01-05 11:25:49 -06:00
k00b
529b5d1fef fix #1795 2025-01-04 19:31:25 -06:00
soxa
a04647d304
fix: iOS check, proper AMOUNT count on iOS (#1796) 2025-01-04 19:18:15 -06:00
ekzyis
34175babb2
Fix scroll after modal close (#1798) 2025-01-04 15:32:45 -06:00
k00b
3fc1291600 fix missing sender wallet flag 2025-01-03 13:51:28 -06:00
Keyan
146b60278c
cowboy credits (aka nov-5 (aka jan-3)) (#1678)
* wip adding cowboy credits

* invite gift paid action

* remove balance limit

* remove p2p zap withdrawal notifications

* credits typedefs

* squash migrations

* remove wallet limit stuff

* CCs in item detail

* comments with meCredits

* begin including CCs in item stats/notifications

* buy credits ui/mutation

* fix old /settings/wallets paths

* bios don't get sats

* fix settings

* make invites work with credits

* restore migration from master

* inform backend of send wallets on zap

* satistics header

* default receive options to true and squash migrations

* fix paidAction query

* add nav for credits

* fix forever stacked count

* ek suggested fixes

* fix lint

* fix freebies wrt CCs

* add back disable freebies

* trigger cowboy hat job on CC depletion

* fix meMsats+meMcredits

* Update api/paidAction/README.md

Co-authored-by: ekzyis <ek@stacker.news>

* remove expireBoost migration that doesn't work

---------

Co-authored-by: ekzyis <ek@stacker.news>
2025-01-03 10:33:07 -06:00
ekzyis
47debbcb06
Fix insecure default id for invites (#1789)
* Fix insecure default id for invites

* Use 16 bytes
2025-01-03 09:42:28 -06:00
ekzyis
077727dced
Fix expirein used instead of keepuntil (#1788)
* Fix expirein used instead of keepuntil

* Fix existing boost jobs
2025-01-03 08:52:34 -06:00
ekzyis
d53bc09773
Distinguish invoices cancelled by user (#1785) 2025-01-02 11:53:05 -06:00
ekzyis
6a02ea8c5c
Allow cancel of own invoices without hmac (#1787) 2025-01-02 10:35:54 -06:00
soxa
0ca9596310
enhance: collapse childless deleted comments (#1786) 2025-01-01 13:07:32 -06:00
ekzyis
ba2ac2c94e
Fix ignored cancel error (#1784) 2025-01-01 11:18:54 -06:00
ekzyis
4623743c8f
fix cookie pointer override during account switching (#1783) 2024-12-31 13:05:20 -06:00
ekzyis
a41d077c21
Fix muted parents not uncollapsed (#1775)
* Fix parents not uncollapsed of router.query.commentId

* Fix code comment

* Use descriptive fragment name

* Add cache to effect dependencies

not sure if needed since also works without but probably safer to have than not
2024-12-31 10:24:59 -06:00
ekzyis
ba2cdc2275
Fix NostrAuth link text (#1782) 2024-12-31 08:40:30 -06:00
Riccardo Balbo
1972af1fd9
filter out POST /api/graphql spam from sndev logs (#1780)
* filter out POST /api/graphql spam from sndev logs

* don't use -P in grep
2024-12-30 18:49:54 -06:00
ekzyis
bec27a5a0e
Fix user since if first item wasn't paid (#1773) 2024-12-30 16:33:17 -06:00
k00b
4262189c91 fix week interval in newsletter 2024-12-28 15:50:17 -06:00
k00b
211e3c136b fix lint 2024-12-28 11:16:59 -06:00
CypherCosmo
43129f7045
fit missing signup button spacing
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-12-28 10:47:57 -06:00
ekzyis
c89220dbde
Add exclusion constraint to prevent duplicate items within 10m (#1747)
* Add exclusion constraint to prevent duplicate items within 10m

* Fix missing extension

* More user-friendly error message

* Use MD5 for slightly better performance

* Always use MD5 for columns of type TEXT

* shift constraint into the future

---------

Co-authored-by: k00b <k00b@stacker.news>
2024-12-28 10:33:53 -06:00
ekzyis
9783df8e3b
Require invite limit (#1748)
* Fix invite limit required

* Fix redeeming of unlimited invites

* Require invite limit
2024-12-28 09:28:05 -06:00
k00b
74f771adf4 fix #1754 2024-12-27 19:06:30 -06:00
Keyan
eb22fdab41
make possibly conflicting optimistic cache updates write to each other (#1772)
* make possibly conflicting optimistic cache updates write to each other

* always update optimistic caches with cache.modifying items
2024-12-27 14:52:32 -06:00
Keyan
18445b1dc1
fix 1695 by not updating ancestors on zap (#1766)
* fix 1695 by not updating ancestors on zap

* update ancestors but only onPaid
2024-12-27 10:19:49 -06:00
Riccardo Balbo
6efa782c11
fix typo (#1764) 2024-12-26 13:10:15 -06:00
ekzyis
4ad54d6f5a
Fix typo in deposit notification (#1765) 2024-12-26 13:10:02 -06:00
k00b
1b1045fcd9 increase pull refresh distance 2024-12-23 19:50:51 -06:00
Keyan
9490520925
renerf ek and I (#1760) 2024-12-23 19:17:32 -06:00
Keyan
2fc529419e
reduce territory price with prorated refund for actives (#1761) 2024-12-23 18:50:35 -06:00
ekzyis
b4352e74b4
Fix echoed message about mining blocks (#1759) 2024-12-23 13:15:37 -06:00
ekzyis
0ca37038dd
Fix upvote button shown for deleted items (#1753) 2024-12-20 18:16:24 -06:00
Keyan
f5fb88342b
update cache on vault key update (#1752)
* update cache on vault key update

* Update components/vault/use-vault-configurator.js

Co-authored-by: ekzyis <ek@stacker.news>

---------

Co-authored-by: ekzyis <ek@stacker.news>
2024-12-20 18:15:06 -06:00
Riccardo Balbo
6ad7a3cf60
add ofelia cron for wallets and force cln to reconnect (#1746) 2024-12-19 17:02:27 -06:00
ekzyis
fdbe14d195
Fix send-only wallet save after device sync enabled (#1732)
* Rename walletData to recvConfig

* Use nested upsert during wallet update
2024-12-19 11:31:11 -06:00
Riccardo Balbo
e4ca2d6e07
Refine #1739 and fix regression causing nostr crossposts and login to not work (#1740)
* Refine #1739 and fix regression causing crossposts and login to not work

* use temp nostr instance for signing
2024-12-19 08:27:45 -06:00
ekzyis
4db2edb1d9
Close relay connections after each NWC call (#1739) 2024-12-19 00:11:03 -06:00
ekzyis
d3a705d3ad
Fix admin edits (#1737) 2024-12-18 18:28:18 -06:00
k00b
b8061a630c don't double factor trust 2024-12-18 18:27:38 -06:00
k00b
16b7160d36 increase create invoice timeout 2024-12-18 13:47:03 -06:00
k00b
faeefdc498 increase send payment timeout 2024-12-18 12:09:07 -06:00
Keyan
6d4dfddae8
improve rewards (#1731)
* don't bias to early zapping so much

* untested rewards/leaderboard changes

* fix cln dep for payments

* make zap proportion scale using quad root

* fix for missing proportion on hidden users

* improve rewards cutoff criteria

* Update api/resolvers/user.js

Co-authored-by: ekzyis <ek@stacker.news>

* Update api/typeDefs/user.js

Co-authored-by: ekzyis <ek@stacker.news>

* improve switch readability

* small increase in min zap

* refresh materialized views on migration

---------

Co-authored-by: ekzyis <ek@stacker.news>
2024-12-18 10:12:11 -06:00
ekzyis
6098d39574
Fix missing logs on save (#1729)
* Fix missing logs on save

* fix receive logs wrt device sync

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-12-16 17:37:31 -06:00
ekzyis
62a922247d
Add timeouts to all wallet API calls (#1722)
* Add timeout to all wallet API calls

* Pass timeout signal to wallet API

* Fix timeout error message not shown on timeout

* Fix cross-fetch throws generic error message on abort

* Fix wrong method in error message

* Always use FetchTimeoutError

* Catch NDK timeout error to replace with custom timeout error

* Also use 15s for NWC connect timeout

* Add timeout delay
2024-12-16 14:05:31 -06:00
ekzyis
819d382494
Fix lightning address logs deletion (#1728) 2024-12-15 11:14:33 -06:00
ekzyis
14de23b21d
Refactor CLN function signatures (#1726) 2024-12-14 10:32:51 -06:00
ekzyis
3cdfe620d0
Refactor Blink function signatures (#1725)
This makes them consistent with function signatures of other wallets
2024-12-14 08:56:45 -06:00
ekzyis
77d22cfd77
Remove unused wallet context args (#1724) 2024-12-14 08:55:08 -06:00
Riccardo Balbo
bdd24130f9
Nip46 auth with NDK (#1636)
* ndk

* fix: remove duplicated zap note event template

* don't init Nip07 signer by default

* Update wallets/nwc/server.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* nwc protocol parsing workaround

* WebSocket polyfill for worker

* increase nwc timeout

* remove NDKNip46Signer type

* fix type annotation

* move  eslint-disable camelcase to the top

* pass event args to the constructor

* fix error handling

* Update wallets/nwc/index.js

Co-authored-by: ekzyis <ek@stacker.news>

* nip46 auth

* style tweak, remove unmaintained signers from the list

* don't use modal

* workaround url parsing

* use kind 27235

* add kind 27235 metadata

* show suggestion after a timeout

* Update lib/nostr.js

Co-authored-by: ekzyis <ek@stacker.news>

* Update components/nostr-auth.js

Co-authored-by: ekzyis <ek@stacker.news>

* fix unrelated lnauth crash when closing ext prompt

* make ui consistent ...

* give buttons spacing

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-12-13 20:25:34 -06:00
ekzyis
285203889d Remove unused import 2024-12-13 21:20:51 +01:00
ekzyis
a50a2c8bd1
Fix receiver fallbacks depend on fast polls (#1723) 2024-12-13 14:19:00 -06:00
Riccardo Balbo
d73f6323ff
NDK (#1590)
* ndk

* fix: remove duplicated zap note event template

* don't init Nip07 signer by default

* Update wallets/nwc/server.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* nwc protocol parsing workaround

* WebSocket polyfill for worker

* increase nwc timeout

* remove NDKNip46Signer type

* fix type annotation

* move  eslint-disable camelcase to the top

* pass event args to the constructor

* fix error handling

* Update wallets/nwc/index.js

Co-authored-by: ekzyis <ek@stacker.news>

* Fix type annotation

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-12-13 13:28:36 -06:00
Riccardo Balbo
52734940a3
Bolt12 dev environment (#1702)
* lndk-eclair bolt12 test environment

* use static certs for lndk dev

* move eclair/lndk/cln to wallets profile, force lndk onto x86 platform

* fix port conflict

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-12-13 12:30:30 -06:00
Keyan
bf541aa643
Merge pull request #1721 from stackernews/fix-missing-usage-of-timeout-error
Fix missing usage of new TimeoutError
2024-12-13 10:29:50 -06:00
ekzyis
06a3a71eb9 Use new TimeoutError 2024-12-13 16:29:00 +01:00
Keyan
adfbdeddba
Merge pull request #1720 from stackernews/fix-toast-padding
Fix missing toast padding
2024-12-13 08:39:45 -06:00
ekzyis
fc4303658d
Use AbortSignal.timeout + custom timeout error message (#1718)
* refactor: replace custom logic with AbortSignal.timeout

* Use custom timeout error message

* Include method and url in fetch timeout error

* Fix error not rethrown
2024-12-13 08:38:42 -06:00
Keyan
e8434d07c5
Merge pull request #1719 from stackernews/fix-missing-save-error-log
Fix missing logging of sender wallet validation error
2024-12-13 08:36:37 -06:00
ekzyis
959cd1f3f4 Fix missing toast padding 2024-12-13 15:03:04 +01:00
ekzyis
64ba7a56cb Fix missing wallet error logging 2024-12-13 11:30:54 +01:00
Riccardo Balbo
ec213907fc
Use debian cdn instead of de mirror (#1715)
* Use debian cdn instead of de mirror

* use https in deb mirror
2024-12-12 10:30:39 -06:00
ekzyis
66b7352bf0
Fix forever edits (#1716)
* Fix forever edits

* Refactor edit check on server
2024-12-12 09:35:30 -06:00
Keyan
6918bcb452
Merge pull request #1714 from stackernews/1704-fix-exposed-routing-fees
Don't expose p2p zap receivers to routing fees
2024-12-11 15:47:57 -06:00
k00b
98fae6c9ae prioritize payment reliability 2024-12-11 15:38:38 -06:00
Keyan
0e765d4179
Merge pull request #1709 from stackernews/fix-client-pending-forwards-paid
Fix pending forwards considered paid by client
2024-12-11 14:43:33 -06:00
Keyan
3fe5f4b435
Merge pull request #1713 from stackernews/shared-failed-forwards-view 2024-12-11 14:42:31 -06:00
ekzyis
c9439c33c6 Don't expose p2p zap receivers to routing fees 2024-12-11 20:55:43 +01:00
ekzyis
8f092cdc66 Use consistent view about failed forwards 2024-12-11 20:09:49 +01:00
ekzyis
4e6fb40c0b Use conditional waitFor to fix premature payment success 2024-12-11 19:27:29 +01:00
ekzyis
8cb89574ae Fix pending forwards considered paid by client 2024-12-11 14:39:01 +01:00
ekzyis
756e75ed7c
Fix latest timestamp not updated (#1705) 2024-12-10 15:36:04 -06:00
ekzyis
a46f81f1e1
Receiver fallbacks (#1688)
* Use same naming scheme between ln containers and env vars

* Add router_lnd container

* Only open channels to router_lnd

* Use 1sat base fee and 0ppm fee rate

* Add script to test routing

* Also fund router_lnd wallet

* Receiver fallbacks

* Rename to predecessorId

* Remove useless wallet table join

* Missing renaming to predecessor

* Fix payment stuck on sender error

We want to await the invoice poll promise so we can check for receiver errors, but in case of sender errors, the promise will never settle.

* Don't log failed forwards as sender errors

* fix check for receiver error

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-12-10 14:15:29 -06:00
Keyan
e6c74c965b
Update awards.csv 2024-12-10 11:15:10 -06:00
ekzyis
3ead4db8dc
Use new LND node as routing node (#1700)
* Use same naming scheme between ln containers and env vars

* Add router_lnd container

* Only open channels to router_lnd

* Use 1sat base fee and 0ppm fee rate

* Add script to test routing

* Also fund router_lnd wallet

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-12-10 10:52:17 -06:00
ekzyis
1a41760915
CLN: Use port 9735 and log to stdout (#1701)
* use port 9735 for cln node

this makes it consistent with the rest of the environment (default port for regtest is 19846)

* cln log to stdout

---------

Co-authored-by: Riccardo Balbo <riccardo0blb@gmail.com>
2024-12-10 09:14:18 -06:00
ekzyis
3e29e04b01
Use same naming scheme between ln containers and env vars (#1698) 2024-12-10 09:13:14 -06:00
k00b
bf20cf8f56 fix different carousels named the exact same thing 2024-12-09 19:06:46 -06:00
k00b
52098a3e50 fix broken static header from carousel 2024-12-09 19:03:30 -06:00
ekzyis
61fb1c445f
Fix header carousel desync (#1696) 2024-12-09 16:09:26 -06:00
Keyan
d05a27a6c3
Update awards.csv 2024-12-09 09:10:49 -06:00
ekzyis
a5fa40aa1b
Fix comment in useSendWallets (#1691) 2024-12-09 09:04:41 -06:00
Riccardo Balbo
080459cd21
Fix lud-18 validation imports (#1690)
* fix lud18PayerDataSchema import

* fix validateSchema import
2024-12-08 12:37:56 -06:00
Keyan
4ec36670c3
Update awards.csv
@felipebueno I can't find a route to your lightning address from SN. Please advise. :)
2024-12-07 15:59:38 -06:00
Keyan
a8f79d59ff
Update awards.csv 2024-12-07 15:48:20 -06:00
ekzyis
ec904e6b4c
Move useInvoice and useQrPayment into own files (#1686) 2024-12-05 08:52:32 -06:00
soxa
3806dc23af
enhance: show an error when uploads are expired (#1685)
* enhance: show an error when uploads are expired

* lint: standardjs
2024-12-05 08:15:08 -06:00
ekzyis
e55af28763
Fix wallet logs delete for single wallet (#1684) 2024-12-05 08:10:29 -06:00
Keyan
713227b255
invite paid action (#1681) 2024-12-04 12:10:30 -06:00
k00b
909853521d item referral threshold 2024-12-02 14:45:15 -06:00
ekzyis
01d5177006
Fix edit timer stuck at 00:00 (#1673)
* Fix edit timer stuck at 00:00

* refactor with useCanEdit hook
2024-12-02 08:18:35 -06:00
k00b
8595a2b8b0 stop probable source of 504 toasts 2024-12-01 17:01:13 -06:00
Riccardo Balbo
7f11792111
Custom invite code and note (#1649)
* Custom invite code and note

* disable autocomplete and hide invite code under advanced

* show invite description only to the owner

* note->description and move unser advanced

* Update lib/validate.js

Co-authored-by: ekzyis <ek@stacker.news>

* Update lib/webPush.js

Co-authored-by: ekzyis <ek@stacker.news>

* Update api/typeDefs/invite.js

Co-authored-by: ekzyis <ek@stacker.news>

* Update pages/invites/index.js

Co-authored-by: ekzyis <ek@stacker.news>

* Update pages/invites/index.js

Co-authored-by: ekzyis <ek@stacker.news>

* fix

* apply review suggestions

* change limits

* Update lib/validate.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* don't show invite id in push notification

* remove invoice metadata from push notifications

* fix form reset, jsx/dom attrs, accidental uncontrolled prop warnings

* support underscores as we claim

* increase default gift to fit inflation

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-12-01 16:31:47 -06:00
soxa
76e384b188
fix can't upload from iOS camera/mov files (#1667)
* fix can't upload from iOS camera/mov files

* pivot: iOS automatically transcodes HEVC mov files to H264, custom error if codec not supported
2024-11-30 19:12:13 -06:00
soxa
0b97d2ae94
fix: top boosts shows others' unpaid boosts (#1647) 2024-11-30 19:06:10 -06:00
Lorenzo
d88971e8e5
Fix: progress bar shown on back navigation through pathname check (#1633)
* fix: progress bar shown on back navigation through pathname check

* fix progress done race

* use router.pathname instead cause it's already there

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-30 19:05:26 -06:00
ekzyis
0837460c53
Fix missing authentication check for invite revocation (#1666)
* Fix missing authentication check for invite revocation

* Toast invite revocation error
2024-11-30 12:08:30 -06:00
Felipe Bueno
55d1f2c952
Introduce SubPopover (#1620)
* Introduce SubPopover

* add truncation to sub description popover

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-29 19:58:18 -06:00
Lorenzo
bd5db1b62e
Fix Territories selector updates without hard-reload (#1619)
* fix: territories select fetches new data on reload

* chore: removed unnecessary extra function

* chore: territories refetched on nsfwMode change

* chore: check for undefined me object on refetch hook

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-11-29 18:59:39 -06:00
Lorenzo
7cb2aed9db
feat: recent unpaid bounties selection (#1589)
* feat: recent unpaid bounties selection

* chore: added checkbox on recent header

* chore: active bounties selection made through a checkbox

* chore: renamed function for better clarity

* chore: fixed active bounties only checkbox alignment

* chore: active-only option passed as query param

* chores: variablesFunc refactoring

* chore: removed type mapping function from recent header

* chore: router replace instead of push

* chore: router retrieved by hook instead of argument

* chore: checkbox starts checked based on url's query param

* more idiomatic react + push instead of replace

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-28 18:47:01 -06:00
ekzyis
8ce2e46519
Merge pull request #1642 from stackernews/sender-fallbacks
Sender fallbacks
2024-11-28 23:00:03 +01:00
k00b
9caeca00df fix cache update option name on qr 2024-11-28 14:23:41 -06:00
ekzyis
799a39b75f
Fix deposit push notifications (#1662) 2024-11-28 11:48:08 -06:00
k00b
404cf188b3 function for merging data after retry 2024-11-28 11:43:40 -06:00
k00b
105f7b07e5 make fallback retry cache updates a special case 2024-11-28 11:22:46 -06:00
k00b
cb028d217c fix zap fallback retries in notifications 2024-11-28 10:48:22 -06:00
k00b
6b59e1fa75 usesendwallets 2024-11-27 19:39:20 -06:00
k00b
f89286ffdf make logger use full wallet 2024-11-27 19:10:00 -06:00
ekzyis
6c3301a9c4 Fix missing item invoice update on failure 2024-11-28 01:53:45 +01:00
k00b
67f6c170aa readability improvements 2024-11-27 18:39:23 -06:00
ekzyis
f9169c645a Fix [undefined] in logs 2024-11-28 01:36:29 +01:00
ekzyis
a0d33a23f3 Fix wallet save 2024-11-28 01:16:30 +01:00
k00b
b608fb6848 refactor out array of hooks 2024-11-27 17:31:08 -06:00
Keyan
61395a3525
Merge branch 'master' into sender-fallbacks 2024-11-27 17:17:11 -06:00
ekzyis
5e76ee1844
Remove unused useWallet from QR code component (#1660) 2024-11-27 17:16:44 -06:00
ekzyis
7a8db53ecf Only retry same receiver if forward did not fail 2024-11-27 23:00:27 +01:00
ekzyis
b301b31a46 Create wrapped invoices on p2p zap retries 2024-11-27 23:00:27 +01:00
ekzyis
9cfc18d655 Return latest state of paid or failed invoice 2024-11-27 23:00:27 +01:00
ekzyis
68513559e4 Remove unnecessary error handling in LNC
We already wrap sendPayment with our own error handling so these errors will become instances of WalletPaymentErrors anyway
2024-11-27 23:00:27 +01:00
ekzyis
a4144d4fcc Fix missing item invoice update for optimistic actions 2024-11-27 23:00:27 +01:00
ekzyis
0051c82415 Fix invoice retry even if no payment was attempted 2024-11-27 23:00:27 +01:00
ekzyis
14a92ee5ce Fix filter for wallets that can send
Testing for canSend is not enough since it can also return true if the wallet is not enabled.

This is the case because we want to allow disabling wallets but canSend must still return true in this case if send is configured.

This should probably be changed such that canSend only returns true when the wallet is enabled without preventing disabling of wallets.
2024-11-27 23:00:27 +01:00
ekzyis
b1cdd953a0 Return last attempted invoice in canceled state 2024-11-27 23:00:27 +01:00
ekzyis
7e25e29507 Show aggregated wallet errors in QR code 2024-11-27 23:00:27 +01:00
ekzyis
7f5bb33073 Fix payment method returned by retries 2024-11-27 23:00:27 +01:00
ekzyis
be4ce5daf9 Allow retries of pessimistic actions 2024-11-27 23:00:27 +01:00
ekzyis
1f2b717da9 Fix last wallet not returning new invoice 2024-11-27 23:00:27 +01:00
ekzyis
00f9e05dd7 Ignore wallet configuration errors in QR code 2024-11-27 23:00:27 +01:00
ekzyis
974e897753 Add comment when err.newInvoice is not set 2024-11-27 23:00:27 +01:00
ekzyis
517d9a9bb9 Abort payment on unexpected errors 2024-11-27 23:00:27 +01:00
ekzyis
413f76c33a Refactor wallet error handling with inheritance 2024-11-27 23:00:27 +01:00
ekzyis
ed82d9cfc0 Fix payments if recv-only wallet enabled 2024-11-27 23:00:27 +01:00
ekzyis
d99caa43fc Remove unnecessary sort 2024-11-27 23:00:27 +01:00
ekzyis
7036804c67 Fix old invoice passed to QR code 2024-11-27 23:00:27 +01:00
ekzyis
7742257470 Fix TypeError 2024-11-27 23:00:27 +01:00
ekzyis
bc0c6d1038 Fix SenderError name 2024-11-27 23:00:27 +01:00
ekzyis
5218a03b3a sender fallbacks 2024-11-27 23:00:27 +01:00
ekzyis
2b47bf527b Fix comment 2024-11-27 23:00:27 +01:00
ekzyis
35159bf7f3 Remove unused useWallet from QR code component 2024-11-27 22:54:00 +01:00
soxa
707d7bdf8b
fix: cannot add images above text (#1659)
* fix: cannot add images above text

* make sure there are always 2 newlines on either side of media

---------

Co-authored-by: k00b <k00b@stacker.news>
2024-11-27 15:31:46 -06:00
Riccardo Balbo
e05989d371
Improve LNC realiability by being nicer (#1658)
* be nicer to lnc

* decrease timeout to 4 seconds
2024-11-27 12:46:40 -06:00
Riccardo Balbo
6630899e79
use flexbox for wallet card header and make logos more consistent (#1654)
* use flexbox for wallet card header

* make wallet logo consistent

* remove extra div

* Update styles/wallet.module.css

Co-authored-by: ekzyis <ek@stacker.news>

* Update styles/wallet.module.css

Co-authored-by: ekzyis <ek@stacker.news>

* resize wallet banner

* remove unused justify-content

* remove cardMeta

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-27 12:14:00 -06:00
ekzyis
a032da57b9
Wallet filters (#1627)
* Add wallet filters

* Fix grid layout shift

* Store filter state in query params

* Use auto-fill instead of auto-fit

This doesn't seem to change anything but this is closer to our intention how the grid should work with fixed column width.

* Use same order for filters as icons in card

* Use state update function

* Use user-select: none for wallet filters

* Remove unnecessary '|| false'

* Add media query to keep centered grid layout on small screens

* Decrease wallet filter margin-top to 1rem

* fix wallet support usage

* improve grid

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-27 11:39:30 -06:00
Keyan
0bff478d39
direct receives and send paid action (#1650)
* direct receives and send paid action

* remove withdrawl->invoiceForward has many relationship

* fix formatMsats implicit type expectations

* ui + dropping direct payment bolt11s

* squash migrations

* fix bolt11 dropping and improve paid action wallet logging

* remove redundant sender id

* fix redirect when funding account over threshold

* better logging
2024-11-27 07:39:05 -06:00
ekzyis
8b5e13236b
Fix inconsistent actionArgs on retry (#1651) 2024-11-26 07:39:05 -06:00
ekzyis
b0207a2906
Create useWalletStatus and useWalletSupport (#1646) 2024-11-23 20:19:22 -06:00
ekzyis
f5569d7444
Only fetch logs when we need them (#1638)
* Don't fetch logs as anon

* Only fetch logs if we need them on the current page

* Wait for poll to finish with setTimeout

This makes sure that we wait for the pending poll to finish before we poll again. This prevents running multiple polls at the same time on slow connections.

I noticed we don't need to queue a new poll ourselves since a poll updates effect dependencies so we will cleanup and run the effect again anyway.

* Fix polling via useEffect abuse
2024-11-23 18:37:30 -06:00
ekzyis
cb9947e4f2
refactor: Check darkmode in useWallets (#1640)
* Check darkmode in useWallets

* Check darkmode in useWalletImage
2024-11-23 17:59:59 -06:00
ekzyis
3023d8a5d3
Remove unused function usePayment (#1641) 2024-11-23 12:21:39 -06:00
Riccardo Balbo
a7ea380476
fix LNC attachment and docs (#1639) 2024-11-22 18:03:02 -06:00
Riccardo Balbo
83341d5b7d
add in error message how high are estimated fees when too high (#1634) 2024-11-22 08:26:18 -06:00
ekzyis
923ddb74ca
Use invoice.cancelledAt to determine if invoice expired or was canceled by user (#1631)
* Set and resolve invoice.cancelledAt

* Don't close modal if invoice expired
2024-11-22 08:25:20 -06:00
k00b
96e5c6c51c fix #1628 2024-11-21 08:52:24 -06:00
Riccardo Balbo
eb3efbef57
HOTFIX: restore missing link between lnurlp and nip57 (#1630)
* restore missing link between lnurlp and nip57

* pass noteStr as receive action arg

* make sure the desc field is not updated if noteStr is unset
2024-11-21 08:46:28 -06:00
ekzyis
277f8a20fb
Fix lnaddr not returning payment request (#1626) 2024-11-20 19:57:16 -06:00
ekzyis
7448cef932
Fix wallet status ignoring failed requests to create invoices (#1624) 2024-11-20 16:26:50 -06:00
Riccardo Balbo
d238eafdde
fix typo signular -> singular (#1623) 2024-11-20 16:26:18 -06:00
k00b
09bacdc016 catch wrap invoice error as noninvoiceable 2024-11-20 14:47:22 -06:00
Keyan
67a0565c04
Update awards.csv 2024-11-20 13:58:55 -06:00
Keyan
cfbb274d8f
Update awards.csv 2024-11-20 13:58:25 -06:00
Keyan
b012f7670e
Update awards.csv 2024-11-20 13:57:17 -06:00
Riccardo Balbo
21207184dd
refresh cln certs (#1622) 2024-11-20 12:48:03 -06:00
ekzyis
f45144cb1c
Always check res.ok and content-type header (#1621)
* Always check res.ok and content-type header

* Fix blink body consumed before we use it

* Always consume response body to avoid memory leaks
2024-11-20 12:46:39 -06:00
Simone Cervino
c88afc5aae
fix can't upload mp4 on safari (#1617) 2024-11-20 07:06:05 -06:00
ekzyis
6bae1f1a89
Fix account switching anon login (#1618)
* Always switch to user we just logged in as

If we're logged in and switch to anon and then use login to get into our previous account instead of using 'switch accounts', we only updated the JWT but we didn't switch to the user.

* Fix getToken unaware of multi-auth middleware

If we use login with new credentials while switched to anon (multi_auth.user-id === 'anonymous'), we updated the pubkey because getToken wasn't aware of the switch and thus believed we're logged in as a user.

This is fixed by applying the middleware before calling getToken.
2024-11-20 07:05:42 -06:00
ekzyis
82fead60f1
Fix mentions for pessimistic actions (#1615)
* Fix mentions for pessimistic actions

* (item)mentions should use tx not models

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-19 19:28:21 -06:00
k00b
5dac2f2ed0 fix #1566 2024-11-19 19:12:18 -06:00
Keyan
9179688abc
Merge pull request #1614 from stackernews/wallet-fixes
Wallet fixes
2024-11-19 15:49:12 -06:00
k00b
66ec5d5da8 dark/light mode images on wallet pages 2024-11-19 15:46:11 -06:00
k00b
f3cc0f9e1d make recv optional 2024-11-19 15:38:27 -06:00
k00b
aa4c448999 fix account switch disconnect 2024-11-19 14:58:48 -06:00
Keyan
8441e04d3e
Update awards.csv 2024-11-19 12:55:55 -06:00
ekzyis
bcd229af93
Fix 'Cannot mix BigInt and other types' on zaps (#1609)
* Fix 'Cannot mix BigInt and other types' on zaps

* Also add sybilFeePercent to context in worker

* add paymentMethod to context

---------

Co-authored-by: k00b <k00b@stacker.news>
2024-11-19 09:31:26 -06:00
ekzyis
c2dc0be1c1
Always use custom PTR on Android (#1607) 2024-11-18 19:13:52 -06:00
k00b
5d71e57839 fix ptr interfering with layout container 2024-11-18 17:33:45 -06:00
ekzyis
570c842934
Wallet send+recv status derived from logs (#1559)
* Derive wallet status from logs

* Add send/recv icons

* Set status individually for send and recv

* Move status logic into own function

* Add LNbits, Blink, CLN, LND, phoenixd logo

* Fix wallet.status.any not using Status enum

* Fix WebLN being weird

* Use phoenixd logo with text

* Also use wallet logo on config page

* Also poll logs for wallet status

* Use logger.info for logs not relevant for wallet status

* Remove no longer used wallet badges

* Crop LND logo like other logos

* Fix all wallets show 'configure'

* Fix wallet status not respecting enabled

* Fix wallet.def.requiresConfig undefined

* Fix banner shown for WebLN

* Fix attach shown when configured

* Filter by context.status to determine wallet status

* Fix +- shown without context

* Fix missing theme support for wallet logos
2024-11-18 16:46:24 -06:00
Keyan
62cf5b9c34
Merge pull request #1603 from stackernews/fix-dark-mode-update
Fix missing useDarkMode update
2024-11-18 16:09:16 -06:00
ekzyis
120dd4122f Fix missing useDarkMode update 2024-11-18 23:01:12 +01:00
Keyan
c3b7ad3fdd
Merge pull request #1599 from Soxasora/feat_pwa_pull_to_refresh
feat: PWA pull to refresh
2024-11-18 13:11:56 -06:00
k00b
d2b5d23af5 add body margin on ptr 2024-11-18 13:05:51 -06:00
Soxasora
a992426058 enhance: cleanup, ux/ui changes, safer approach to android's ptr 2024-11-18 15:49:54 +01:00
Keyan
cce7195652
Merge pull request #1600 from stackernews/update-pr-template
Also ask if tested on light/dark mode in pull request template
2024-11-17 16:16:10 -06:00
ekzyis
deccb0fea9 Also ask if tested on light/dark mode 2024-11-17 22:58:28 +01:00
Soxasora
5a359feeed test: togglable Android's native PTR 2024-11-17 21:58:40 +01:00
Simone Cervino
c08088bdbe
fix uploaded videos don't load on safari (#1593)
* fix uploaded videos don't load on safari

* fix safari loading video as image, min-content restored

* refinements for ssr and skip load check for images

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-17 13:38:11 -06:00
Soxasora
eea121e30c feat: pull-to-refresh for PWA without native refresh 2024-11-17 20:23:26 +01:00
Lorenzo
83e72e21cc
fix: reply storage is updated with the new content on file upload (#1585)
* fix: reply storage is updated with the new content on file upload

* Revert "fix: reply storage is updated with the new content on file upload"

This reverts commit 350931fd0c7a47ffe59716722755ab294c481b71.

* chore: reworked image draft save by using events

* chore: helpers.setValue called just after setNativeValue

* chore: updated setNativeValue function to be more use-case specific
2024-11-16 17:23:07 -06:00
Lorenzo
8a2bd84f69
fix: upvote widget not rendered when comment is collapsed (#1583)
* fix: upvote widget not rendered when comment is collapsed

* fix: restored missing conditional on handleShortPress

* chore: icon horizontal space maintained even if the comment is collapsed

* chore: 'rendered' argument renamed to 'visible'

* chore: collapsed condition merged with the 'disabled' variable

* reduce unecessary code

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-16 17:04:49 -06:00
k00b
73df5e0308 improve newsletter import 2024-11-16 16:52:08 -06:00
ekzyis
5631d6acf6
Fix #undefined in downzap invoice description (#1597) 2024-11-16 14:58:33 -06:00
ekzyis
79ada2ab58
Fix unpaid items are counted (#1595)
* Fix unpaid items are counted

* Also fix for ncomments

* Never count unpaid items
2024-11-15 20:02:15 -06:00
Riccardo Balbo
9c55f1ebe2
Implement deposit as receive paidAction (#1570)
* lnurlp paid action

* lnurlp has 10% sybil fee

* fix merge issue

* Update pages/settings/index.js

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>

* fix notifications

* fix destructure

* pass lud18Data to lnurlp action

* minor cleanup

* truncate invoice description to permitted length

* remove redundant targetUserId

* lnurlp paidAction -> receive paidAction

* remove redundant user query

* improve determining if peer is invoiceable

* fix inconsistent relative imports

* prevent paying self-proxied invoices and better held invoice cancellation

* make gun/horse streak zap specific

* unique withdrawal hash should apply to confirmed payments too

* prevent receive from exceeding wallet limits

* notifications

* fix notifications & enhance invoice/withdrawl page

* notification indicator, proxy receive based on threshold, refinements

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-15 18:38:14 -06:00
ekzyis
8c43caed80
Fix TypeError if local storage is cleared (#1594) 2024-11-15 12:02:02 -06:00
ekzyis
b7fc0e0e74
Fix fee added to received amount (#1582) 2024-11-12 19:50:15 -06:00
ekzyis
08c29b9f10
Fix missing sorting of wallet logs (#1581) 2024-11-12 19:48:21 -06:00
ekzyis
3044c2e98f
Fix outlawed can show up on front page (#1565)
If an item has a lot of comments, it gets ranked higher.

When an item is now downzapped, it can become outlawed but still show up on the front page because of the comments.

This fixes it by filtering outlawed items out instead of relying on the ranking to make them not show up.
2024-11-12 19:47:14 -06:00
Keyan
a44d0daf09
paid action payment methods as an array (#1584)
* introduce fee credits & allow paid actions to specify payment method priority

* fix merge issue

* express supported paid action payment methods as an array

* log force payment method skipping methods

* fix stuff

* immutable context

* immutable paidAction context and other fixes

---------

Co-authored-by: Riccardo Balbo <riccardo0blb@gmail.com>
2024-11-12 19:00:51 -06:00
k00b
d1c770dbbc noop walletLogger if wallet isn't provided 2024-11-12 09:22:51 -06:00
k00b
cb8cce77f0 don't let args overwrite withdrawal/deposit checking params 2024-11-12 08:50:54 -06:00
Riccardo Balbo
7e5a8310df
fix lnd hostname (#1580) 2024-11-11 17:52:15 -06:00
Riccardo Balbo
18700b4201
configurable sybil fee (#1577)
* configurable sybil fee

* document getSybilFeePercent

* fixes

* remove null check

* refine at the margins

---------

Co-authored-by: k00b <k00b@stacker.news>
2024-11-11 16:59:52 -06:00
ekzyis
fdd34b2eb3
Fix edit countdown on deleted items (#1571)
* Clarify conditions to show edit countdown

* Fix edit countdown shown for deleted items

* refactor: Minimize canEdit state

I noticed that only anonEdit requires state because it needs to use useEffect to fetch from local storage.

The other conditions can simply be checked during render.

* refactor: Use datePivot for edit countdown
2024-11-11 09:23:08 -06:00
ekzyis
406ae81693
Fixes around account switching / authentication (#1575)
* Fix missing page reload after account switch on logout

* Fix missing key

* Explain why we set multi_auth cookies on login/signup

* Fix 500 if multi_auth cookie missing
2024-11-11 09:16:32 -06:00
Keyan
4675a2c29d
sndev cleanup (#1563)
* begin sndev cleanup

* cleanup sndev

* fix posix shell compliance

* add tests to sndev
2024-11-09 15:52:04 -06:00
Keyan
c31cf97288
fix precedence of op for not full hot sort (#1564) 2024-11-09 14:25:52 -06:00
ekzyis
d06f4ae70d
Fix offcanvas not closed before showing logout prompt (#1561) 2024-11-09 14:00:36 -06:00
ekzyis
afb71012af
Only send push notification if referrer was updated (#1562) 2024-11-09 14:00:07 -06:00
ekzyis
72e2d19433
supercharged wallet logs (#1516)
* Inject wallet logger interface

* Include method in NWC logs

* Fix wrong page total

* Poll for new logs every second

* Fix overlapping pagination

* Remove unused total

* Better logs for incoming payments

* Use _setLogs instead of wrapper

* Remove inconsistent receive log

* Remove console.log from wallet logger on server

* Fix missing 'wallet detached' log

* Fix confirm_withdrawl code

* Remove duplicate autowithdrawal log

* Add context to log

* Add more context

* Better table styling

* Move CSS for wallet logs into one file

* remove unused logNav class
* rename classes

* Align key with second column

* Fix TypeError if context empty

* Check content-type header before calling res.json()

* Fix duplicate 'failed to create invoice'

* Parse details from LND error

* Fix invalid DOM property 'colspan'

* P2P zap logs with context

* Remove unnecessary withdrawal error log

* the code assignment was broken anyway
* we already log withdrawal errors using .catch on payViaPaymentRequest

* Don't show outgoing fee to receiver to avoid confusion

* Fix typo in comment

* Log if invoice was canceled by payer

* Automatically populate context from bolt11

* Fix missing context

* Fix wrap errors not logged

* Only log cancel if client canceled

* Remove unused imports

* Log withdrawal/forward success/error in payment flow

* Fix boss not passed to checkInvoice

* Fix TypeError

* Fix database timeouts caused by logger

The logger shares the same connection pool with any currently running transaction.

This means that we enter a classic deadlock when we await logger calls: the logger call is waiting for a connection but the currently running transaction is waiting for the logger call to finish before it can release a connection.

* Fix cache returning undefined

* Fix typo in comment

* Add padding-right to key in log context

* Always use 'incoming payment failed:'

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-11-08 13:26:40 -06:00
k00b
f1b2197d31 find lightning module relative to project directory 2024-11-08 09:37:45 -06:00
ekzyis
d6916fa3f4
Merge pull request #1557 from stackernews/fix-invoice-waiting
fix invoice waiting
2024-11-08 05:25:05 +01:00
k00b
dcab8e1365 fix invoice waiting 2024-11-07 19:55:34 -06:00
Keyan
e9a5925c50
Merge pull request #1556 from stackernews/fix-1544
fix invoice status display
2024-11-07 19:25:06 -06:00
k00b
544a54399c fix invoice status display 2024-11-07 19:17:50 -06:00
Keyan
0891e51c9e
fix passphrase scanning (#1553) 2024-11-07 16:24:41 -06:00
Keyan
a67ef43f6e
listen for local storage wallet changes (#1552) 2024-11-07 13:15:39 -06:00
Keyan
d117549348
fix lightning module resolution (#1551)
* fix tsx usage

* Revert "Revert "Merge pull request #1521 from riccardobl/tordev""

This reverts commit 4cc3780eca1f1390bdd331f0e418b9a7efd4347e.

* lightning module resolution

* fix our one test
2024-11-07 09:03:54 -06:00
k00b
4cc3780eca Revert "Merge pull request #1521 from riccardobl/tordev"
This reverts commit 3112fc30d8b476c3682d6dc717bf0c83b50286a6, reversing
changes made to 803daed2dfe7171d317e9bf4bf02ed6a0962822c.
2024-11-05 20:08:33 -06:00
Keyan
4ad93cd4ab
Merge pull request #1546 from stackernews/fix-1543
fix vault key changes not seen by all instances of useVault
2024-11-05 19:45:16 -06:00
k00b
f0a5bc4f27 fix vault key changes not seen by all instances of useVault 2024-11-05 17:25:18 -06:00
Keyan
281757dd81
Merge pull request #1545 from riccardobl/passhide
Use -webkit-text-security to hide passphrase
2024-11-05 12:59:11 -06:00
Riccardo Balbo
49caec5b63 do not override passed style 2024-11-05 18:06:45 +01:00
Riccardo Balbo
4d8f85508a Use -webkit-text-security to hide passphrase 2024-11-05 17:16:27 +01:00
Keyan
532abcb486
Merge pull request #1540 from riccardobl/inputdefault
add explicit support for default values in input fields
2024-11-05 09:37:23 -06:00
Riccardo Balbo
cd4fcdff83 Merge remote-tracking branch 'upstream/master' into inputdefault 2024-11-05 16:35:16 +01:00
Riccardo Balbo
7eb668e0e2 explicit default values 2024-11-05 16:27:48 +01:00
Keyan
7efd0899c8
Merge pull request #1539 from stackernews/fix-1538
fix #1538
2024-11-05 07:02:38 -06:00
Keyan
3112fc30d8
Merge pull request #1521 from riccardobl/tordev
TOR patch
2024-11-04 19:41:54 -06:00
Keyan
40ff3a83f4
Merge branch 'master' into tordev 2024-11-04 19:20:29 -06:00
Keyan
803daed2df
Merge pull request #1535 from stackernews/fix-global-wallet-logs
Fix global wallet logs never fetch logs from server
2024-11-04 18:52:02 -06:00
Keyan
6feaf383b9
Merge pull request #1536 from stackernews/fix-wallet-priority-reorder
Fix wallet priority reorder inconsistency
2024-11-04 18:50:07 -06:00
k00b
db55cc7a00 fix 1538 2024-11-04 18:39:52 -06:00
ekzyis
bcaa135270 Fix wallet priority reorder 2024-11-05 00:40:06 +01:00
ekzyis
be212d1de9 Fix global wallet logs never fetch logs from server 2024-11-05 00:39:57 +01:00
Keyan
8ab44e19fd
Merge pull request #1537 from riccardobl/rqwt
requiredWithout doesn't expect an array
2024-11-04 15:43:07 -06:00
Riccardo Balbo
0416ebbdd8 requiredWithout doesn't expect an array 2024-11-04 22:18:43 +01:00
Riccardo Balbo
571a230b3c fix require is not defined in ES module scope 2024-11-04 20:09:34 +01:00
Keyan
d6caee5b42
Merge pull request #1510 from stackernews/fix-more-shown-after-delete
Fix more button shown after logs deleted
2024-11-04 07:28:58 -06:00
ekzyis
0901f15249 Fix more button shown after logs deleted by wrapping setLogs 2024-11-04 14:05:41 +01:00
ekzyis
58c37bbd63 Fix more button shown after logs deleted 2024-11-04 13:22:40 +01:00
Riccardo Balbo
048c27fa7e
Merge branch 'master' into tordev 2024-11-04 10:38:00 +01:00
Riccardo Balbo
c708c5bf6f fix lnd paths 2024-11-04 10:13:22 +01:00
Riccardo Balbo
7a8646c516 stacker_cln get_onion -> stacker_clncli get_onion 2024-11-04 10:12:52 +01:00
Keyan
d9e9a6722a
Merge pull request #1528 from riccardobl/trace
decode minified stacktrace
2024-11-03 17:28:25 -06:00
Keyan
08b160d663
Merge branch 'master' into tordev 2024-11-03 14:52:21 -06:00
Keyan
e375cc7c76
Merge pull request #1507 from stackernews/wallet-fantasy-refactor
Fantasy wallet refactor
2024-11-03 09:10:01 -06:00
k00b
a4440c991f add removeLocalWallets fixes from rblb 2024-11-03 01:15:10 -05:00
k00b
fb65ea3ace fix removing server config on unsynced client vault 2024-11-03 01:09:45 -05:00
k00b
25facad5d9 merge master 2024-11-02 19:15:44 -05:00
Riccardo Balbo
9e39e0bd01
Merge branch 'master' into tordev 2024-11-03 00:36:42 +01:00
Riccardo Balbo
1d6f1a962b
Merge branch 'master' into trace 2024-11-03 00:36:30 +01:00
Keyan
193ceefc58
Merge pull request #1527 from riccardobl/urfh
Prevent UPDATE_COMMENT from invalidating the cache of child comments (Fix #1509 )
2024-11-02 16:52:04 -05:00
Keyan
a253e42829
Merge pull request #1512 from riccardobl/clearbug
Fix some issues in clear button for input fields
2024-11-02 16:45:33 -05:00
Keyan
fee9a96186
Merge pull request #1479 from riccardobl/blinkreceiver
Blink Receive
2024-11-02 16:45:03 -05:00
Riccardo Balbo
3b523fe949
Merge branch 'master' into clearbug 2024-11-02 11:45:13 +01:00
Riccardo Balbo
6cb4870595
Merge branch 'master' into blinkreceiver 2024-11-02 11:44:54 +01:00
Riccardo Balbo
5924de6172
Merge branch 'master' into tordev 2024-11-02 11:43:59 +01:00
Riccardo Balbo
d75f9d5c74
Merge branch 'master' into urfh 2024-11-02 11:43:31 +01:00
Riccardo Balbo
2cd16a68c2
Merge branch 'master' into trace 2024-11-02 11:43:16 +01:00
Keyan
fd5f649d5c
Merge pull request #1520 from stackernews/edit-ux
Don't hide cancel if edit timer runs out
2024-11-01 20:28:43 -05:00
Keyan
11a5bb5367
Merge branch 'master' into edit-ux 2024-11-01 20:18:49 -05:00
Keyan
c68589597d
Merge pull request #1513 from stackernews/fix-hide-invoice-desc
Fix description of wrapped invoices don't respect privacy setting
2024-11-01 20:09:31 -05:00
Keyan
0039efc8c7
Merge pull request #1506 from stackernews/colored-badges
Color send & recv badges
2024-11-01 20:02:26 -05:00
Keyan
2b136715a5
Merge branch 'master' into colored-badges 2024-11-01 19:12:34 -05:00
Keyan
10008f71e2
Merge pull request #1529 from stackernews/fix-territory-revenue
fix territory revenue attribution for one founder and multiple territories
2024-11-01 13:42:06 -05:00
k00b
a73ac8896e fix territory revenue attribution for one founder and multiple terrtories 2024-11-01 13:38:04 -05:00
k00b
dccfd21be4 make sure autowithdraw members are numbers 2024-10-31 18:25:15 -05:00
k00b
14ab51a730 fixes related to p2p zaps 2024-10-31 18:12:55 -05:00
k00b
7b4a33b354 fix wallet creation without vaultEntries 2024-10-31 17:54:47 -05:00
k00b
1f71a9e187 fix logout purge of localstorage 2024-10-31 15:36:51 -05:00
k00b
b8216740d4 final touches 2024-10-31 14:06:58 -05:00
Riccardo Balbo
c3c3fe1ccb Always return original stack trace 2024-10-31 15:54:54 +01:00
Riccardo Balbo
be9b919b60 decode minified stacktrace 2024-10-31 15:43:20 +01:00
k00b
3cfbaf4638 validate generated fields 2024-10-30 22:26:45 -05:00
k00b
b1fc341017 sync/desync from localstorage on vault connect/disconnect 2024-10-30 18:37:45 -05:00
k00b
0c8180d89c fix active vault check and optional vaultEntries 2024-10-30 13:49:57 -05:00
k00b
dce5762f63 get vault working 2024-10-30 13:49:57 -05:00
k00b
eae4c2b882 cancel button spacing 2024-10-30 13:49:57 -05:00
k00b
4f7bdadd80 better wallet security banner 2024-10-30 13:49:57 -05:00
k00b
84f5db4488 update wallet readme 2024-10-30 13:49:57 -05:00
k00b
aa04adacea remove unused phoenix schema 2024-10-30 13:49:57 -05:00
k00b
e96982c353 refactor wallet validation 2024-10-30 13:49:57 -05:00
k00b
57603a936f reorder priority 2024-10-30 13:49:57 -05:00
k00b
ccdf346954 server side config saves 2024-10-30 13:49:47 -05:00
k00b
4826ae5a7b wip upsertWallet 2024-10-30 13:45:09 -05:00
k00b
2bdbb433df webln saves at least *double kazoo* 2024-10-30 13:45:09 -05:00
k00b
48640cbed6 pages load *kazoo* 2024-10-30 13:45:05 -05:00
k00b
da020cf899 complete fantasy scaffolding 2024-10-30 13:44:18 -05:00
k00b
b61c957cc7 fix missing field from merge conflict resolution 2024-10-30 13:42:55 -05:00
k00b
4e61c19bb3 fix showing autowithdraw settings on send only wallets 2024-10-30 13:42:55 -05:00
Riccardo Balbo
1e68182cda sender wallets: only test if enabled 2024-10-30 13:42:55 -05:00
Riccardo Balbo
2cfe851046 merge migrations 2024-10-30 13:42:55 -05:00
Riccardo Balbo
319db6dea6 fix schema 2024-10-30 13:42:55 -05:00
Riccardo Balbo
6a23aac6f9 Update api/resolvers/vault.js 2024-10-30 13:42:55 -05:00
Riccardo Balbo
ed66cfb3f8 remove unused fragment, fix BestWallet to return flags 2024-10-30 13:42:55 -05:00
Riccardo Balbo
fdc3df9c15 force refresh client wallets when device sync is enabled 2024-10-30 13:42:55 -05:00
Riccardo Balbo
00c047f09b do not drop config on error (might be caused by temporary connection issues) 2024-10-30 13:42:55 -05:00
Riccardo Balbo
3acad86157 improve local storage hook implementation 2024-10-30 13:42:55 -05:00
Riccardo Balbo
86994c4c46 use openConfig instead of useConfig 2024-10-30 13:42:55 -05:00
Riccardo Balbo
eeef7039b9 prevent stale me entry from causing vault configurator to delete the local vault key 2024-10-30 13:42:55 -05:00
Riccardo Balbo
4bc669c1c5 prevent double close 2024-10-30 13:42:55 -05:00
Riccardo Balbo
623b69df3a skip wallet fetch for anon users 2024-10-30 13:42:55 -05:00
Riccardo Balbo
41b86c8251 unsetLocalKey 2024-10-30 13:42:55 -05:00
Riccardo Balbo
de0eb8a52c ensure that wallets are configured to send and/or receive 2024-10-30 13:42:55 -05:00
Riccardo Balbo
0263aa8372 do not test invoice when disabling wallets 2024-10-30 13:42:50 -05:00
Riccardo Balbo
240040f2a3 ensure wallets are kept in-sync between clients 2024-10-30 13:41:41 -05:00
Riccardo Balbo
1beac3a405 ensure wallet id is in sync before saving the config 2024-10-30 13:41:41 -05:00
Riccardo Balbo
a6665bca6a fix priority sorting for send wallets,caching and sorting 2024-10-30 13:41:41 -05:00
Riccardo Balbo
bb91b629f7 make sorting optional 2024-10-30 13:41:41 -05:00
Riccardo Balbo
40f24236fd show enabled only if configured to receive or send (handle client settings wipe) 2024-10-30 13:41:41 -05:00
Riccardo Balbo
2ef7651421 optimize api calls, remove useless effects 2024-10-30 13:41:41 -05:00
Riccardo Balbo
aded5ac422 fixes 2024-10-30 13:41:41 -05:00
Riccardo Balbo
87c5634b55 add debug log 2024-10-30 13:41:41 -05:00
Riccardo Balbo
f438b278bc fix priority sorting 2024-10-30 13:41:41 -05:00
Riccardo Balbo
d30502a011 fix wallet filtering 2024-10-30 13:41:41 -05:00
Riccardo Balbo
06afe2cda2 remove debug log 2024-10-30 13:41:41 -05:00
Riccardo Balbo
4fce6fa234 Fix for enabled but not available wallets 2024-10-30 13:41:41 -05:00
Riccardo Balbo
6bd07284a5 optimize api calls 2024-10-30 13:41:41 -05:00
Riccardo Balbo
4aa9608212 fixed and add wallet migration 2024-10-30 13:41:41 -05:00
Riccardo Balbo
6f1113636f fix: await in transaction 2024-10-30 13:41:41 -05:00
Riccardo Balbo
a95e4cd6e9 collect meta from server config 2024-10-30 13:41:41 -05:00
Riccardo Balbo
4604a7bac9 use SSR constant 2024-10-30 13:41:41 -05:00
Riccardo Balbo
49cf1f2e23 fix window checks for SSR 2024-10-30 13:41:41 -05:00
Riccardo Balbo
b70dbeb6d6 user vault and server side client wallets 2024-10-30 13:41:09 -05:00
Riccardo Balbo
3d708ae7eb Prevent UPDATE_COMMENT from invalidating the cache of child comments 2024-10-30 18:42:30 +01:00
Riccardo Balbo
d9a51f9c1c
Merge branch 'master' into tordev 2024-10-29 11:21:25 +01:00
Keyan
7a942881ed
Merge pull request #1526 from stackernews/1525-fix-mathjax-unicode-typerror
Replace unicode currency symbols in inline math
2024-10-28 14:41:54 -05:00
ekzyis
b50bb2fcc1 Replace unicode currency symbols in inline math 2024-10-28 20:00:00 +01:00
Riccardo Balbo
96e1f86bca use patched authenticatedLndGrpc instead of privoxy to handle non onion grpc traffic 2024-10-26 20:06:22 +02:00
Riccardo Balbo
4fb873b105 enable tor for cln and lnbits 2024-10-26 11:36:06 +02:00
Riccardo Balbo
33c4314212 run both tor http proxy and privoxy 2024-10-26 11:35:48 +02:00
Riccardo Balbo
57042d9ed0 use cross-fetch because native fetch doesn't support agents 2024-10-26 11:34:38 +02:00
Riccardo Balbo
5466270c41 grpc_proxy should be lowercase (?) 2024-10-26 11:00:39 +02:00
Riccardo Balbo
5a09c48e04 tor for the dev environment 2024-10-26 01:53:08 +02:00
ekzyis
146f578d3d Rename onEdit to toggleEdit 2024-10-25 22:10:15 +02:00
ekzyis
739509de4f Don't hide cancel if still in edit mode 2024-10-25 22:05:29 +02:00
Keyan
18565200e6
Merge pull request #1519 from stackernews/1518-fix-invites
Fix missing fragmentName
2024-10-25 07:33:29 -05:00
ekzyis
6fa747b234 Fix missing fragmentName 2024-10-25 14:10:41 +02:00
Keyan
6c16b5eff3
Update feature_request.yml 2024-10-24 16:39:54 -05:00
Keyan
be6bcd7736
Update feature_request.yml 2024-10-24 16:39:12 -05:00
Keyan
687b9c343c
Update bug_report.yml 2024-10-24 16:38:30 -05:00
Keyan
25238f261a
Merge pull request #1514 from stackernews/gh-templates
Use issue forms
2024-10-24 16:37:24 -05:00
ekzyis
3fa4037197 More concise pull request template 2024-10-24 22:31:10 +02:00
ekzyis
9987921692 Use Github form templates for issues 2024-10-24 22:31:10 +02:00
ekzyis
383272f1c2 Fix description of wrapped invoices don't respect privacy setting 2024-10-24 21:48:44 +02:00
Riccardo Balbo
a267f3a476 use useIsClient 2024-10-24 19:58:04 +02:00
Riccardo Balbo
68dafa3684 disable clear button if field is readonly 2024-10-24 16:27:13 +02:00
Riccardo Balbo
9c6e98b47b use effect and state to disable clear button: prevent hydration errors if the field is initialized differently in the client 2024-10-24 16:27:06 +02:00
ekzyis
455f6ab665 Merge branch 'master' into blinkreceiver 2024-10-22 21:21:56 +02:00
ekzyis
ba06f3043f Color send & recv badges 2024-10-22 21:15:43 +02:00
Riccardo Balbo
00ac89287b Fix readonly (https://github.com/stackernews/stacker.news/pull/1479#pullrequestreview-2383552377) 2024-10-22 10:57:14 +02:00
Riccardo Balbo
bc4b4196f4 make sending checks less strict 2024-10-22 10:54:05 +02:00
Riccardo Balbo
751869fb63 fix text 2024-10-22 10:52:58 +02:00
Riccardo Balbo
63c2a7f270 mark fields as non editable 2024-10-22 10:51:41 +02:00
k00b
820ea90267 bump secp256k1 2024-10-21 14:52:55 -05:00
Keyan
31d3b2fd76
Update awards.csv 2024-10-21 09:26:03 -05:00
Keyan
46b2a57ece
Merge pull request #1488 from stackernews/max-base-fee
Max total fee
2024-10-20 18:44:47 -05:00
Keyan
d146e50660
Merge branch 'master' into max-base-fee 2024-10-20 18:25:16 -05:00
Keyan
fd9b087c99
Update awards.csv 2024-10-20 18:06:28 -05:00
Keyan
e307573e8b
Merge pull request #1444 from stackernews/local-dev-lnbits-recv
Map localhost:<port> to lnbits:5000 on server
2024-10-20 17:56:28 -05:00
Keyan
6049baf742
Merge branch 'master' into local-dev-lnbits-recv 2024-10-20 17:38:57 -05:00
ekzyis
88fa3bdca6 Fix autoWithdrawThreshold saved in send config 2024-10-20 15:19:53 +02:00
ekzyis
c97ce2627b Rename to autoWithdrawMaxFeeTotal 2024-10-20 15:14:31 +02:00
ekzyis
d06f89d707 Change text 2024-10-20 15:14:31 +02:00
ekzyis
596d67fc68 Add max base fee setting 2024-10-20 15:14:31 +02:00
Keyan
f5ebd573d6
Merge pull request #1499 from stackernews/fix-wallet-save
Fix wallet client validation
2024-10-19 20:22:22 -05:00
Keyan
5d5bc22e3d
Merge pull request #1502 from stackernews/fix-unarchive
Fix territory unarchive schema validation
2024-10-19 20:21:40 -05:00
ekzyis
50e153df7c Fix territory unarchive schema validation 2024-10-20 01:25:25 +02:00
ekzyis
1f9ab08228 Add comments 2024-10-19 22:36:26 +02:00
ekzyis
69c80e3d5c Fix wallet client validation 2024-10-19 22:33:37 +02:00
Keyan
7aa0d8f430
Merge pull request #1498 from stackernews/fix-1485
premature invoice deletion: fix interval in sql template
2024-10-19 09:52:16 -05:00
k00b
01580d9ee8 delete primage when invoice is deleted 2024-10-19 09:51:24 -05:00
k00b
bcd8adae45 fix interval in sql template 2024-10-18 20:20:45 -05:00
Keyan
59a24192c1
Merge pull request #1487 from stackernews/fix-missing-autowithdraw-validation
Fix missing autowithdraw settings validation
2024-10-18 13:06:57 -05:00
Riccardo Balbo
d45cf99dd4 Add autowithdrawSchemaMembers 2024-10-18 19:17:22 +02:00
ekzyis
6cc49e937b Fix missing autowithdraw settings validation 2024-10-18 18:58:59 +02:00
Riccardo Balbo
fea990390a remove strict toggle 2024-10-18 17:20:24 +02:00
Riccardo Balbo
1ef2af3f5d fix error text 2024-10-18 17:18:19 +02:00
Keyan
ad1244c260
Merge pull request #1484 from stackernews/fix-wallet-logs-pagination
Fix first page of wallet logs loaded twice
2024-10-18 09:07:54 -05:00
ekzyis
49ddace73f Fix first page of wallet logs loaded twice 2024-10-18 15:10:27 +02:00
Keyan
153f47de7d
Merge pull request #1480 from stackernews/fix-wallet-logs-infinite-loop
Fix infinite loop of loading wallet logs + center more button
2024-10-18 07:52:03 -05:00
Keyan
6b1f671329
Merge pull request #1483 from stackernews/fix-lnaddr-withdrawals
Fix lnaddr withdrawals
2024-10-18 07:50:45 -05:00
ekzyis
ab1104e115 Fix lnaddr withdrawals 2024-10-18 14:19:40 +02:00
k00b
aba212f6ec don't abort on blinded path feature bits 2024-10-17 14:23:03 -05:00
ekzyis
70858b97f7 Center more button 2024-10-17 21:22:45 +02:00
ekzyis
d91c5c90de Fix infinite loop of loading wallet logs 2024-10-17 21:18:11 +02:00
ekzyis
75051f1c56 LNbits updates for local dev
* persistent lnbits db with lnbits superuser
* map localhost:5001 -> lnbits:5000 in local dev for LNbits receives
* updated ATTACH.md
2024-10-17 20:54:07 +02:00
k00b
6aac9eeed4 update breaking change in estimateRouteFee 2024-10-17 13:25:49 -05:00
k00b
1d3ab23ec4 cached fetcher delete key 2024-10-16 14:43:11 -05:00
k00b
1645c2aabf avoid dev refresh of lnd 2024-10-16 14:43:00 -05:00
Riccardo Balbo
ce3ee703df improve validation errors 2024-10-16 19:56:27 +02:00
Riccardo Balbo
14b6d7f818 blink receive 2024-10-16 19:46:33 +02:00
Keyan
ec8e775ae6
Merge pull request #1477 from stackernews/upgrade-deps
Upgrade deps
2024-10-15 12:47:56 -05:00
k00b
031d589686 disable next telemetry 2024-10-15 12:13:40 -05:00
Keyan
7d139faca1
Merge branch 'master' into upgrade-deps 2024-10-15 11:34:55 -05:00
k00b
4b18498651 hide cowboy hat -> essentials 2024-10-14 17:52:40 -05:00
k00b
2562999e85 fix mathjax single dollar conflicts 2024-10-13 10:08:54 -05:00
k00b
fac70a0b94 npm audit 2024-10-12 18:28:16 -05:00
k00b
81897461e3 fix qrcode display after upgrade 2024-10-12 18:06:07 -05:00
k00b
6a8b823f9f upgrade qr code scanner and fix #1476 2024-10-12 18:06:07 -05:00
k00b
f9ed1ee6f5 upgrade non-(apparently)-breaking major versions 2024-10-12 18:06:07 -05:00
k00b
ff3ad7676d upgrade minor dep versions 2024-10-12 18:06:07 -05:00
k00b
9a6a167dd4 upgrade patch versions 2024-10-12 18:06:07 -05:00
k00b
021a13d21e fix anon badge 2024-10-12 18:05:45 -05:00
Keyan
245419185f
wallet streaks (#1468)
* wallet streaks backend

* notifications and badges

* reuseable streak fragment

* squash migrations

* push notifications

* update cowboy notification setting label text
2024-10-11 19:14:18 -05:00
k00b
915fc87596 tradeoff memory for throughput in prod 2024-10-10 18:11:39 -05:00
k00b
ce2d7e5791 try to fix apollo leak 2024-10-10 16:03:21 -05:00
k00b
d6d4f01b45 remove prod debug of cached fetcher 2024-10-10 10:44:11 -05:00
k00b
c634c61dd2 cached fetcher debug env var 2024-10-10 09:35:39 -05:00
ekzyis
7eaaa7ce44
Fix sub?.removeAllListeners is not a function (#1469) 2024-10-09 20:13:53 -05:00
ekzyis
41da95b125
Fix wallet double tap on mobile (#1467)
* Fix wallet double tap on mobile

* also add icon that card can be dragged

* Fix ugly drag image
2024-10-09 19:27:49 -05:00
Keyan
e48cd61721
remove unused fields from me fragment (#1466) 2024-10-09 11:56:29 -05:00
k00b
29b3f6008e reduce prod heap size 2024-10-08 22:25:08 -05:00
k00b
adcb80782b caching is hard 2024-10-08 19:26:29 -05:00
k00b
449568e3a2 don't let pending cache to build up 2024-10-08 17:58:15 -05:00
k00b
8c0cafa3ec bump nodejs version 2024-10-08 15:23:27 -05:00
k00b
67498fbc87 bump nextjs patch version 2024-10-08 15:00:37 -05:00
k00b
f8d88d18f8 give nextjs more memory in prod 2024-10-08 14:54:21 -05:00
k00b
651053fd71 remove deprecated canonizeResults 2024-10-08 14:25:23 -05:00
k00b
ebe513b5ca bump apollo versions 2024-10-08 14:22:12 -05:00
Keyan
fec7c92fd9
run noncritical side effects outside critical path of paid action (#1464)
* run noncritical side effects outside critical path of paid action

* fix item fetching of zap side effect

* fix vapid pubkey env var name in readme
2024-10-08 11:48:19 -05:00
k00b
4532e00085 Revert "Include extension in S3 key (#1426)"
This reverts commit b82641d1bdd020142e453477d21230b1a3cdcdc1.
2024-10-07 13:22:01 -05:00
Keyan
6b1f3ba8ef
Update awards.csv 2024-10-07 12:09:10 -05:00
k00b
a916533826 fix apple-mobile-web-app-capable deprecation 2024-10-07 10:52:29 -05:00
k00b
070b350211 catch when indexeddb is not available fix #1462 2024-10-07 10:51:25 -05:00
k00b
154c0e0a4a fix cache key bloat 2024-10-06 18:56:48 -05:00
k00b
177e0f6bb0 fix #1453 2024-10-05 13:57:55 -05:00
k00b
153455983e Revert "Encrypted device sync (#1373)"
This reverts commit a9a566a79f1ce59255f85127a58642beb6b70241.
2024-10-04 15:00:13 -05:00
Keyan
5543a0755a
paginating wallet logs (#1459)
* paginating wallet logs

* refine
2024-10-04 06:59:32 -05:00
k00b
00bcd8c992 fix #1451 2024-10-02 21:23:16 -05:00
Keyan
a01590e321
Update awards.csv 2024-10-02 19:53:26 -05:00
k00b
dff452f00f mathjax close #1436 2024-10-02 19:52:05 -05:00
Keyan
f4382ad73e
better boost hints (#1441)
* better boost hints

* refine
2024-10-02 19:24:01 -05:00
Keyan
5f1d3dbde4
Update awards.csv 2024-10-02 18:53:18 -05:00
k00b
65a7ef10d0 make bio work as paid action 2024-10-02 18:39:56 -05:00
k00b
5fab3abb82 fix bio quirks 2024-10-02 18:12:49 -05:00
toyota-corolla0
c400a6c1c6
feat: cache bio draft (#1455) 2024-10-02 18:06:22 -05:00
Keyan
4ce395889d
Be kind to lnd (#1448)
* cache or remove unecessary calls to lnd

* avoid redundant grpc calls in state machine

* store preimage whenever available

* enhancements post self-code review

* small refinements

* fixes

* fix lnurl-verify

* prevent wallet logger throwing on idb close

* fix promise in race while waiting for payment
2024-10-02 15:03:30 -05:00
toyota-corolla0
56809d6389
chore: update postgres docker container version (#1449)
* chore: update postgres docker container

* sndev postgres to v16

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-10-02 08:42:56 -05:00
Riccardo Balbo
a9a566a79f
Encrypted device sync (#1373)
* user vault

* code cleanup and fixes

* improve ui

* prevent name collisions between users on the same device

* some improvements

* implement storage migration

* comments and cleanup

* make connect button primary instead of warning

* move show passphrase in new line (improvement for small screen devices)

* make show passphrase field readOnly

* fixes

* fix vault key unsync

* implicit migration

* move device sync under  general tab

* fix locally disabled wallets and default wallet selection

* improve text

* remove useless SSR check

* add auth checks

* Rename variables

* Fix missing await

* Refactor local<>vault storage interface

I've changed quite some things here. Attempt of a summary:

* storageKey is now only controlled by useVaultStorageState

I've noticed that dealing with how storage keys are generated (to apply user scope) was handled in two places: the existing wallet code and in the new vault code.

This was confusing and error-prone. I've fixed that by completely relying on the new vault code to generate correct storage keys.

* refactored migration

Migration now simply encrypts any existing local wallets and sends them to the server. On success, the local unencrypted version is deleted.

The previous code seemed to unnecessarily generate new local entries prefixed by 'vault:'.

However, since we either use unencrypted local state OR use the encrypted vault on the server for the data, I didn't see any need for these.

Migration seems to work just as well as before.

* removed unnecessary state

In the <DeviceSync> component, enabled & connected were using a unnecessary combo of useState+useEffect.

They were only using variables that are always available during render so simple assignments were enough.

* other minor changes include:

  * early returns
  * remove unnecessary SSR checks in useEffect or useCallback
  * formatting, comments
  * remove unnecessary me? to expose possible bugs

* Fix missing dependency for useZap

This didn't cause any bugs because useWallet returns everything we need on first render.

This caused a bug with E2EE device sync branch though since there the wallet is loaded async.

This meant that during payment, the wallet config was undefined.

* Assume JSON during encryption and decryption

* Fix stale value from cache served on next fetches

* Add wallet.perDevice field

This adds 'perDevice' as a new wallet field to force local storage. For example, WebLN should not be synced across devices.

* Remove debug buttons

* Rename userVault -> vault

* Update console.log's

* revert some of the migration and key handling changes. restore debug buttons for testing

* Fix existing wallets not loaded

* Pass in localOnly and generate localStorageKey once

* Small refactor of migration

* Fix wallet drag and drop

* Add passphrase copy button

* Fix priorityOnly -> skipTests

* Disable autocompletion for reset confirmation prompt

* Show wrong passphrase as input error

* Move code into components/device-sync.js

* Import/export passphrase via QR code

* Fix modal back button invisible in light mode

* Fix modal closed even on connect error

* Use me-2 for cancel/close button

* Some rephrasing

* Fix wallet detach

* Remove debug buttons

* Fix QR code scan in dark mode

* Don't allow custom passphrases

* More rephrasing

* Only use schema if not enabled

* Fix typo in comment

* Replace 'generate passphrase' button with reload icon

* Add comment about IV reuse in GCM

* Use 600k iterations as recommended by OWASP

* Set extractable to false where not needed

* use-vault fallbacks to local storage only for anonymous users

* fix localStorage reset on logout

* add copy button

* move reset out of modals

* hide server side errors

* hardened passphrase storage

* do not show passphrase even if hardened storage is disabled (ie. indexeddb not supported)

* show qr code button on passphrase creation

* use toast for serverside error

* Move key (de)serialization burden to get/setLocalKey functions

* password textarea and remove qr

* don't print plaintext vault values into console

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-10-01 14:55:01 -05:00
ekzyis
5d1be66eef
Add nwc_recv container (#1442) 2024-10-01 09:14:29 -05:00
ekzyis
7d86ba3865
Fix local anon not called 'anon' (#1443) 2024-09-30 22:45:11 -05:00
k00b
861e944fe1 fix item full embed for the 3rd and final(?) time 2024-09-30 12:37:33 -05:00
k00b
865cdccb2a fix item full embed param 2024-09-30 12:24:06 -05:00
k00b
2e1a7c1035 don't allow other markup in headings 2024-09-29 11:25:01 -05:00
k00b
bbdc37ffd4 fix missing src on autolinks 2024-09-29 11:13:33 -05:00
k00b
6f198fd1ca dark mode for nostr embed 2024-09-28 20:02:33 -05:00
k00b
4f158a98a8 fix link item embed regression 2024-09-28 19:17:15 -05:00
k00b
80a7c24e54 recognize nostr entities 2024-09-28 18:56:37 -05:00
k00b
f7010ee3f7 fix #663 2024-09-28 18:20:33 -05:00
Keyan
cc4bbf99e4
fixes #1395 (#1430)
* fixes #1395

* rehype plugin for embeds

* fix lint

* replace many plugins with one rehype and improve image collage

* remove unused css

* handle more custom markdown behavior in rehype

* refactor markdown rendering more + better footnotes

* move more markdown logic to reyhpe plugin + better headers

* fix #1397

* refactor embeds out of media-or-link
2024-09-28 16:33:07 -05:00
Keyan
0f9b6f02f6
Update awards.csv 2024-09-26 18:41:24 -05:00
ekzyis
9f79d588a8
Image carousel (#1425)
* Add image carousel in fullscreen

* Flip through all images of a post

* Disable image selection in fullscreen

* Keep max-width: 100vw for images

* Fix missing dependency

* fix merge resolve bug

* better css

* refactor, keypress/swipe events, remove scoll

* changes after self-review

* give previews their own carousel

* hooks for arrow keys and swiping

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-09-26 17:37:13 -05:00
k00b
5371e1abf8 fix bounty paid action notification 2024-09-26 09:53:25 -05:00
k00b
0d4c8cdc23 no optimistic item repetition for anon 2024-09-26 09:44:14 -05:00
k00b
b3c8d32d5a remove debug log comment 2024-09-25 18:04:03 -05:00
k00b
3f49c93ecb update item repetition optimistically 2024-09-25 18:02:40 -05:00
k00b
68f7e4111b prevent shift on pending -> paid 2024-09-25 17:38:37 -05:00
Keyan
9f06fd65ee
UX latency enhancements for paid actions (#1434)
* prevent multiple retries & pulse retry button

* fix lint

* don't wait for settlement on pessimistic zaps

* optimistic act modal
2024-09-25 13:32:52 -05:00
Keyan
450c969dfc
allow edit of pending items (#1431) 2024-09-24 15:42:32 -05:00
ekzyis
1641b58e55
CSS animation for toasts and offcanvas (#1432)
* CSS animation for toasts

* Smaller toasts

* CSS animation for offcanvas

* faster animations and toast from the bottom

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-09-24 13:43:15 -05:00
k00b
d7667f7820 add boost index 2024-09-24 13:10:10 -05:00
Keyan
76218dccac
batch zap requests (#1424) 2024-09-24 09:38:48 -05:00
ekzyis
b82641d1bd
Include extension in S3 key (#1426) 2024-09-24 08:10:43 -05:00
ekzyis
18fbd17025
CTRL+U for uploads (#1423) 2024-09-23 20:08:37 -05:00
k00b
2b5a1cbfe9 more reply placeholders 2024-09-22 12:05:03 -05:00
k00b
b6dcee4f26 add booosts to newsletter script 2024-09-22 12:05:03 -05:00
ekzyis
d30dace266
Fix missing dependency for useZap (#1420)
This didn't cause any bugs because useWallet returns everything we need on first render.

This caused a bug with E2EE device sync branch though since there the wallet is loaded async.

This meant that during payment, the wallet config was undefined.
2024-09-22 11:03:38 -05:00
k00b
894d02a196 allow top sorting by boost 2024-09-21 14:58:25 -05:00
k00b
83458fdc9e add amas to newsletter script 2024-09-21 14:44:17 -05:00
k00b
101574b605 fix territory revenue notification 2024-09-20 11:07:15 -05:00
k00b
59d5fd60f2 wider top boost on rewards page 2024-09-20 10:46:07 -05:00
k00b
f90a9905ba unzapped bolt shouldn't glow 2024-09-20 10:41:46 -05:00
k00b
8447a4a8b2 boost icon refinement 2024-09-20 10:15:44 -05:00
k00b
4981d572bb show boost for when forwardee 2024-09-20 09:58:53 -05:00
ekzyis
4e7b4ee571
Fix upvote hover style not showing for first zap (#1418) 2024-09-20 09:44:15 -05:00
k00b
8c9d4aa59b fix auction based ranking 2024-09-19 17:07:36 -05:00
k00b
ad9a65ce78 fix expire boost unit 2024-09-19 16:10:04 -05:00
ekzyis
b4e143460b
Fix nwc error message (#1417)
* Fix this.error undefined on relay error

* Also use arrow function for ws.onmessage
2024-09-19 15:53:42 -05:00
k00b
731df5fc67 better err message ek suggestion 2024-09-19 15:32:18 -05:00
k00b
2f191e04f9 ek boost hint suggestions 2024-09-19 15:27:09 -05:00
k00b
09be42844e boosted items aren't freebies 2024-09-19 15:18:07 -05:00
Keyan
3310925155
increase founders fee to 70% and zap sybil fee to 30% (#1388)
* increase founders fee to 70% and zap sybil fee to 30%

* more sybil fee changes
2024-09-19 15:15:56 -05:00
k00b
020b4c5eea allow comment updates when they have boost 2024-09-19 14:06:34 -05:00
k00b
e323ed27c6 fix boost hint for comments 2024-09-19 13:42:59 -05:00
k00b
d17929f2c5 ss validate boost acts 2024-09-19 13:38:13 -05:00
Keyan
5f0494de30
rethinking boost (#1408)
* reuse boost for jobs

* wip

* allow job stopping

* restore upvote.js

* expire boost

* boost beyond edit window

* fix boost bolt styling

* rank comments with boost

* no random sort for jobs

* top boost for month at top of territory

* boost hints

* more boost help

* squash migrations

* for same boost, prioritize older

* show ad only if active

* fix itemCreate/Update boost expiration jobs

* fix fee button precedence
2024-09-19 13:13:14 -05:00
Keyan
beba2f4794
Update awards.csv 2024-09-18 15:45:52 -05:00
ekzyis
dcbe83f155
Add wss:// to user relays by default (#1412) 2024-09-18 15:42:48 -05:00
ekzyis
5088673b84
Fix margin on row overflow (#1411) 2024-09-18 11:18:08 -05:00
k00b
ae579cbec3 remove mod outlaws from hot 2024-09-16 13:30:40 -05:00
Keyan
a5af2538c6
Update awards.csv 2024-09-16 12:11:15 -05:00
k00b
d46ae03598 attempt to fix img shift 2024-09-16 12:00:15 -05:00
k00b
e63609a7c1 don't show deleted items in main sorts 2024-09-16 11:57:16 -05:00
k00b
404cb0aaa7 fixes for toplevel media embeds 2024-09-16 11:54:22 -05:00
k00b
d9213da268 fix overflowed image 2024-09-14 19:25:25 -05:00
k00b
12d2e99576 fix inline image shrinking 2024-09-14 18:22:11 -05:00
k00b
88eea9ca94 newsletter script: search for bounties on @sn account 2024-09-14 13:44:57 -05:00
k00b
df65104c60 fix text/media component styling 2024-09-13 20:40:40 -05:00
k00b
2018cc1f0b fix image height in certain contexts 2024-09-13 19:14:49 -05:00
ekzyis
7c68eafa56
Fix hat contrast in profile in dark mode (#1406) 2024-09-13 19:05:16 -05:00
ekzyis
85a8fc5dcd
Fix different slugs generated in ToC vs text (#1405) 2024-09-13 15:54:30 -05:00
ekzyis
be7ea41d03
Always set Secure for multi auth cookies in prod (#1404) 2024-09-13 13:00:16 -05:00
ekzyis
a32d1f2177
Use X-Forwarded-Proto to detect scheme (#1403) 2024-09-13 12:27:52 -05:00
ekzyis
c8975038bd
Never update author of item on edit (#1401)
* Never update author of item on edit

* Only show option to edit via hmac if anonymous

* Only send hash+hmac if anonymous
2024-09-13 11:19:54 -05:00
ekzyis
30d5eb9801
Catch s3 upload errors (#1400)
* Catch s3 upload errors

* Include file name in error message

* More renaming from image to file
2024-09-13 10:41:07 -05:00
k00b
e09e1398fd increase upload max and update error messages 2024-09-13 10:13:34 -05:00
ekzyis
8a4e67e9f0
Anon edits (#1393)
* Rename vars around edit permission

* Allow anon edits with hash+hmac

* Fix missing time zone for invoice.confirmedAt of comments

* Fix missing invoice update on item update

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-09-13 10:11:19 -05:00
ekzyis
4340a82a62
Allow video uploads (#1399)
* Allow video uploads

* fix video preview

---------

Co-authored-by: k00b <k00b@stacker.news>
2024-09-13 09:26:08 -05:00
k00b
b3d9eb0eba fix media grid with newlines in same p tag 2024-09-12 16:44:05 -05:00
ekzyis
24aacd8839
Fix recv wallet deleted on logout (#1398)
* Fix recv wallet deleted on logout

* Fix wallet logs on server deleted on logout

* Remove unused option in deleteLogs to override wallet
2024-09-12 13:06:13 -05:00
ekzyis
a6713f9793
Account Switching (#644)
* WIP: Account switching

* Fix empty USER query

ANON_USER_ID was undefined and thus the query for @anon had no variables.

* Apply multiAuthMiddleware in /api/graphql

* Fix 'you must be logged in' query error on switch to anon

* Add smart 'switch account' button

"smart" means that it only shows if there are accounts to which one can switch

* Fix multiAuth not set in backend

* Comment fixes, minor changes

* Use fw-bold instead of 'selected'

* Close dropdown and offcanvas

Inside a dropdown, we can rely on autoClose but need to wrap the buttons with <Dropdown.Item> for that to work.

For the offcanvas, we need to pass down handleClose.

* Use button to add account

* Some pages require hard reload on account switch

* Reinit settings form on account switch

* Also don't refetch WalletHistory

* Formatting

* Use width: fit-content for standalone SignUpButton

* Remove unused className

* Use fw-bold and text-underline on selected

* Fix inconsistent padding of login buttons

* Fix duplicate redirect from /settings on anon switch

* Never throw during refetch

* Throw errors which extend GraphQLError

* Only use meAnonSats if logged out

* Use reactive variable for meAnonSats

The previous commit broke the UI update after anon zaps because we actually updated item.meSats in the cache and not item.meAnonSats.

Updating item.meAnonSats was not possible because it's a local field. For that, one needs to use reactive variables.

We do this now and thus also don't need the useEffect hack in item-info.js anymore.

* Switch to new user

* Fix missing cleanup during logout

If we logged in but never switched to any other account, the 'multi_auth.user-id' cookie was not set.

This meant that during logout, the other 'multi_auth.*' cookies were not deleted.

This broke the account switch modal.

This is fixed by setting the 'multi_auth.user-id' cookie on login.

Additionally, we now cleanup if cookie pointer OR session is set (instead of only if both are set).

* Fix comments in middleware

* Remove unnecessary effect dependencies

setState is stable and thus only noise in effect dependencies

* Show but disable unavailable auth methods

* make signup button consistent with others

* Always reload page on switch

* refine account switch styling

* logout barrier

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-09-12 13:05:11 -05:00
ekzyis
36e9f3f16f
useWallet hook cleanup (#1396)
* Assign everything to wallet object

* Add canReceive for sake of completeness
2024-09-12 11:09:44 -05:00
Keyan
fe0678b4cb
css to put adjacent images/video into vertical alignment (#1387)
* css to put adjacent images/video into vertical alignment

* fix grids chrome

* even grid gap with safari support

* fixes for video and horizontal scroll
2024-09-11 20:10:52 -05:00
ekzyis
0e0fe1af69
Fix duplicate push notification as reply and subscription (#1392) 2024-09-11 16:28:55 -05:00
ekzyis
0bf9fb0780
Fix layout shift between setting tabs (#1390) 2024-09-11 11:14:56 -05:00
k00b
da2fabc95c fix embed styling 2024-09-11 11:10:21 -05:00
k00b
855afcb1de embed spotify podcasts 2024-09-10 15:11:46 -05:00
ekzyis
821ac60de5
Throw errors which extend GraphQLError (#1386) 2024-09-10 11:35:25 -05:00
ekzyis
ec5241ad29
Enable WebLN wallet on 'webln:enabled' (#1385)
* Enable WebLN wallet on 'webln:enabled'

* Optimistically use WebLN for login with lightning

* Don't scope WebLN config to user

* Rename var to wallet
2024-09-10 11:13:39 -05:00
Keyan
f0e49c160a
automate meme monday, fact friday, what work wednesday (#1384) 2024-09-10 10:43:41 -05:00
ekzyis
8c56904094
Fix missing user invoice timeout (#1379)
* Wait max 10 seconds for user wallet to create invoice

* Add timeout in error message
2024-09-08 17:16:52 -05:00
ekzyis
2ad2cabb03
Remove overused fw-bold from infos (#1377) 2024-09-08 16:53:13 -05:00
ekzyis
1822b3fe42
Fix embeds (#1375)
* Fix wavlake embed

* Fix invalid DOM property

* Fix iframe message not received

* Fix spotify embed controller and popups

* Allow popups to escape sandbox
2024-09-08 10:32:35 -05:00
k00b
62556d2154 fix multiple nostr embed race and link clicks 2024-09-07 19:45:37 -05:00
ekzyis
597d1087f6
Fix jest module resolution (#1372) 2024-09-07 12:45:17 -05:00
Keyan
15b038cd78
refactor embeds to be reused (#1368)
* refactor embeds to be reused

* adjust the meaning of settings for embeds

* add wavlake embed (close #1359)

* add spotify embed (closes #1360)

* fix 'format' appearing in srcSet

* add nostr embed

* refine nostr embed

* Update components/media-or-link.js

Co-authored-by: ekzyis <ek@stacker.news>

* Update pages/settings/index.js

Co-authored-by: ekzyis <ek@stacker.news>

* ek suggestions

---------

Co-authored-by: ekzyis <ek@stacker.news>
2024-09-07 12:07:10 -05:00
ekzyis
f05b29717a
Fix grammar in autodelete invoices info (#1371) 2024-09-07 10:01:27 -05:00
ekzyis
54f8a61483
Random CSS fixes (#1370)
* Fix missing margin-left for invoice status in /satistics

* Fix margin-bottom not applied in invoice info

* Only apply margin-left if there is something left
2024-09-07 10:01:00 -05:00
k00b
6b27f54502 fix improxy container 2024-09-06 10:04:03 -05:00
Keyan
2f546facb2
get dimensions for video and refactor images (#1366)
* get dimensions for video and refactor images

* improve rendering performance

* more rendering perf enhancements
2024-09-06 09:34:44 -05:00
ekzyis
3f0499b96e
Fix ephemeral events missed (#1367)
* Fix ephemeral events missed

The spec mentions the following:

> for kind n such that 20000 <= n < 30000, events are ephemeral, which means they are not expected to be stored by relays.

This applies to NWC events. This means that we need to subscribe _before_ we publish the request.

See https://github.com/nostr-protocol/nips/blob/master/01.md

* Verify events before accepting them
2024-09-06 08:20:49 -05:00
k00b
fe717e0169 fix image/video clicks in notifications 2024-09-04 13:36:56 -05:00
k00b
1cc897a7a3 don't enforce min-width on videos 2024-09-04 11:00:54 -05:00
k00b
5a00f7b825 allow video in CSP 2024-09-04 09:58:05 -05:00
Keyan
07b98c3253
Optout of display of images and video (show them as links) (#1358)
* optout of display of images/video

* fix disableFreebies warning in settings

* preview trusted images

Co-authored-by: ekzyis <ek@stacker.news>

---------

Co-authored-by: ekzyis <ek@stacker.news>
2024-09-04 09:23:06 -05:00
Keyan
6f68a700ce
recognize video links (#1357) 2024-09-03 18:35:14 -05:00
Keyan
adcf048f4e
improve dupes (#1356) 2024-09-03 14:29:45 -05:00
ekzyis
d9024ff837
Reinitialize wallet form if initial values change + fix readOnly hydration error (#1354)
* Reinitialize wallet form if initial values change

This fixes that enabled is not set on first render if only recv is configured

* Remove unnecessary old usage of ClientCheckbox

This isn't needed even without enableReinitialize since for send, enabled is correctly set on first render.

It was needed in the past when we were still validating wallets before enabling them on first page load but now, we simply load the configuration from localStorage which is immediately available on the client.

* Fix readOnly hydration error

* Replace repetitive isMounted logic with useIsClient hook
2024-09-03 09:15:04 -05:00
k00b
69916117b1 refine popover close timing 2024-09-02 18:25:02 -05:00
k00b
67799a508a image loading fixes (fixes #1345) 2024-09-02 18:15:21 -05:00
ekzyis
7428738b23
Update wallets/README.md (#1353)
* Remove warning about send+recv not tested

* Add file comment

* Fix createInvoice description
2024-09-02 17:15:46 -05:00
ekzyis
a7066a34cd
Use default-src 'self' a.stacker.news (#1349)
This should fix CSP errors in Firefox because scripts fetched via <link rel="prefetch"> don't use script-src.
2024-09-02 12:58:14 -05:00
ekzyis
07ebc60bc3
Use undefined instead of empty function for onHide (#1348) 2024-09-02 12:57:16 -05:00
ekzyis
5e77106297
Undelete bio items (#1346) 2024-09-02 12:02:47 -05:00
ekzyis
c43a171794
Fix onHide is not a function (#1347) 2024-09-02 09:33:17 -05:00
k00b
f42344497e update newsletter script 2024-08-31 14:04:49 -05:00
k00b
6dedda577b only queue autowithdraw if a wallet is enabled 2024-08-29 16:13:16 -05:00
k00b
b6e4f97668 fix env loading in worker 2024-08-28 09:38:41 -05:00
ekzyis
17da24ce24
Add a.stacker.news to script-src (#1339) 2024-08-28 09:33:26 -05:00
ekzyis
ae8cadd4be
Switch NWC from Damus to Primal relay (#1340) 2024-08-28 09:32:29 -05:00
ekzyis
2503a3cb6a
Update wallet security banner (#1338)
* fix condition
* update text
2024-08-27 17:16:41 -05:00
Keyan
3af43d74d3
Update awards.csv 2024-08-27 11:22:34 -05:00
ekzyis
4cec369005
Support Tor for LNbits recv (#1336)
* Add tor support to LNbits recv

* Only return agent
2024-08-27 11:16:02 -05:00
ekzyis
d09f7c5427
Fix websocket leaks (#1334) 2024-08-27 11:15:00 -05:00
ekzyis
ec6124ca62
NWC hardcoded keys (#1335)
* Include keys.json in NWC container image

* Update NWC ATTACH.md
2024-08-27 11:14:35 -05:00
ekzyis
9f194c5d8e
Fix preimage undefined in wallet logs (#1337)
* Fix preimage undefined in NWC wallet logs

* Return preimage as string
2024-08-27 11:13:52 -05:00
Keyan
266e9a892d
Improve freebies (#1333)
* remove free posts

* deleted and freebie comments are always last
2024-08-26 19:23:07 -05:00
ekzyis
cc003a9a3e
Phoenixd send+recv (#1322)
* Add genwallet script

* Add phoenixd as send+recv wallet

* phoenixd passwords are 64 hex chars
2024-08-26 18:20:45 -05:00
k00b
5cfefc1ca8 cancelled failed payment if not showing qr 2024-08-26 13:58:37 -05:00
k00b
5ae3084e53 remove defunct chats from footer 2024-08-26 12:59:39 -05:00
ekzyis
48d0cd1086
Fix full config saved on client on priority change (#1329)
* Fix full config saved on client on priority change

* Fix WebLN disabled on priority change

* Always merge configs
2024-08-25 18:40:55 -05:00
ekzyis
203db13553
Fix cost not shown in comment details (#1330) 2024-08-25 18:40:02 -05:00
k00b
467a9d6a76 fix lnc by always reusing the same lnc object 2024-08-24 18:56:15 -05:00
ekzyis
c0de29cb82
Rename NWC primary key column from 'int' to 'id' (#1328) 2024-08-23 10:11:13 -05:00
ekzyis
66cf97e832
Skip wallet tests on priority update (#1327)
* Skip wallet connection tests if only priority is changed

* Fix server priority overrides client priority

* Also add priorityOnly as last argument in generateMutation
2024-08-22 20:08:02 -05:00
ekzyis
382f16643d
Ignore no rows found in disableFreebies (#1326) 2024-08-22 18:09:22 -05:00
ekzyis
82788b35bf
Show item details via context menu (#1325)
* Show item details via context

* Use zappers instead of upvotes

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-08-21 20:35:13 -05:00
ekzyis
896265a4a8
Update QA question in PR template (#1324) 2024-08-21 19:30:06 -05:00
k00b
c4a96af5d3 fix crossposting toast 2024-08-21 14:59:28 -05:00
Keyan
df62cfb28c
paid action limits (#1323) 2024-08-21 14:45:51 -05:00
ekzyis
67d71ef0c8
Rename LNbits primary key column from 'int' to 'id' (#1321)
* Rename LNbits primary key column from 'int' to 'id'

* fix migration

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-08-21 10:49:48 -05:00
ekzyis
789d7626f7
Support receiving with NWC (#1310)
* Add NWC receives

* Refactor sendPayment+createInvoice with nwcCall function

* Update badge

* Add method support checks

* Add timeout to NWC test invoice

* Fix NWC isConfigured state

All NWC fields are marked as optional but NWC should only be considered configured if one of them is set.

* Fix relay.fetch() throws 'crypto is not defined' in node

nip04.encrypt() was failing in worker because 'crypto is not defined'. Updating to nostr-tools v2.7.2 fixed that.

However, now crypto.randomUUID() in relay.fetch() was throwing 'crypto is not defined'. Importing crypto from 'crypto' fixed that.

However, with the import, randomUUID() does not work so I switched to randomBytes().

Running relay.fetch() now works in browser and node.

* recv must not support pay_invoice

* Fix Relay connection check

* this.url was undefined
* error was an object

* Fix additional isConfigured check runs always

It was meant to only catch false positives, not turn negatives into false positives.

* Rename testConnectServer to testCreateInvoice

* Rename testConnectClient to testSendPayment

* Only run testSendPayment if send is configured

The return value of testSendPayment was used before but it only returned something for LNC.

And for LNC, we only wanted to save the transformation during validation, so it was not needed.

* Always use withTimeout in NWC test functions

* Fix fragment name

* Use get_info command exclusively

* Check permissions more efficiently

* Log NWC request-response flow

* Fix variable name

* Call ws.send after listener is added

* Fix websocket not closed after timeout

* Also check that pay_keysend etc. are not supported

* fix lnc session key save

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-08-21 10:13:27 -05:00
Keyan
bc94ec7d28
disable freebies setting (#1320)
* disable freebies setting

* await in disableFreebie resolver + better info modal
2024-08-21 09:37:25 -05:00
k00b
555601c7de no bios in random 2024-08-20 21:12:39 -05:00
k00b
85e91ede47 show only active items with comments on random 2024-08-20 21:09:52 -05:00
k00b
accf5212b2 fix ~jobs 2024-08-20 20:50:04 -05:00
Keyan
eca7e8df0d
Update awards.csv 2024-08-20 17:06:45 -05:00
Tom
4fd23a3694
Add Random link and basic query (#1306)
* Add Random link and basic query

* Use random

* refine random sort query

* make vote threshold higher

---------

Co-authored-by: k00b <k00b@stacker.news>
2024-08-20 16:50:55 -05:00
ekzyis
17199e8f91
Fix missing fragment name update (#1319) 2024-08-19 17:41:52 -05:00
ekzyis
6801b775c1
Fix inconsistency between walletTypeToResolveType and generateTypeDefName (#1318) 2024-08-19 17:36:07 -05:00
k00b
a495c421ce select first status=null on withdrawal check 2024-08-19 10:10:34 -05:00
k00b
2ff839f3a5 check invoice after transitioning to cancel 2024-08-18 18:03:01 -05:00
Keyan
3264601dc6
add priority:low to readme 2024-08-18 17:30:25 -05:00
ekzyis
06b661625c
Use custom relay API (#1302)
* Use custom relay API

Relay from nostr-tools was cumbersome to use. This custom abstraction over window.WebSocket makes interacting with nostr relays easier.

* Use variables for nostr message parts

* Fix NWC save

* Use try/finally

* Refactor crossposting code

* use custom replay API
* simplify callWithTimeout

* Use isomorphic-ws for nip57 zap receipts

* Use async map

* Reject with timeout error

* Move time functions into lib/time

* Remove outdated comment regarding relay.close()
2024-08-18 17:28:39 -05:00
ekzyis
ccbc28322e
Add wasm-unsafe-eval to CSP for LNC (#1313) 2024-08-18 17:20:46 -05:00
k00b
df7baf4d7c don't filter freebies/outlaws on profiles + fix ~sub/recent/all 2024-08-18 16:43:19 -05:00
k00b
3608d133d7 remove redundant info help text from wallets 2024-08-18 15:20:31 -05:00
k00b
506bb364d1 fix walletTypeToResolveType 2024-08-18 14:57:50 -05:00
k00b
9932a782b2 improve autowithdraw recent failure check 2024-08-18 14:06:17 -05:00
Keyan
11ddbe3983
Update awards.csv 2024-08-18 11:45:39 -05:00
Riccardo Balbo
2d139bed85
Blink wallet sending attachment (#1293)
* blink attachment

* support staging

* add staging dashboard link

* Revert "add staging dashboard link"

This reverts commit a43fa2204f03d74e733063aedd6862c6d71e4a46.

* Revert "support staging"

This reverts commit 93c15aa5083e60b1dafc77c30e999fb90fef8589.

* handle pending payments, code cleanup and comments

* stable sats -> stablesats

* catch HTTP errors

* print wallet currency in debug

* disable autocomplete

* schema without test()

* Fix save since default is not applied for empty strings

Formik validation must see 'currency' as undefined and apply the default but the validation before save sees an empty string.

* Save transformed config

* Remove unnecessary defaults

* Prefix HTTP error with text

---------

Co-authored-by: ekzyis <ek@stacker.news>
2024-08-18 11:36:55 -05:00
ekzyis
3d8ae4a7a3
Generate more server wallet code (#1309)
* Generate more code from wallet defs

* generate "type WalletLND { ... }"
* generate "union WalletDetails = WalletLND | ..."
* hardcode function for __resolveType
* add comments where updates are needed if another server wallet is added

* Fix type for LN addresses

* Generate __resolveType from wallet.type column
2024-08-18 11:32:25 -05:00
k00b
d2d04ce141 lnbits doesn't support msats either 2024-08-17 18:43:39 -05:00
Keyan
ffc156df2b
Msats to sats floor (#1307)
* make wallet invoice creation tests make full sat invoice

* handle rounded/floored msats for receiving wallets

* msats flooring to sats function
2024-08-16 19:33:17 -05:00
ekzyis
ab80873a57
Fix all wallets deleted on logout (#1308)
Every wallet returned by the useWallet hook has sendPayment set even if it doesn't support payments since wallet.sendPayment is wrapped with a useCallback hook.
2024-08-16 15:50:11 -05:00
ekzyis
3cd9991aa9
Hide wallet name in logs for only a single wallet (#1305) 2024-08-16 09:02:09 -05:00
k00b
ec2383b998 bug is only in wildwestmode 2024-08-14 15:43:07 -05:00
k00b
5a65eaae18 try to fix satsFilter clause 2024-08-14 15:36:21 -05:00
ekzyis
8e18fa0760
Remove TODO about errors in createInvoice (#1300) 2024-08-14 14:56:02 -05:00
ekzyis
4000522773
Fix WebLN checkbox unclickable (#1299)
Checkbox was unclickable because wallet.isConfigured is false if wallet.config was null.
2024-08-14 14:29:24 -05:00
k00b
ddb32e0bb7 fix null initial values for wallet forms 2024-08-14 13:22:17 -05:00
k00b
11ac605cd9 fix clicking on p2p in satistics 2024-08-14 12:59:27 -05:00
k00b
71b639be5d reduce p2p max 2024-08-14 12:11:08 -05:00
ekzyis
56d55027ab
Fix freebie comment edits like a post (#1298) 2024-08-14 09:31:52 -05:00
k00b
cb2efb0a7f fix sats filter for notifications 2024-08-13 16:27:22 -05:00
k00b
4d7e0a8296 fix settings mutation after greeterMode removal 2024-08-13 16:11:54 -05:00
k00b
cfd63f4efb fix relative path env in sndev 2024-08-13 15:48:22 -05:00
k00b
ac87322ac8 fix user/item popover 2024-08-13 15:30:43 -05:00
ekzyis
53465e3f46
Remove unnecessary me from addWalletLog params (#1296) 2024-08-13 09:53:44 -05:00
Keyan
cc289089cf
not-custodial zap beta (#1178)
* not-custodial zap scaffolding

* invoice forward state machine

* small refinements to state machine

* make wrap invoice work

* get state machine working end to end

* untested logic layout for paidAction invoice wraps

* perform pessimisitic actions before outgoing payment

* working end to end

* remove unneeded params from wallets/server/createInvoice

* fix cltv relative/absolute confusion + cancelling forwards

* small refinements

* add p2p wrap info to paidAction docs

* fallback to SN invoice when wrap fails

* fix paidAction retry description

* consistent naming scheme for state machine

* refinements

* have sn pay bounded outbound fee

* remove debug logging

* reenable lnc permissions checks

* don't p2p zap on item forward splits

* make createInvoice params json encodeable

* direct -> p2p badge on notifications

* allow no tls in dev for core lightning

* fix autowithdraw to create invoice with msats

* fix autowithdraw msats/sats inconsitency

* label p2p zaps properly in satistics

* add fees to autowithdrawal notifications

* add RETRYING as terminal paid action state

* Update api/paidAction/README.md

Co-authored-by: ekzyis <ek@stacker.news>

* Update api/paidAction/README.md

Co-authored-by: ekzyis <ek@stacker.news>

* Update api/lnd/index.js

Co-authored-by: ekzyis <ek@stacker.news>

* ek suggestions

* add bugetable to nwc card

* get paranoid with numbers

* better finalize retries and better max timeout height

* refine forward failure transitions

* more accurate satistics p2p status

* make sure paidaction cancel in state machine only

* dont drop bolt11s unless status is not null

* only allow PENDING_HELD to transition to FORWARDING

* add mermaid state machine diagrams to paid action doc

* fix cancel transition name

* cleanup readme

* move forwarding outside of transition

* refine testServerConnect and make sure ensureB64 transforms

* remove unused params from testServerConnect

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: k00b <k00b@stacker.news>
2024-08-13 09:48:30 -05:00
ekzyis
454ad26bd7
Add migration to fix missing item bio marker (#1295) 2024-08-12 19:19:18 -05:00
k00b
ce7d2b888d add back greeterMode for backwards compat 2024-08-12 17:49:01 -05:00
ekzyis
ae73b0c19f
Support receiving via LNbits (#1278)
* Support receiving with LNbits

* Remove hardcoded LNbits url on server

* Fix saveConfig ignoring save errors

* saveConfig was meant to only ignore validation errors, not save errors
* on server save errors, we redirected as if save was successful
* this is now fixed with a promise chain
* logging payments vs receivals was also moved to correct place

* Fix enabled falsely disabled on SSR

If a wallet was configured for payments but not for receivals and you refreshed the configuration form, enabled was disabled even though payments were enabled.

This was the case since we don't know during SSR if it's enabled since this information is stored on the client.

* Fix missing 'receivals disabled' log message

* Move 'wallet detached for payments' log message

* Fix stale walletId during detach

If page was reloaded, walletId in clearConfig was stale since callback dependency was missing.

* Add missing callback dependencies for saveConfig

* Verify that invoiceKey != adminKey

* Verify LNbits keys are hex-encoded

* Fix local config polluted with server data

* Fix creation of duplicate wallets

* Remove unused dependency

* Fix missing error message in logs

* Fix setPriority

* Rename: localConfig -> clientConfig

* Add description to LNbits autowithdrawals

* Rename: receivals -> receives

* Use try/catch instead of promise chain in saveConfig

* add connect label to lnbits for no url found for lnbits

* Fix adminKey not saved

* Remove hardcoded LNbits url on server again

* Add LNbits ATTACH.md

* Delete old docs to attach LNbits with polar

* Add missing callback dependencies

* Set editable: false

* Only set readOnly if field is configured

---------

Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-08-12 17:23:39 -05:00
Keyan
68758b3443
Update awards.csv 2024-08-12 16:58:49 -05:00
Keyan
c5f043c625
replace greeter mode with investment filter (#1291)
* replace greeter mode with investment filter

* change name to satsFilter

* drop freebie column

---------

Co-authored-by: k00b <k00b@stacker.news>
2024-08-11 18:47:03 -05:00
Keyan
e897a2d1dc
Update awards.csv 2024-08-11 16:31:44 -05:00
Anis Khalfallah
ed6ef2f82f
fix: constrain less important services in docker compose (#1289)
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-08-11 16:29:46 -05:00
ekzyis
bcae5e6d2e
Fix callback set to NextJS data URL (#1292) 2024-08-10 14:38:35 -05:00
Keyan
ef229b378e
Update awards.csv 2024-08-10 11:17:22 -05:00
ekzyis
3863edb871
Remove commented out webpack.IgnorePlugin code (#1290) 2024-08-08 16:16:11 -05:00
ekzyis
f7a170fff0
Crosspost errors are warnings now (#1285) 2024-08-05 18:05:09 -05:00
ekzyis
288fa37197
Only validate tipRandom if enabled (#1284)
* Only validate tipRandom if enabled

* Use consistent naming scheme for zap settings

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-08-03 19:51:15 -05:00
keyan
5d0e071939 better random zaps 2024-08-03 19:37:43 -05:00
ekzyis
0c3337fb97
More zap undo fixes IV (#1282)
* Add margin-top to zap undo input

* Update zap undo hint

* Fix zap undo pulse not reflecting zap amount
2024-08-02 17:41:47 -05:00
ekzyis
9c66a5aafc
Fix duplicate 'Wallet' in resolver name (#1281) 2024-08-01 16:06:58 -05:00
Keyan
8000886e72
dont expect unrun services in dev (#1279) 2024-07-31 19:44:08 -05:00
keyan
4b391dd6ee add separators to all unabbreviated numbers 2024-07-31 19:02:33 -05:00
Keyan
e6f6895ce0
Update awards.csv 2024-07-31 12:22:30 -05:00
Keyan
837767a323
Update awards.csv 2024-07-31 12:18:26 -05:00
keyan
7901f2fe61 fix anon zaps (no tip settings) 2024-07-30 18:09:46 -05:00
SatsAllDay
dc0370ba17
random zap amounts (#1263)
* add random zapping support

adds an option to enable random zap amounts per stacker

configurable in settings, you can enable this feature and provide
an upper and lower range of your random zap amount

* rename github eslint check to lint

this has been bothering me since we aren't using eslint for linting

* fixup! add random zapping support

* fixup! rename github eslint check to lint

* fixup! fixup! add random zapping support

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-07-26 22:37:03 -05:00
keyan
4964e2c7d1 order all zap recipients 2024-07-26 12:25:48 -05:00
keyan
3d1f7834ca fix potential zap deadlock 2024-07-25 20:46:17 -05:00
ekzyis
628a0466fd
Use validation for WebLN wallet (#1277)
* remove available prop
* 'enabled' checkbox is now always enabled but uses validation
* CheckboxGroup was missing to show error message
2024-07-24 10:08:09 -05:00
ekzyis
d3ca87a78b
Add WebLN for sending payments (#1274)
* Add WebLN for sending payments

* attach docs for alby

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-07-23 13:23:48 -05:00
Keyan
c20a954cfc
This day on ... automated post (#1273)
* add this day posting job

* put in proper timezone

* make sure we're in central timezone

* schedule thisDay job
2024-07-23 10:35:15 -05:00
ekzyis
7fd4f58e81
Fix subscription ignores OP replies (#1272) 2024-07-22 16:14:48 -05:00
ekzyis
ea8ad5c4c9
Fix notification indicator shown for own replies (#1271) 2024-07-22 16:13:07 -05:00
keyan
de38a683d5 remove saloon from top posts 2024-07-21 13:57:18 -05:00
ekzyis
e517abb061
Fix+improve wallet README (#1269) 2024-07-21 13:26:33 -05:00
h0dlr
1ef88664d4
Update awards.csv (#1267) 2024-07-21 11:47:34 -05:00
Anis Khalfallah
887354820b
fix: modified docker link, added free disk space requirement (#1265) 2024-07-21 11:47:12 -05:00
Anis Khalfallah
f051afff35
feat: added NGN currency (#1264) 2024-07-21 11:46:52 -05:00
ekzyis
371e7417ce
Wallet definitions with uniform interface (#1243)
* wip: Use uniform interface for wallets

* Fix import error

* Update wallet logging + other stuff

* add canPay and canSend to wallet definition
* rename 'default payment method' to 'enabled' and add enable + disable method

* Set canPay, canReceive in useWallet

* Enable wallet if just configured

* Don't pass logger to sendPayment

* Add logging to attach & detach

* Add schema to wallet def

* Add NWC wallet

* Fix unused isDefault saved in config

* Fix enableWallet

* wrong storage key was used
* broke if wallets with no configs existed

* Run validation during save

* Use INFO level for 'wallet disabled' message

* Pass config with spread operator

* Support help, optional, hint in wallet fields

* wip: Add LNC

* Fix 20s page load for /settings/wallets.json?nodata=true

For some reason, if nodata is passed (which is the case if going back), the page takes 20s to load.

* Fix extremely slow page load for LNC import

I noticed that the combination of

```
import { Form, PasswordInput, SubmitButton } from '@/components/form'
```

in components/wallet/lnc.js and the dynamic import via `await import` in components/wallet/index.js caused extremely slow page loads.

* Use normal imports

* Revert "Fix 20s page load for /settings/wallets.json?nodata=true"

This reverts commit deb476b3a966569fefcfdf4082d6b64f90fbd0a2.

Not using the dynamic import for LNC fixed the slow page load with ?nodata=true.

* Remove follow and show recent logs first

* Fix position of log start marker

* Add FIXMEs for LNC

I can't get LNC to connect. It just hangs forever on lnc.connect(). See FIXMEs.

* Remove logger.error since already handled in useWallet

* Don't require destructuring to pass props to input

* wip: Add LND autowithdrawals

* receiving wallets need to export 'server' object field
* don't print macaroon error stack
* fix missing wallet logs order update
* mark autowithdrawl settings as required
* fix server wallet logs deletion
* remove canPay and canReceive since it was confusing where it is available

TODO

* also use numeric priority for sending wallets to be consistent with how status for receiving wallets is determined
* define createInvoice function in wallet definition
* consistent wallet logs: sending wallets use 'wallet attached'+'wallet enabled/disabled' whereas receiving wallets use 'wallet created/updated'
* see FIXMEs

* Fix TypeError

* Fix sendPayment called with empty config

* removed useEffect such that config is available on first render
* fix hydration error using dynamic import without SSR

* Fix confusing UX around enabled

* Remove FIXMEs

Rebase on master seemed to have fixed these, weird

* Use same error format in toast and wallet log

* Fix usage of conditional hooks in useConfig

* Fix isConfigured

* Fix delete wallet logs on server

* Fix wallet logs refetch

onError does not exist on client.mutate

* Fix TypeError in isConfigured if no enabled wallet found

* Only include local/server config if required

* Fix another hydration error

* Fix server config not updated after save or detach

* Also use 'enabled' for server wallets

* Fix wallet logs not updated after server delete

* Consistent logs between local and server wallets

* 'wallet attached' on create
* 'wallet updated' on config updates
* 'wallet enabled' and 'wallet disabled' if checkbox changed
* 'wallet detached' on delete

* Also enable server wallets on create

* Disable checkbox if not configured yet

* Move all validation schema into lib/validate

* Implement drag & drop w/o persistence

* Use dynamic import for WalletCard

This fixes a lot of issues with hydration

* Save order as priority

* Fix autowithdrawSettings not applied

Form requires config in flat format but mutation requires autowithdraw settings in a separate 'settings' field.

I have decided that config will be in flat form format. It will be transformed into mutation format during save.

* Save dedicated enabled flag for server wallets

* wallet table now contains boolean column 'enabled'
* 'priority' is now a number everywhere
* use consistent order between how autowithdrawals are attempted and server wallets cards

* Fix onCanceled missing

* Fix typo

* Fix noisy changes in lib/validate

I moved the schema for lnbits, nwc and lnc out of lib/validate only to put them back in there later.

This commit should make the changeset cleaner by removing noise.

* Split arguments into [value,] config, context

* Run lnbits url.replace in validate and sendPayment

* Remove unnecessary WALLETS_QUERY

* Generate wallet mutation from fields

* Generate wallet resolver from fields

* Fix import inconsistency between app and worker

* Use wallet.createInvoice for autowithdrawals

* Fix success autowithdrawal log

* Fix wallet security banner shown for server wallets

* Add autowithdrawal to lightning address

* Add optional wallet short name for logging

* Fix draggable

* Fix autowithdraw loop

* Add missing hints

* Add CLN autowithdrawal

* Detach wallets and delete logs on logout

* Remove Wallet in lib/constants

* Use inject function for resolvers and typeDefs

* Fix priority ignored when fetching enabled wallet

* Fix draggable false on first page load due to SSR

* Use touches instead of dnd on mobile

Browsers don't support drag events for touch devices.

To have a consistent implementation for desktop and mobile, we would need to use mousedown/touchstart, mouseup/touchend and mousemove/touchmove.

For now, this commit makes changing the order possible on touch devices with simple touches.

* Fix duplicate CLN error

* Fix autowithdraw priority order

* Fix error per invalid bip39 word

* Update LNC code

* remove LNC FIXMEs

Mhh, I guess the TURN server was down or something? It now magically works. Or maybe it only works once per mnemonic?

* also removed the lnc.lnd.lightning.getInfo() call since we don't ask and need permission for this RPC for payments.

* setting a password does not work though. It fails with 'The password provided is not valid' which is triggered at https://github.com/lightninglabs/lnc-web/blob/main/lib/util/credentialStore.ts#L81.

* Fix order if wallet with no priority exists

* Use common sort

* Add link to lnbits.com

* Add example wallet def

* Remove TODOs

TODO in components/wallet-logger.js was handled.
I don't see a need for the TODO in lib/wallet.js anymore. This function will only be called with the wallet of type LIGHTNING_ADDRESS anyway.

* Remove console.log

* Toast priority save errors

* Fix leaking relay connections

* Remove 'tor or clearnet' hint for LN addresses

* Remove React dependency from wallet definitions

* Generate resolver name from walletField

* Move wallets into top level directory wallet/

* Put wallets into own folder

* Fix generateMutation

* remove resolverName property from wallet defs
* move function into lib/wallet
* use function in generateMutation on client to fix wrongly generated mutation

* Separate client and server imports by files

* wallets now consist of an index.js, a client.js and a server.js file
* client.js is imported on the client and contains the client portion
* server.js is imported on the server and contains the server porition
* both reexport index.js so everything in index.js can be shared by client and server

* every wallet contains a client.js file since they are all imported on the client to show the cards

* client.js of every wallet is reexported as an array in wallets/client.js
* server.js of every wallet is reexported as an array in wallets/server.js

FIXME: for some reason, worker does not properly import the default export of wallets/server.js

* Fix worker import of wallets/server

* Fix wallet.server usage

* I removed wallet.server in a previous commit
* the client couldn't determine which wallet was stored on the server since all server specific fields were set in server.js
* walletType and walletField are now set in index.js
* walletType is now used to determine if a wallet is stored on the server

* also included some formatting changes

* Fix w.default usage

Since package.json with { "type": "module" } was added, this is no longer needed.

* Fix id access in walletPrioritySort

* Fix autowithdrawal error log

* Generate validation schema for LNbits

* Generate validation schema for NWC

* Rename to torAllowed

* Generate validation schema for LNC

* Generate validation schema for LND

* Generate validation schema for LnAddr

* Remove stringTypes

* Generate validation schema for CLN

* Make clear that message belongs to test

* validate.message was used in tandem with validate.test
* it might be confused as the message if the validation for validate.type failed
* now validate.test can be a function or an object of { test, message } shape which matches Yup.test

* Remove validate.schema as a trap door

* make lnc work

* Return null if no wallet was found

* Revert code around schema generation

* Transform autowithdrawSchemaMembers into an object

* Rename schema to yupSchema

* Fix missing required for LNbits adminKey

* Support formik form-level validation

* Fix missing addWalletLog import

* Fix missing space after =

* fix merge conflict resolution mistake

* remove non-custodial* badges

* create guides for attaching wallets in sndev

* Use built-in formik validation or Yup schema but not both

* Rename: validate -> testConnectClient, testConnect -> testConnectServer

* make lnaddr autowithdraw work in dev

* move ATTACH docs to ./wallets and add lnaddr doc

* Fix missing rename: yupSchema -> fieldValidation

* Remove unused context

* Add documentation how to add wallets

---------

Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-07-20 17:51:46 -05:00
keyan
cadfc47eb5 make sure all notifications have the same padding 2024-07-19 14:47:12 -05:00
keyan
c025c85855 fix referrals showing in meRewards 2024-07-19 12:38:49 -05:00
keyan
573d4d8452 fix #1266 2024-07-19 11:50:21 -05:00
504 changed files with 29058 additions and 11511 deletions

View File

@ -1,5 +1,7 @@
PRISMA_SLOW_LOGS_MS=
GRAPHQL_SLOW_LOGS_MS=
NODE_ENV=development
COMPOSE_PROFILES='minimal,images,search,payments,wallets,email,capture'
############################################################################
# OPTIONAL SECRETS #
@ -27,8 +29,8 @@ SLACK_BOT_TOKEN=
SLACK_CHANNEL_ID=
# lnurl ... you'll need a tunnel to localhost:3000 for these
LNAUTH_URL=
LNWITH_URL=
LNAUTH_URL=http://localhost:3000/api/lnauth
LNWITH_URL=http://localhost:3000/api/lnwith
########################################
# SNDEV STUFF WE PRESET #
@ -76,6 +78,7 @@ IMGPROXY_MAX_ANIMATION_FRAME_RESOLUTION=200
IMGPROXY_READ_TIMEOUT=10
IMGPROXY_WRITE_TIMEOUT=10
IMGPROXY_DOWNLOAD_TIMEOUT=9
IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1
# IMGPROXY_DEVELOPMENT_ERRORS_MODE=1
# IMGPROXY_ENABLE_DEBUG_HEADERS=true
@ -113,8 +116,6 @@ POSTGRES_DB=stackernews
# opensearch container stuff
OPENSEARCH_INITIAL_ADMIN_PASSWORD=mVchg1T5oA9wudUh
plugins.security.disabled=true
discovery.type=single-node
DISABLE_SECURITY_DASHBOARDS_PLUGIN=true
# bitcoind container stuff
@ -125,27 +126,42 @@ RPC_PORT=18443
P2P_PORT=18444
ZMQ_BLOCK_PORT=28334
ZMQ_TX_PORT=28335
ZMQ_HASHBLOCK_PORT=29000
# sn lnd container stuff
LND_REST_PORT=8080
LND_GRPC_PORT=10009
LND_P2P_PORT=9735
# sn_lnd container stuff
SN_LND_REST_PORT=8080
SN_LND_GRPC_PORT=10009
SN_LND_P2P_PORT=9735
# docker exec -u lnd sn_lnd lncli newaddress p2wkh --unused
LND_ADDR=bcrt1q7q06n5st4vqq3lssn0rtkrn2qqypghv9xg2xnl
LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490
SN_LND_ADDR=bcrt1q7q06n5st4vqq3lssn0rtkrn2qqypghv9xg2xnl
SN_LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490
# sn_lndk stuff
SN_LNDK_GRPC_PORT=10012
# stacker lnd container stuff
STACKER_LND_REST_PORT=8081
STACKER_LND_GRPC_PORT=10010
# docker exec -u lnd stacker_lnd lncli newaddress p2wkh --unused
STACKER_LND_ADDR=bcrt1qfqau4ug9e6rtrvxrgclg58e0r93wshucumm9vu
STACKER_LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35
# lnd container stuff
LND_REST_PORT=8081
LND_GRPC_PORT=10010
# docker exec -u lnd lnd lncli newaddress p2wkh --unused
LND_ADDR=bcrt1qfqau4ug9e6rtrvxrgclg58e0r93wshucumm9vu
LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35
# stacker cln container stuff
STACKER_CLN_REST_PORT=9092
# docker exec -u clightning stacker_cln lightning-cli newaddr bech32
STACKER_CLN_ADDR=bcrt1q02sqd74l4pxedy24fg0qtjz4y2jq7x4lxlgzrx
STACKER_CLN_PUBKEY=03ca7acec181dbf5e427c682c4261a46a0dd9ea5f35d97acb094e399f727835b90
# cln container stuff
CLN_REST_PORT=9092
# docker exec -u clightning cln lightning-cli newaddr bech32
CLN_ADDR=bcrt1q02sqd74l4pxedy24fg0qtjz4y2jq7x4lxlgzrx
CLN_PUBKEY=03ca7acec181dbf5e427c682c4261a46a0dd9ea5f35d97acb094e399f727835b90
# sndev cli eclair getnewaddress
# sndev cli eclair getinfo
ECLAIR_ADDR="bcrt1qdus2yml69wsax3unz8pts9h979lc3s4tw0tpf6"
ECLAIR_PUBKEY="02268c74cc07837041131474881f97d497706b89a29f939555da6d094b65bd5af0"
# router lnd container stuff
ROUTER_LND_REST_PORT=8082
ROUTER_LND_GRPC_PORT=10011
# docker exec -u lnd router_lnd lncli newaddress p2wkh --unused
ROUTER_LND_ADDR=bcrt1qfkmwfpwgn6wt0dd36s79x04swz8vleyafsdpdr
ROUTER_LND_PUBKEY=02750991fbf62e57631888bc469fae69c5e658bd1d245d8ab95ed883517caa33c3
LNCLI_NETWORK=regtest
@ -155,8 +171,16 @@ AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
PERSISTENCE=1
SKIP_SSL_CERT_DOWNLOAD=1
# tor
TOR_PROXY=http://127.0.0.1:7050/
# tor proxy
TOR_PROXY=http://tor:7050/
grpc_proxy=http://tor:7050/
# lnbits
LNBITS_WEB_PORT=5001
LNBITS_WEB_PORT=5001
# CPU shares for each category
CPU_SHARES_IMPORTANT=1024
CPU_SHARES_MODERATE=512
CPU_SHARES_LOW=256
NEXT_TELEMETRY_DISABLED=1

View File

@ -21,4 +21,5 @@ PRISMA_SLOW_LOGS_MS=50
GRAPHQL_SLOW_LOGS_MS=50
DB_APP_CONNECTION_LIMIT=4
DB_WORKER_CONNECTION_LIMIT=2
DB_TRANSACTION_TIMEOUT=10000
DB_TRANSACTION_TIMEOUT=10000
NEXT_TELEMETRY_DISABLED=1

View File

@ -1,35 +0,0 @@
---
name: Bug report
about: Report a problem
title: ''
labels: bug
assignees: ''
---
*Note: this template is meant to help you report the bug so that we can fix it faster, ie not all of these sections are required*
**Description**
A clear and concise description of what the bug is.
**Steps to Reproduce**
A clear and concise way we might be able to reproduce the bug.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs**
If applicable, add your browsers console logs.
**Environment:**
If you only experience the issue on certain devices or browsers, provide that info.
- Device: [e.g. iPhone6]
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

65
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@ -0,0 +1,65 @@
name: 🐞 Bug report
description: Create a bug report to help us fix it
title: "bug report"
labels: [bug]
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: textarea
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is. Include images if relevant.
placeholder: I accidentally deleted the internet. Here's my story ...
validations:
required: true
- type: textarea
attributes:
label: Screenshots
description: |
Add screenshots to help explain your problem. You can also add a video here.
Tip: You can attach images or video files by clicking this area to highlight it and then dragging files in.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the bug.
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll to '...'
4. See error
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen
validations:
required: true
- type: textarea
attributes:
label: Logs
description: If applicable, add your browser's console logs here
- type: textarea
attributes:
label: Device information
placeholder: |
- OS: [e.g. Windows]
- Browser: [e.g. chrome, safari, firefox]
- Browser Version: [e.g. 22]
validations:
required: false
- type: textarea
attributes:
label: Additional context
description: |
Do you have links to discussions about this on SN or other references?
validations:
required: false

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Questions
url: https://stacker.news/~meta
about: If you simply have a question, you can ask it in ~meta or the saloon.

View File

@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest a feature
title: ''
labels: feature
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -0,0 +1,32 @@
name: ✨ Feature request
description: Request a feature you'd like to see in SN!
title: "feature request"
labels: [feature]
body:
- type: markdown
attributes:
value: |
We're always looking for suggestions on how we could improve SN!
- type: textarea
attributes:
label: Describe the problem you're trying to solve
description: |
Is your feature request related to a problem? Add a clear and concise description of what the problem is.
validations:
required: true
- type: textarea
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
attributes:
label: Describe alternatives you've considered
description: |
A clear and concise description of any alternative solutions or features you have considered.
- type: textarea
attributes:
label: Additional context
description: |
Add any other additional context or screenshots about the feature request here.

View File

@ -1,45 +1,22 @@
## Description
<!--
A clear and concise description of what you changed and why.
Don't forget to mention which tickets this closes (if any).
Use following syntax to close them automatically on merge: closes #<number>
-->
_A clear and concise description of what you changed and why._
## Screenshots
<!--
If your changes are user facing, please add screenshots of the new UI.
You can also create a video to showcase your changes (useful to show UX).
-->
## Additional Context
<!--
You can mention here anything that you think is relevant for this PR. Some examples:
* You encountered something that you didn't understand while working on this PR
* You were not sure about something you did but did not find a better way
* You initially had a different approach but went with a different approach for some reason
-->
_Was anything unclear during your work on this PR? Anything we should definitely take a closer look at?_
## Checklist
**Are your changes backwards compatible? Please answer below:**
<!-- put your answer about backwards compatibility here -->
<!--
If your PR is not ready for review yet, please mark your PR as a draft.
If changes were requested, request a new review when you incorporated the feedback.
-->
**Did you QA this? Could we deploy this straight to production? Please answer below:**
**On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:**
<!-- put your answer about QA here -->
**For frontend changes: Tested on mobile? Please answer below:**
**For frontend changes: Tested on mobile, light and dark mode? Please answer below:**
<!-- put your answer about mobile QA here -->
**Did you introduce any new environment variables? If so, call them out explicitly here:**
<!-- put your answer about env vars here -->

35
.github/workflows/extend-awards.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: extend-awards
on:
pull_request_target:
types: [ closed ]
branches:
- master
permissions:
pull-requests: write
contents: write
issues: read
jobs:
if_merged:
if: |
github.event_name == 'pull_request_target' &&
github.event.action == 'closed' &&
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref != 'extend-awards/patch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: pip install requests
- run: python extend-awards.py
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_CONTEXT: ${{ toJson(github) }}
- uses: peter-evans/create-pull-request@v7
with:
add-paths: awards.csv
branch: extend-awards/patch
commit-message: Extending awards.csv
title: Extending awards.csv
body: A PR was merged that solves an issue and awards.csv should be extended.

View File

@ -1,8 +1,8 @@
name: Eslint Check
name: Lint Check
on: [pull_request]
jobs:
eslint-run:
lint-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
@ -11,7 +11,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "18.17.0"
node-version: "18.20.4"
- name: Install
run: npm install

View File

@ -11,7 +11,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "18.17.0"
node-version: "18.20.4"
- name: Install
run: npm install

10
.gitignore vendored
View File

@ -56,3 +56,13 @@ docker-compose.*.yml
# nostr wallet connect
scripts/nwc-keys.json
# lnbits
docker/lnbits/data
# lndk
!docker/lndk/tls-*.pem
# nostr link extract
scripts/nostr-link-extract.config.json
scripts/nostr-links.db

View File

@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
FROM node:18.17.0-bullseye
FROM node:18.20.4-bullseye
ENV NODE_ENV=development

View File

@ -5,7 +5,7 @@
</p>
- Stacker News makes internet communities that pay you Bitcoin
- Stacker News is trying to fix online communities with economics
- What You See is What We Ship (look ma, I invented an initialism)
- 100% FOSS
- We pay bitcoin for PRs, issues, documentation, code reviews and more
@ -30,7 +30,9 @@ Go to [localhost:3000](http://localhost:3000).
- Clone the repo
- ssh: `git clone git@github.com:stackernews/stacker.news.git`
- https: `git clone https://github.com/stackernews/stacker.news.git`
- Install [docker](https://docs.docker.com/get-docker/)
- Install [docker](https://docs.docker.com/compose/install/)
- If you're running MacOS or Windows, I ***highly recommend*** using [OrbStack](https://orbstack.dev/) instead of Docker Desktop
- Please make sure that at least 10 GB of free space is available, otherwise you may encounter issues while setting up the development environment.
<br>
@ -63,64 +65,55 @@ USAGE
$ sndev help [COMMAND]
COMMANDS
help show help
help show help
env:
start start env
stop stop env
restart restart env
status status of env
logs logs from env
delete delete env
start start env
stop stop env
restart restart env
status status of env
logs logs from env
delete delete env
sn:
login login as a nym
login login as a nym
set_balance set the balance of a nym
lnd:
fund pay a bolt11 for funding
withdraw create a bolt11 for withdrawal
cln:
cln_fund pay a bolt11 for funding with CLN
cln_withdraw create a bolt11 for withdrawal with CLN
lightning:
fund pay a bolt11 for funding
withdraw create a bolt11 for withdrawal
db:
psql open psql on db
prisma run prisma commands
psql open psql on db
prisma run prisma commands
dev:
pr fetch and checkout a pr
lint run linters
pr fetch and checkout a pr
lint run linters
test run tests
other:
compose docker compose passthrough
sn_lndcli lncli passthrough on sn_lnd
stacker_lndcli lncli passthrough on stacker_lnd
stacker_clncli lightning-cli passthrough on stacker_cln
cli service cli passthrough
open open service GUI in browser
onion service onion address
cert service tls cert
compose docker compose passthrough
```
### Modifying services
#### Running specific services
By default all services will be run. If you want to exclude specific services from running, set `COMPOSE_PROFILES` to use one or more of `minimal|images|search|payments|wallets|email|capture`. To only run mininal services without images, search, email, wallets, or payments:
By default all services will be run. If you want to exclude specific services from running, set `COMPOSE_PROFILES` in a `.env.local` file to one or more of `minimal,images,search,payments,wallets,email,capture`. To only run mininal necessary without things like payments in `.env.local`:
```sh
$ COMPOSE_PROFILES=minimal ./sndev start
```
Or, as I would recommend:
```sh
$ export COMPOSE_PROFILES=minimal
$ ./sndev start
```.env
COMPOSE_PROFILES=minimal
```
To run with images and payments services:
```sh
$ COMPOSE_PROFILES=images,payments ./sndev start
```.env
COMPOSE_PROFILES=images,payments
```
#### Merging compose files
@ -233,6 +226,7 @@ _Due to Rule 3, make sure that you mark your PR as a draft when you create it an
| tag | multiplier |
| ----------------- | ---------- |
| `priority:low` | 0.5 |
| `priority:medium` | 1.5 |
| `priority:high` | 2 |
| `priority:urgent` | 3 |
@ -370,9 +364,11 @@ You can connect to the local database via `./sndev psql`. [psql](https://www.pos
<br>
## Running lncli on the local lnd nodes
## Running cli on local lightning nodes
You can run `lncli` on the local lnd nodes via `./sndev sn_lncli` and `./sndev stacker_lncli`. The node for your local SN instance is `sn_lnd` and the node serving as any external node, like a stacker's node or external wallet, is `stacker_lnd`.
You can run `lncli` on the local lnd nodes via `./sndev cli lnd` and `./sndev cli sn_lnd`. The node for your local SN instance is `sn_lnd` and the node serving as any external node, like a stacker's node or external wallet, is `lnd`.
You can run `lightning-cli` on the local cln node via `./sndev cli cln` which serves as an external node or wallet.
<br>
@ -431,7 +427,7 @@ GITHUB_SECRET=<Client secret>
## Enabling web push notifications
To enable Web Push locally, you will need to set the `VAPID_*` env vars. `VAPID_MAILTO` needs to be an email address using the `mailto:` scheme. For `NEXT_PUBLIC_VAPID_KEY` and `VAPID_PRIVKEY`, you can run `npx web-push generate-vapid-keys`.
To enable Web Push locally, you will need to set the `VAPID_*` env vars. `VAPID_MAILTO` needs to be an email address using the `mailto:` scheme. For `NEXT_PUBLIC_VAPID_PUBKEY` and `VAPID_PRIVKEY`, you can run `npx web-push generate-vapid-keys`.
<br>
@ -459,7 +455,9 @@ In addition, we run other critical services the above services interact with lik
## Wallet transaction safety
To ensure stackers balances are kept sane, all wallet updates are run in [serializable transactions](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-SERIALIZABLE) at the database level. Because early versions of prisma had relatively poor support for transactions most wallet touching code is written in [plpgsql](https://www.postgresql.org/docs/current/plpgsql.html) stored procedures and can be found in the `prisma/migrations` folder.
To ensure stackers balances are kept sane, some wallet updates are run in [serializable transactions](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-SERIALIZABLE) at the database level. Because early versions of prisma had relatively poor support for transactions most wallet touching code is written in [plpgsql](https://www.postgresql.org/docs/current/plpgsql.html) stored procedures and can be found in the `prisma/migrations` folder.
*UPDATE*: Most wallet updates are now run in [read committed](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED) transactions. See `api/paidAction/README.md` for more information.
<br>

View File

@ -1,13 +1,20 @@
import lndService from 'ln-service'
import { cachedFetcher } from '@/lib/fetch'
import { toPositiveNumber } from '@/lib/format'
import { authenticatedLndGrpc } from '@/lib/lnd'
import { getIdentity, getHeight, getWalletInfo, getNode, getPayment, parsePaymentRequest } from 'ln-service'
import { datePivot } from '@/lib/time'
import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
const { lnd } = lndService.authenticatedLndGrpc({
const lnd = global.lnd || authenticatedLndGrpc({
cert: process.env.LND_CERT,
macaroon: process.env.LND_MACAROON,
socket: process.env.LND_SOCKET
})
}).lnd
if (process.env.NODE_ENV === 'development') global.lnd = lnd
// Check LND GRPC connection
lndService.getWalletInfo({ lnd }, (err, result) => {
getWalletInfo({ lnd }, (err, result) => {
if (err) {
console.error('LND GRPC connection error')
return
@ -15,4 +22,181 @@ lndService.getWalletInfo({ lnd }, (err, result) => {
console.log('LND GRPC connection successful')
})
export async function estimateRouteFee ({ lnd, destination, tokens, mtokens, request, timeout }) {
// if the payment request includes us as route hint, we needd to use the destination and amount
// otherwise, this will fail with a self-payment error
if (request) {
const inv = parsePaymentRequest({ request })
const ourPubkey = await getOurPubkey({ lnd })
if (Array.isArray(inv.routes)) {
for (const route of inv.routes) {
if (Array.isArray(route)) {
for (const hop of route) {
if (hop.public_key === ourPubkey) {
console.log('estimateRouteFee ignoring self-payment route')
request = false
break
}
}
}
}
}
}
return await new Promise((resolve, reject) => {
const params = {}
if (request) {
console.log('estimateRouteFee using payment request')
params.payment_request = request
} else {
console.log('estimateRouteFee using destination and amount')
params.dest = Buffer.from(destination, 'hex')
params.amt_sat = tokens ? toPositiveNumber(tokens) : toPositiveNumber(BigInt(mtokens) / BigInt(1e3))
}
lnd.router.estimateRouteFee({
...params,
timeout
}, (err, res) => {
if (err) {
if (res?.failure_reason) {
reject(new Error(`Unable to estimate route: ${res.failure_reason}`))
} else {
reject(err)
}
return
}
if (res.routing_fee_msat < 0 || res.time_lock_delay <= 0) {
reject(new Error('Unable to estimate route, excessive values: ' + JSON.stringify(res)))
return
}
resolve({
routingFeeMsat: toPositiveNumber(res.routing_fee_msat),
timeLockDelay: toPositiveNumber(res.time_lock_delay)
})
})
})
}
// created_height is the accepted_height, timeout is the expiry height
// ln-service remaps the `htlcs` field of lookupInvoice to `payments` and
// see: https://github.com/alexbosworth/lightning/blob/master/lnd_responses/htlc_as_payment.js
// and: https://lightning.engineering/api-docs/api/lnd/lightning/lookup-invoice/index.html#lnrpcinvoicehtlc
export function hodlInvoiceCltvDetails (inv) {
if (!inv.payments) {
throw new Error('No payments found')
}
if (!inv.is_held) {
throw new Error('Invoice is not held')
}
const acceptHeight = inv.payments.reduce((max, htlc) => {
const createdHeight = toPositiveNumber(htlc.created_height)
return createdHeight > max ? createdHeight : max
}, 0)
const expiryHeight = inv.payments.reduce((min, htlc) => {
const timeout = toPositiveNumber(htlc.timeout)
return timeout < min ? timeout : min
}, Number.MAX_SAFE_INTEGER)
return {
expiryHeight: toPositiveNumber(expiryHeight),
acceptHeight: toPositiveNumber(acceptHeight)
}
}
export function getPaymentFailureStatus (withdrawal) {
if (withdrawal && !withdrawal.is_failed) {
throw new Error('withdrawal is not failed')
}
if (withdrawal?.failed?.is_insufficient_balance) {
return {
status: 'INSUFFICIENT_BALANCE',
message: 'you didn\'t have enough sats'
}
} else if (withdrawal?.failed?.is_invalid_payment) {
return {
status: 'INVALID_PAYMENT',
message: 'invalid payment'
}
} else if (withdrawal?.failed?.is_pathfinding_timeout) {
return {
status: 'PATHFINDING_TIMEOUT',
message: 'no route found'
}
} else if (withdrawal?.failed?.is_route_not_found) {
return {
status: 'ROUTE_NOT_FOUND',
message: 'no route found'
}
}
return {
status: 'UNKNOWN_FAILURE',
message: 'unknown failure'
}
}
export const getBlockHeight = cachedFetcher(async function fetchBlockHeight ({ lnd, ...args }) {
try {
const { current_block_height: height } = await getHeight({ lnd, ...args })
return height
} catch (err) {
throw new Error(`Unable to fetch block height: ${err.message}`)
}
}, {
maxSize: 1,
cacheExpiry: 60 * 1000, // 1 minute
forceRefreshThreshold: 5 * 60 * 1000, // 5 minutes
keyGenerator: () => 'getHeight'
})
export const getOurPubkey = cachedFetcher(async function fetchOurPubkey ({ lnd, ...args }) {
try {
const identity = await getIdentity({ lnd, ...args })
return identity.public_key
} catch (err) {
throw new Error(`Unable to fetch identity: ${err.message}`)
}
}, {
maxSize: 1,
cacheExpiry: 0, // never expire
forceRefreshThreshold: 0, // never force refresh
keyGenerator: () => 'getOurPubkey'
})
export const getNodeSockets = cachedFetcher(async function fetchNodeSockets ({ lnd, ...args }) {
try {
return (await getNode({ lnd, is_omitting_channels: true, ...args }))?.sockets
} catch (err) {
throw new Error(`Unable to fetch node info: ${err.message}`)
}
}, {
maxSize: 100,
cacheExpiry: 1000 * 60 * 60 * 24, // 1 day
forceRefreshThreshold: 1000 * 60 * 60 * 24 * 7, // 1 week
keyGenerator: (args) => {
const { public_key: publicKey } = args
return publicKey
}
})
export async function getPaymentOrNotSent ({ id, lnd, createdAt }) {
try {
return await getPayment({ id, lnd })
} catch (err) {
if (err[1] === 'SentPaymentNotFound' &&
createdAt < datePivot(new Date(), { milliseconds: -LND_PATHFINDING_TIMEOUT_MS * 2 })) {
// if the payment is older than 2x timeout, but not found in LND, we can assume it errored before lnd stored it
return { notSent: true, is_failed: true }
} else {
throw err
}
}
}
export default lnd

View File

@ -1,3 +0,0 @@
{
"type": "module"
}

View File

@ -2,6 +2,38 @@
Paid actions are actions that require payments to perform. Given that we support several payment flows, some of which require more than one round of communication either with LND or the client, and several paid actions, we have this plugin-like interface to easily add new paid actions.
<details>
<summary>internals</summary>
All paid action progress, regardless of flow, is managed using a state machine that's transitioned by the invoice progress and payment progress (in the case of p2p paid action). Below is the full state machine for paid actions:
```mermaid
stateDiagram-v2
[*] --> PENDING
PENDING --> PAID
PENDING --> CANCELING
PENDING --> FAILED
PAID --> [*]
CANCELING --> FAILED
FAILED --> RETRYING
FAILED --> [*]
RETRYING --> [*]
[*] --> PENDING_HELD
PENDING_HELD --> HELD
PENDING_HELD --> FORWARDING
PENDING_HELD --> CANCELING
PENDING_HELD --> FAILED
HELD --> PAID
HELD --> CANCELING
HELD --> FAILED
FORWARDING --> FORWARDED
FORWARDING --> FAILED_FORWARD
FORWARDED --> PAID
FAILED_FORWARD --> CANCELING
FAILED_FORWARD --> FAILED
```
</details>
## Payment Flows
There are three payment flows:
@ -17,11 +49,20 @@ For paid actions that support it, if the stacker doesn't have enough fee credits
<details>
<summary>Internals</summary>
Internally, optimistic flows make use of a state machine that's transitioned by the invoice payment progress. All optimistic actions start in a `PENDING` state and have the following transitions:
Internally, optimistic flows make use of a state machine that's transitioned by the invoice payment progress.
- `PENDING` -> `PAID`: when the invoice is paid
- `PENDING` -> `FAILED`: when the invoice expires or is cancelled
- `FAILED` -> `RETRYING`: when the invoice for the action is replaced with a new invoice
```mermaid
stateDiagram-v2
[*] --> PENDING
PENDING --> PAID
PENDING --> CANCELING
PENDING --> FAILED
PAID --> [*]
CANCELING --> FAILED
FAILED --> RETRYING
FAILED --> [*]
RETRYING --> [*]
```
</details>
### Pessimistic
@ -32,27 +73,68 @@ Internally, pessimistic flows use hold invoices. If the action doesn't succeed,
<details>
<summary>Internals</summary>
Internally, pessimistic flows make use of a state machine that's transitioned by the invoice payment progress much like optimistic flows, but with extra steps. All pessimistic actions start in a `PENDING_HELD` state and has the following transitions:
Internally, pessimistic flows make use of a state machine that's transitioned by the invoice payment progress much like optimistic flows, but with extra steps.
- `PENDING_HELD` -> `HELD`: when the invoice is paid and the action's `perform` is run and the invoice is settled
- `HELD` -> `PAID`: when the action's `onPaid` is called
- `PENDING_HELD` -> `FAILED`: when the invoice for the action expires or is cancelled
- `HELD` -> `FAILED`: when the action fails after the invoice is paid
```mermaid
stateDiagram-v2
PAID --> [*]
CANCELING --> FAILED
FAILED --> [*]
[*] --> PENDING_HELD
PENDING_HELD --> HELD
PENDING_HELD --> CANCELING
PENDING_HELD --> FAILED
HELD --> PAID
HELD --> CANCELING
HELD --> FAILED
```
</details>
### Table of existing paid actions and their supported flows
| action | fee credits | optimistic | pessimistic | anonable | qr payable | p2p wrapped | side effects |
| ----------------- | ----------- | ---------- | ----------- | -------- | ---------- | ----------- | ------------ |
| zaps | x | x | x | x | x | x | x |
| posts | x | x | x | x | x | | x |
| comments | x | x | x | x | x | | x |
| downzaps | x | x | | | x | | x |
| poll votes | x | x | | | x | | |
| territory actions | x | | x | | x | | |
| donations | x | | x | x | x | | |
| update posts | x | | x | | x | | x |
| update comments | x | | x | | x | | x |
| action | fee credits | optimistic | pessimistic | anonable | qr payable | p2p wrapped | side effects | reward sats | p2p direct |
| ----------------- | ----------- | ---------- | ----------- | -------- | ---------- | ----------- | ------------ | ----------- | ---------- |
| zaps | x | x | x | x | x | x | x | | |
| posts | x | x | x | x | x | | x | x | |
| comments | x | x | x | x | x | | x | x | |
| downzaps | x | x | | | x | | x | x | |
| poll votes | x | x | | | x | | | x | |
| territory actions | x | | x | | x | | | x | |
| donations | x | | x | x | x | | | x | |
| update posts | x | | x | | x | | x | x | |
| update comments | x | | x | | x | | x | x | |
| receive | | x | | | x | x | x | | x |
| buy fee credits | | | x | | x | | | x | |
| invite gift | x | | | | | | x | x | |
## Not-custodial zaps (ie p2p wrapped payments)
Zaps, and possibly other future actions, can be performed peer to peer and non-custodially. This means that the payment is made directly from the client to the recipient, without the server taking custody of the funds. Currently, in order to trigger this behavior, the recipient must have a receiving wallet attached and the sender must have insufficient funds in their custodial wallet to perform the requested zap.
This works by requesting an invoice from the recipient's wallet and reusing the payment hash in a hold invoice paid to SN (to collect the sybil fee) which we serve to the sender. When the sender pays this wrapped invoice, we forward our own money to the recipient, who then reveals the preimage to us, allowing us to settle the wrapped invoice and claim the sender's funds. This effectively does what a lightning node does when forwarding a payment but allows us to do it at the application layer.
<details>
<summary>Internals</summary>
Internally, p2p wrapped payments make use of the same paid action state machine but it's transitioned by both the incoming invoice payment progress *and* the outgoing invoice payment progress.
```mermaid
stateDiagram-v2
PAID --> [*]
CANCELING --> FAILED
FAILED --> RETRYING
FAILED --> [*]
RETRYING --> [*]
[*] --> PENDING_HELD
PENDING_HELD --> FORWARDING
PENDING_HELD --> CANCELING
PENDING_HELD --> FAILED
FORWARDING --> FORWARDED
FORWARDING --> FAILED_FORWARD
FORWARDED --> PAID
FAILED_FORWARD --> CANCELING
FAILED_FORWARD --> FAILED
```
</details>
## Paid Action Interface
@ -60,10 +142,16 @@ Each paid action is implemented in its own file in the `paidAction` directory. E
### Boolean flags
- `anonable`: can be performed anonymously
- `supportsPessimism`: supports a pessimistic payment flow
- `supportsOptimism`: supports an optimistic payment flow
#### Functions
### Payment methods
- `paymentMethods`: an array of payment methods that the action supports ordered from most preferred to least preferred
- P2P: a p2p payment made directly from the client to the recipient
- after wrapping the invoice, anonymous users will follow a PESSIMISTIC flow to pay the invoice and logged in users will follow an OPTIMISTIC flow
- FEE_CREDIT: a payment made from the user's fee credit balance
- OPTIMISTIC: an optimistic payment flow
- PESSIMISTIC: a pessimistic payment flow
### Functions
All functions have the following signature: `function(args: Object, context: Object): Promise`
@ -75,7 +163,11 @@ All functions have the following signature: `function(args: Object, context: Obj
- it can optionally store in the invoice with the `invoiceId` the `actionId` to be able to link the action with the invoice regardless of retries
- `onPaid`: called when the action is paid
- if the action does not support optimism, this function is optional
- this function should be used to mark the rows created in `perform` as `PAID` and perform any other side effects of the action (like notifications or denormalizations)
- this function should be used to mark the rows created in `perform` as `PAID` and perform critical side effects of the action (like denormalizations)
- `nonCriticalSideEffects`: called after the action is paid to run any side effects whose failure does not affect the action's execution
- this function is always optional
- it's passed the result of the action (or the action's paid invoice) and the current context
- this is where things like push notifications should be handled
- `onFail`: called when the action fails
- if the action does not support optimism, this function is optional
- this function should be used to mark the rows created in `perform` as `FAILED`
@ -84,8 +176,11 @@ All functions have the following signature: `function(args: Object, context: Obj
- this function is called when an optimistic action is retried
- it's passed the original `invoiceId` and the `newInvoiceId`
- this function should update the rows created in `perform` to contain the new `newInvoiceId` and remark the row as `PENDING`
- `getInvoiceablePeer`: returns the userId of the peer that's capable of generating an invoice so they can be paid for the action
- this is only used for p2p wrapped zaps currently
- `describe`: returns a description as a string of the action
- for actions that require generating an invoice, and for stackers that don't hide invoice descriptions, this is used in the invoice description
- `getSybilFeePercent` (required if `getInvoiceablePeer` is implemented): returns the action sybil fee percent as a `BigInt` (eg. 30n for 30%)
#### Function arguments
@ -94,10 +189,17 @@ All functions have the following signature: `function(args: Object, context: Obj
`context` contains the following fields:
- `me`: the user performing the action (undefined if anonymous)
- `cost`: the cost of the action in msats as a `BigInt`
- `sybilFeePercent`: the sybil fee percent as a `BigInt` (eg. 30n for 30%)
- `tx`: the current transaction (for anything that needs to be done atomically with the payment)
- `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment)
- `lnd`: the current lnd client
## Recording Cowboy Credits
To avoid adding sats and credits together everywhere to show an aggregate sat value, in most cases we denormalize a `sats` field that carries the "sats value", the combined sats + credits of something, and a `credits` field that carries only the earned `credits`. For example, the `Item` table has an `msats` field that carries the sum of the `mcredits` and `msats` earned and a `mcredits` field that carries the value of the `mcredits` earned. So, the sats value an item earned is `item.msats` BUT the real sats earned is `item.msats - item.mcredits`.
The ONLY exception to this are for the `users` table where we store a stacker's rewards sats and credits balances separately.
## `IMPORTANT: transaction isolation`
We use a `read committed` isolation level for actions. This means paid actions need to be mindful of concurrency issues. Specifically, reading data from the database and then writing it back in `read committed` is a common source of consistency bugs (aka serialization anamolies).
@ -148,7 +250,7 @@ COMMIT;
-- item_zaps.sats is 100, but we would expect it to be 200
```
Note that row level locks wouldn't help in this case, because we can't lock the rows that the transactions doesn't know to exist yet.
Note that row level locks wouldn't help in this case, because we can't lock the rows that the transactions don't know to exist yet.
#### Subqueries are still incorrect
@ -201,4 +303,69 @@ From the [postgres docs](https://www.postgresql.org/docs/current/transaction-iso
> UPDATE, DELETE, SELECT FOR UPDATE, and SELECT FOR SHARE commands behave the same as SELECT in terms of searching for target rows: they will only find target rows that were committed as of the command start time. However, such a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress). If the first updater rolls back, then its effects are negated and the second updater can proceed with updating the originally found row. If the first updater commits, the second updater will ignore the row if the first updater deleted it, otherwise it will attempt to apply its operation to the updated version of the row. The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition. If so, the second updater proceeds with its operation using the updated version of the row. In the case of SELECT FOR UPDATE and SELECT FOR SHARE, this means it is the updated version of the row that is locked and returned to the client.
From the [postgres source docs](https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/executor/README#l350):
> It is also possible that there are relations in the query that are not to be locked (they are neither the UPDATE/DELETE/MERGE target nor specified to be locked in SELECT FOR UPDATE/SHARE). When re-running the test query ***we want to use the same rows*** from these relations that were joined to the locked rows.
> It is also possible that there are relations in the query that are not to be locked (they are neither the UPDATE/DELETE/MERGE target nor specified to be locked in SELECT FOR UPDATE/SHARE). When re-running the test query ***we want to use the same rows*** from these relations that were joined to the locked rows.
## `IMPORTANT: deadlocks`
Deadlocks can occur when two transactions are waiting for each other to release locks. This can happen when two transactions lock rows in different orders whether explicit or implicit.
If both transactions lock the rows in the same order, the deadlock is avoided.
### Incorrect
```sql
-- transaction 1
BEGIN;
UPDATE users set msats = msats + 1 WHERE id = 1;
-- transaction 2
BEGIN;
UPDATE users set msats = msats + 1 WHERE id = 2;
-- transaction 1 (blocks here until transaction 2 commits)
UPDATE users set msats = msats + 1 WHERE id = 2;
-- transaction 2 (blocks here until transaction 1 commits)
UPDATE users set msats = msats + 1 WHERE id = 1;
-- deadlock occurs because neither transaction can proceed to here
```
In practice, this most often occurs when selecting multiple rows for update in different orders. Recently, we had a deadlock when spliting zaps to multiple users. The solution was to select the rows for update in the same order.
### Incorrect
```sql
WITH forwardees AS (
SELECT "userId", (($1::BIGINT * pct) / 100)::BIGINT AS msats
FROM "ItemForward"
WHERE "itemId" = $2::INTEGER
),
UPDATE users
SET
msats = users.msats + forwardees.msats,
"stackedMsats" = users."stackedMsats" + forwardees.msats
FROM forwardees
WHERE users.id = forwardees."userId";
```
If forwardees are selected in a different order in two concurrent transactions, e.g. (1,2) in tx 1 and (2,1) in tx 2, a deadlock can occur. To avoid this, always select rows for update in the same order.
### Correct
We fixed the deadlock by selecting the forwardees in the same order in these transactions.
```sql
WITH forwardees AS (
SELECT "userId", (($1::BIGINT * pct) / 100)::BIGINT AS msats
FROM "ItemForward"
WHERE "itemId" = $2::INTEGER
ORDER BY "userId" ASC
),
UPDATE users
SET
msats = users.msats + forwardees.msats,
"stackedMsats" = users."stackedMsats" + forwardees.msats
FROM forwardees
WHERE users.id = forwardees."userId";
```
### More resources
- https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS

82
api/paidAction/boost.js Normal file
View File

@ -0,0 +1,82 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
]
export async function getCost ({ sats }) {
return satsToMsats(sats)
}
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) {
itemId = parseInt(itemId)
let invoiceData = {}
if (invoiceId) {
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
// store a reference to the item in the invoice
await tx.invoice.update({
where: { id: invoiceId },
data: { actionId: itemId }
})
}
const act = await tx.itemAct.create({ data: { msats: cost, itemId, userId: me.id, act: 'BOOST', ...invoiceData } })
const [{ path }] = await tx.$queryRaw`
SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`
return { id: itemId, sats, act: 'BOOST', path, actId: act.id }
}
export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) {
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
const [{ id, path }] = await tx.$queryRaw`
SELECT "Item".id, ltree2text(path) as path
FROM "Item"
JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId"
WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER`
return { id, sats: msatsToSats(cost), act: 'BOOST', path }
}
export async function onPaid ({ invoice, actId }, { tx }) {
let itemAct
if (invoice) {
await tx.itemAct.updateMany({
where: { invoiceId: invoice.id },
data: {
invoiceActionState: 'PAID'
}
})
itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id } })
} else if (actId) {
itemAct = await tx.itemAct.findFirst({ where: { id: actId } })
} else {
throw new Error('No invoice or actId')
}
// increment boost on item
await tx.item.update({
where: { id: itemAct.itemId },
data: {
boost: { increment: msatsToSats(itemAct.msats) }
}
})
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil)
VALUES ('expireBoost', jsonb_build_object('id', ${itemAct.itemId}::INTEGER), 21, true,
now() + interval '30 days', now() + interval '40 days')`
}
export async function onFail ({ invoice }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
}
export async function describe ({ id: itemId, sats }, { actionId, cost }) {
return `SN: boost ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}`
}

View File

@ -1,26 +1,32 @@
// XXX we don't use this yet ...
// it's just showing that even buying credits
// can eventually be a paid action
import { USER_ID } from '@/lib/constants'
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
export const anonable = false
export const supportsPessimism = false
export const supportsOptimism = true
export async function getCost ({ amount }) {
return satsToMsats(amount)
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ credits }) {
return satsToMsats(credits)
}
export async function onPaid ({ invoice }, { tx }) {
return await tx.users.update({
where: { id: invoice.userId },
data: { balance: { increment: invoice.msatsReceived } }
export async function perform ({ credits }, { me, cost, tx }) {
await tx.user.update({
where: { id: me.id },
data: {
mcredits: {
increment: cost
}
}
})
return {
credits
}
}
export async function describe ({ amount }, { models, me }) {
const user = await models.user.findUnique({ where: { id: me?.id ?? USER_ID.anon } })
return `SN: buying credits for @${user.name}`
export async function describe () {
return 'SN: buy fee credits'
}

View File

@ -1,9 +1,13 @@
import { USER_ID } from '@/lib/constants'
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
export const anonable = true
export const supportsPessimism = true
export const supportsOptimism = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ sats }) {
return satsToMsats(sats)

View File

@ -1,8 +1,14 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { Prisma } from '@prisma/client'
export const anonable = false
export const supportsPessimism = false
export const supportsOptimism = true
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
]
export async function getCost ({ sats }) {
return satsToMsats(sats)
@ -43,9 +49,9 @@ export async function onPaid ({ invoice, actId }, { tx }) {
let itemAct
if (invoice) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id } })
itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id }, include: { item: true } })
} else if (actId) {
itemAct = await tx.itemAct.findUnique({ where: { id: actId } })
itemAct = await tx.itemAct.findUnique({ where: { id: actId }, include: { item: true } })
} else {
throw new Error('No invoice or actId')
}
@ -55,25 +61,40 @@ export async function onPaid ({ invoice, actId }, { tx }) {
// denormalize downzaps
await tx.$executeRaw`
WITH zapper AS (
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER
), zap AS (
INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats")
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
ON CONFLICT ("itemId", "userId") DO UPDATE
SET "downZapSats" = "ItemUserAgg"."downZapSats" + ${sats}::INTEGER, updated_at = now()
RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
)
UPDATE "Item"
SET "weightedDownVotes" = "weightedDownVotes" + (zapper.trust * zap.log_sats)
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER`
WITH territory AS (
SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId"
WHERE i.id = ${itemAct.itemId}::INTEGER
), zapper AS (
SELECT
COALESCE(${itemAct.item.parentId
? Prisma.sql`"zapCommentTrust"`
: Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust",
COALESCE(${itemAct.item.parentId
? Prisma.sql`"subZapCommentTrust"`
: Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust"
FROM territory
LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName"
AND ust."userId" = ${itemAct.userId}::INTEGER
), zap AS (
INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats")
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
ON CONFLICT ("itemId", "userId") DO UPDATE
SET "downZapSats" = "ItemUserAgg"."downZapSats" + ${sats}::INTEGER, updated_at = now()
RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
)
UPDATE "Item"
SET "weightedDownVotes" = "weightedDownVotes" + zapper."zapTrust" * zap.log_sats,
"subWeightedDownVotes" = "subWeightedDownVotes" + zapper."subZapTrust" * zap.log_sats
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER`
}
export async function onFail ({ invoice }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
}
export async function describe ({ itemId, sats }, { cost, actionId }) {
export async function describe ({ id: itemId, sats }, { cost, actionId }) {
return `SN: downzap of ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}`
}

View File

@ -1,8 +1,11 @@
import { createHodlInvoice, createInvoice } from 'ln-service'
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
import { datePivot } from '@/lib/time'
import { USER_ID } from '@/lib/constants'
import { createHmac } from '../resolvers/wallet'
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { createHmac } from '@/api/resolvers/wallet'
import { Prisma } from '@prisma/client'
import { createWrappedInvoice, createUserInvoice } from '@/wallets/server'
import { assertBelowMaxPendingInvoices, assertBelowMaxPendingDirectPayments } from './lib/assert'
import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate'
import * as ZAP from './zap'
@ -13,23 +16,31 @@ import * as TERRITORY_UPDATE from './territoryUpdate'
import * as TERRITORY_BILLING from './territoryBilling'
import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
import * as DONATE from './donate'
import * as BOOST from './boost'
import * as RECEIVE from './receive'
import * as BUY_CREDITS from './buyCredits'
import * as INVITE_GIFT from './inviteGift'
export const paidActions = {
ITEM_CREATE,
ITEM_UPDATE,
ZAP,
DOWN_ZAP,
BOOST,
POLL_VOTE,
TERRITORY_CREATE,
TERRITORY_UPDATE,
TERRITORY_BILLING,
TERRITORY_UNARCHIVE,
DONATE
DONATE,
RECEIVE,
BUY_CREDITS,
INVITE_GIFT
}
export default async function performPaidAction (actionType, args, context) {
export default async function performPaidAction (actionType, args, incomingContext) {
try {
const { me, models, forceFeeCredits } = context
const { me, models, forcePaymentMethod } = incomingContext
const paidAction = paidActions[actionType]
console.group('performPaidAction', actionType, args)
@ -38,49 +49,85 @@ export default async function performPaidAction (actionType, args, context) {
throw new Error(`Invalid action type ${actionType}`)
}
context.me = me ? await models.user.findUnique({ where: { id: me.id } }) : undefined
context.cost = await paidAction.getCost(args, context)
if (!me) {
if (!paidAction.anonable) {
throw new Error('You must be logged in to perform this action')
}
console.log('we are anon so can only perform pessimistic action')
return await performPessimisticAction(actionType, args, context)
if (!me && !paidAction.anonable) {
throw new Error('You must be logged in to perform this action')
}
const isRich = context.cost <= context.me.msats
if (isRich) {
try {
console.log('enough fee credits available, performing fee credit action')
return await performFeeCreditAction(actionType, args, context)
} catch (e) {
console.error('fee credit action failed', e)
// treat context as immutable
const contextWithMe = {
...incomingContext,
me: me ? await models.user.findUnique({ where: { id: parseInt(me.id) } }) : undefined
}
const context = {
...contextWithMe,
cost: await paidAction.getCost(args, contextWithMe),
sybilFeePercent: await paidAction.getSybilFeePercent?.(args, contextWithMe)
}
// if we fail with fee credits, but not because of insufficient funds, bail
if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
// special case for zero cost actions
if (context.cost === 0n) {
console.log('performing zero cost action')
return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod: 'ZERO_COST' })
}
for (const paymentMethod of paidAction.paymentMethods) {
console.log(`considering payment method ${paymentMethod}`)
const contextWithPaymentMethod = { ...context, paymentMethod }
if (forcePaymentMethod &&
paymentMethod !== forcePaymentMethod) {
console.log('skipping payment method', paymentMethod, 'because forcePaymentMethod is set to', forcePaymentMethod)
continue
}
// payment methods that anonymous users can use
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P) {
try {
return await performP2PAction(actionType, args, contextWithPaymentMethod)
} catch (e) {
if (e instanceof NonInvoiceablePeerError) {
console.log('peer cannot be invoiced, skipping')
continue
}
console.error(`${paymentMethod} action failed`, e)
throw e
}
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC) {
return await beginPessimisticAction(actionType, args, contextWithPaymentMethod)
}
// additional payment methods that logged in users can use
if (me) {
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT ||
paymentMethod === PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) {
try {
return await performNoInvoiceAction(actionType, args, contextWithPaymentMethod)
} catch (e) {
// if we fail with fee credits or reward sats, but not because of insufficient funds, bail
console.error(`${paymentMethod} action failed`, e)
if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"') &&
!e.message.includes('\\"users\\" violates check constraint \\"mcredits_positive\\"')) {
throw e
}
}
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) {
return await performOptimisticAction(actionType, args, contextWithPaymentMethod)
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT) {
try {
return await performDirectAction(actionType, args, contextWithPaymentMethod)
} catch (e) {
if (e instanceof NonInvoiceablePeerError) {
console.log('peer cannot be invoiced, skipping')
continue
}
console.error(`${paymentMethod} action failed`, e)
throw e
}
}
}
}
// this is set if the worker executes a paid action in behalf of a user.
// in that case, only payment via fee credits is possible
// since there is no client to which we could send an invoice.
// example: automated territory billing
if (forceFeeCredits) {
throw new Error('forceFeeCredits is set, but user does not have enough fee credits')
}
// if we fail to do the action with fee credits, we should fall back to optimistic
if (paidAction.supportsOptimism) {
console.log('performing optimistic action')
return await performOptimisticAction(actionType, args, context)
}
console.error('action does not support optimism and fee credits failed, performing pessimistic action')
return await performPessimisticAction(actionType, args, context)
throw new Error('No working payment method found')
} catch (e) {
console.error('performPaidAction failed', e)
throw e
@ -89,43 +136,53 @@ export default async function performPaidAction (actionType, args, context) {
}
}
async function performFeeCreditAction (actionType, args, context) {
const { me, models, cost } = context
async function performNoInvoiceAction (actionType, args, incomingContext) {
const { me, models, cost, paymentMethod } = incomingContext
const action = paidActions[actionType]
return await models.$transaction(async tx => {
context.tx = tx
const result = await models.$transaction(async tx => {
const context = { ...incomingContext, tx }
await tx.user.update({
where: {
id: me.id
},
data: {
msats: {
decrement: cost
}
}
})
if (paymentMethod === 'FEE_CREDIT') {
await tx.user.update({
where: {
id: me?.id ?? USER_ID.anon
},
data: { mcredits: { decrement: cost } }
})
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) {
await tx.user.update({
where: {
id: me?.id ?? USER_ID.anon
},
data: { msats: { decrement: cost } }
})
}
const result = await action.perform(args, context)
await action.onPaid?.(result, context)
return {
result,
paymentMethod: 'FEE_CREDIT'
paymentMethod
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
// run non critical side effects in the background
// after the transaction has been committed
action.nonCriticalSideEffects?.(result.result, incomingContext).catch(console.error)
return result
}
async function performOptimisticAction (actionType, args, context) {
const { models } = context
async function performOptimisticAction (actionType, args, incomingContext) {
const { models, invoiceArgs: incomingInvoiceArgs } = incomingContext
const action = paidActions[actionType]
context.optimistic = true
context.lndInvoice = await createLndInvoice(actionType, args, context)
const optimisticContext = { ...incomingContext, optimistic: true }
const invoiceArgs = incomingInvoiceArgs ?? await createSNInvoice(actionType, args, optimisticContext)
return await models.$transaction(async tx => {
context.tx = tx
const context = { ...optimisticContext, tx, invoiceArgs }
const invoice = await createDbInvoice(actionType, args, context)
@ -137,24 +194,128 @@ async function performOptimisticAction (actionType, args, context) {
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}
async function performPessimisticAction (actionType, args, context) {
async function beginPessimisticAction (actionType, args, context) {
const action = paidActions[actionType]
if (!action.supportsPessimism) {
if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC)) {
throw new Error(`This action ${actionType} does not support pessimistic invoicing`)
}
// just create the invoice and complete action when it's paid
context.lndInvoice = await createLndInvoice(actionType, args, context)
const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(actionType, args, context)
return {
invoice: await createDbInvoice(actionType, args, context),
invoice: await createDbInvoice(actionType, args, { ...context, invoiceArgs }),
paymentMethod: 'PESSIMISTIC'
}
}
export async function retryPaidAction (actionType, args, context) {
const { models, me } = context
const { invoiceId } = args
async function performP2PAction (actionType, args, incomingContext) {
// if the action has an invoiceable peer, we'll create a peer invoice
// wrap it, and return the wrapped invoice
const { cost, sybilFeePercent, models, lnd, me } = incomingContext
if (!sybilFeePercent) {
throw new Error('sybil fee percent is not set for an invoiceable peer action')
}
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, incomingContext)
if (!userId) {
throw new NonInvoiceablePeerError()
}
let context
try {
await assertBelowMaxPendingInvoices(incomingContext)
const description = await paidActions[actionType].describe(args, incomingContext)
const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: cost,
feePercent: sybilFeePercent,
description,
expiry: INVOICE_EXPIRE_SECS
}, { models, me, lnd })
context = {
...incomingContext,
invoiceArgs: {
bolt11: invoice,
wrappedBolt11: wrappedInvoice,
wallet,
maxFee
}
}
} catch (e) {
console.error('failed to create wrapped invoice', e)
throw new NonInvoiceablePeerError()
}
return me
? await performOptimisticAction(actionType, args, context)
: await beginPessimisticAction(actionType, args, context)
}
// we don't need to use the module for perform-ing outside actions
// because we can't track the state of outside invoices we aren't paid/paying
async function performDirectAction (actionType, args, incomingContext) {
const { models, lnd, cost } = incomingContext
const { comment, lud18Data, noteStr, description: actionDescription } = args
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, incomingContext)
if (!userId) {
throw new NonInvoiceablePeerError()
}
try {
await assertBelowMaxPendingDirectPayments(userId, incomingContext)
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
for await (const { invoice, logger, wallet } of createUserInvoice(userId, {
msats: cost,
description,
expiry: INVOICE_EXPIRE_SECS
}, { models, lnd })) {
let hash
try {
hash = parsePaymentRequest({ request: invoice }).id
} catch (e) {
console.error('failed to parse invoice', e)
logger?.error('failed to parse invoice: ' + e.message, { bolt11: invoice })
continue
}
try {
return {
invoice: await models.directPayment.create({
data: {
comment,
lud18Data,
desc: noteStr,
bolt11: invoice,
msats: cost,
hash,
walletId: wallet.id,
receiverId: userId
}
}),
paymentMethod: 'DIRECT'
}
} catch (e) {
console.error('failed to create direct payment', e)
logger?.error('failed to create direct payment: ' + e.message, { bolt11: invoice })
}
}
} catch (e) {
console.error('failed to create user invoice', e)
}
throw new NonInvoiceablePeerError()
}
export async function retryPaidAction (actionType, args, incomingContext) {
const { models, me } = incomingContext
const { invoice: failedInvoice } = args
console.log('retryPaidAction', actionType, args)
const action = paidActions[actionType]
if (!action) {
@ -165,32 +326,56 @@ export async function retryPaidAction (actionType, args, context) {
throw new Error(`retryPaidAction - must be logged in ${actionType}`)
}
if (!action.supportsOptimism) {
throw new Error(`retryPaidAction - action does not support optimism ${actionType}`)
if (!failedInvoice) {
throw new Error(`retryPaidAction - missing invoice ${actionType}`)
}
if (!action.retry) {
throw new Error(`retryPaidAction - action does not support retrying ${actionType}`)
const { msatsRequested, actionId, actionArgs, actionOptimistic } = failedInvoice
const retryContext = {
...incomingContext,
optimistic: actionOptimistic,
me: await models.user.findUnique({ where: { id: parseInt(me.id) } }),
cost: BigInt(msatsRequested),
actionId,
predecessorId: failedInvoice.id
}
if (!invoiceId) {
throw new Error(`retryPaidAction - missing invoiceId ${actionType}`)
let invoiceArgs
const invoiceForward = await models.invoiceForward.findUnique({
where: {
invoiceId: failedInvoice.id
},
include: {
wallet: true
}
})
if (invoiceForward) {
// this is a wrapped invoice, we need to retry it with receiver fallbacks
try {
const { userId } = invoiceForward.wallet
// this will return an invoice from the first receiver wallet that didn't fail yet and throw if none is available
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: failedInvoice.msatsRequested,
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
description: await action.describe?.(actionArgs, retryContext),
expiry: INVOICE_EXPIRE_SECS
}, retryContext)
invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
} catch (err) {
console.log('failed to retry wrapped invoice, falling back to SN:', err)
}
}
context.optimistic = true
context.me = await models.user.findUnique({ where: { id: me.id } })
const { msatsRequested } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
context.cost = BigInt(msatsRequested)
context.lndInvoice = await createLndInvoice(actionType, args, context)
invoiceArgs ??= await createSNInvoice(actionType, actionArgs, retryContext)
return await models.$transaction(async tx => {
context.tx = tx
const context = { ...retryContext, tx, invoiceArgs }
// update the old invoice to RETRYING, so that it's not confused with FAILED
const { actionId } = await tx.invoice.update({
await tx.invoice.update({
where: {
id: invoiceId,
id: failedInvoice.id,
actionState: 'FAILED'
},
data: {
@ -198,80 +383,109 @@ export async function retryPaidAction (actionType, args, context) {
}
})
context.actionId = actionId
// create a new invoice
const invoice = await createDbInvoice(actionType, args, context)
const invoice = await createDbInvoice(actionType, actionArgs, context)
return {
result: await action.retry({ invoiceId, newInvoiceId: invoice.id }, context),
result: await action.retry?.({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
invoice,
paymentMethod: 'OPTIMISTIC'
paymentMethod: actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC'
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}
const OPTIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
const PESSIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
const INVOICE_EXPIRE_SECS = 600
export class NonInvoiceablePeerError extends Error {
constructor () {
super('non invoiceable peer')
this.name = 'NonInvoiceablePeerError'
}
}
// we seperate the invoice creation into two functions because
// because if lnd is slow, it'll timeout the interactive tx
async function createLndInvoice (actionType, args, context) {
async function createSNInvoice (actionType, args, context) {
const { me, lnd, cost, optimistic } = context
const action = paidActions[actionType]
const [createLNDInvoice, expirePivot] = optimistic
? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE]
: [createHodlInvoice, PESSIMISTIC_INVOICE_EXPIRE]
const createLNDInvoice = optimistic ? createInvoice : createHodlInvoice
await assertBelowMaxPendingInvoices(context)
if (cost < 1000n) {
// sanity check
throw new Error('The cost of the action must be at least 1 sat')
}
const expiresAt = datePivot(new Date(), expirePivot)
return await createLNDInvoice({
const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS })
const invoice = await createLNDInvoice({
description: me?.hideInvoiceDesc ? undefined : await action.describe(args, context),
lnd,
mtokens: String(cost),
expires_at: expiresAt
})
return { bolt11: invoice.request, preimage: invoice.secret }
}
async function createDbInvoice (actionType, args, context) {
const { me, models, tx, lndInvoice, cost, optimistic, actionId } = context
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs
const db = tx ?? models
const [expirePivot, actionState] = optimistic
? [OPTIMISTIC_INVOICE_EXPIRE, 'PENDING']
: [PESSIMISTIC_INVOICE_EXPIRE, 'PENDING_HELD']
if (cost < 1000n) {
// sanity check
throw new Error('The cost of the action must be at least 1 sat')
}
const expiresAt = datePivot(new Date(), expirePivot)
const invoice = await db.invoice.create({
data: {
hash: lndInvoice.id,
msatsRequested: cost,
preimage: optimistic ? undefined : lndInvoice.secret,
bolt11: lndInvoice.request,
userId: me?.id ?? USER_ID.anon,
actionType,
actionState,
actionArgs: args,
expiresAt,
actionId
}
})
const servedBolt11 = wrappedBolt11 ?? bolt11
const servedInvoice = parsePaymentRequest({ request: servedBolt11 })
const expiresAt = new Date(servedInvoice.expires_at)
const invoiceData = {
hash: servedInvoice.id,
msatsRequested: BigInt(servedInvoice.mtokens),
preimage,
bolt11: servedBolt11,
userId: me?.id ?? USER_ID.anon,
actionType,
actionState: wrappedBolt11 ? 'PENDING_HELD' : optimistic ? 'PENDING' : 'PENDING_HELD',
actionOptimistic: optimistic,
actionArgs: args,
expiresAt,
actionId,
paymentAttempt,
predecessorId
}
let invoice
if (wrappedBolt11) {
invoice = (await db.invoiceForward.create({
include: { invoice: true },
data: {
bolt11,
maxFeeMsats: maxFee,
invoice: {
create: invoiceData
},
wallet: {
connect: {
id: wallet.id
}
}
}
})).invoice
} else {
invoice = await db.invoice.create({ data: invoiceData })
}
// insert a job to check the invoice after it's set to expire
await db.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein, priority)
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil, priority)
VALUES ('checkInvoice',
jsonb_build_object('hash', ${lndInvoice.id}::TEXT), 21, true,
jsonb_build_object('hash', ${invoice.hash}::TEXT), 21, true,
${expiresAt}::TIMESTAMP WITH TIME ZONE,
${expiresAt}::TIMESTAMP WITH TIME ZONE - now() + interval '10m', 100)`
${expiresAt}::TIMESTAMP WITH TIME ZONE + interval '10m', 100)`
// the HMAC is only returned during invoice creation
// this makes sure that only the person who created this invoice

View File

@ -0,0 +1,60 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { notifyInvite } from '@/lib/webPush'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS
]
export async function getCost ({ id }, { models, me }) {
const invite = await models.invite.findUnique({ where: { id, userId: me.id, revoked: false } })
if (!invite) {
throw new Error('invite not found')
}
return satsToMsats(invite.gift)
}
export async function perform ({ id, userId }, { me, cost, tx }) {
const invite = await tx.invite.findUnique({
where: { id, userId: me.id, revoked: false }
})
if (invite.limit && invite.giftedCount >= invite.limit) {
throw new Error('invite limit reached')
}
// check that user was created in last hour
// check that user did not already redeem an invite
await tx.user.update({
where: {
id: userId,
inviteId: null,
createdAt: {
gt: new Date(Date.now() - 1000 * 60 * 60)
}
},
data: {
mcredits: {
increment: cost
},
inviteId: id,
referrerId: me.id
}
})
return await tx.invite.update({
where: { id, userId: me.id, revoked: false, ...(invite.limit ? { giftedCount: { lt: invite.limit } } : {}) },
data: {
giftedCount: {
increment: 1
}
}
})
}
export async function nonCriticalSideEffects (_, { me }) {
notifyInvite(me.id)
}

View File

@ -1,28 +1,57 @@
import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, USER_ID } from '@/lib/constants'
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers } from '@/lib/webPush'
import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers, notifyThreadSubscribers } from '@/lib/webPush'
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { satsToMsats } from '@/lib/format'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { GqlInputError } from '@/lib/error'
export const anonable = true
export const supportsPessimism = true
export const supportsOptimism = true
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export const DEFAULT_ITEM_COST = 1000n
export async function getBaseCost ({ models, bio, parentId, subName }) {
if (bio) return DEFAULT_ITEM_COST
if (parentId) {
// the subname is stored in the root item of the thread
const [sub] = await models.$queryRaw`
SELECT s."replyCost"
FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId"
LEFT JOIN "Sub" s ON s.name = COALESCE(r."subName", i."subName")
WHERE i.id = ${Number(parentId)}`
if (sub?.replyCost) return satsToMsats(sub.replyCost)
return DEFAULT_ITEM_COST
}
const sub = await models.sub.findUnique({ where: { name: subName } })
return satsToMsats(sub.baseCost)
}
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
const sub = (parentId || bio) ? null : await models.sub.findUnique({ where: { name: subName } })
const baseCost = sub ? satsToMsats(sub.baseCost) : 1000n
const baseCost = await getBaseCost({ models, bio, parentId, subName })
// cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + image fees + boost
// cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + upload fees + boost
const [{ cost }] = await models.$queryRaw`
SELECT ${baseCost}::INTEGER
* POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me?.id ?? USER_ID.anon}::INTEGER,
${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
* ${me ? 1 : 100}::INTEGER
+ (SELECT "nUnpaid" * "imageFeeMsats"
FROM image_fees_info(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
+ (SELECT "nUnpaid" * "uploadFeesMsats"
FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
+ ${satsToMsats(boost)}::INTEGER as cost`
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon, and cost must be greater than user's balance
const freebie = (parentId || bio || sub?.allowFreebies) && cost <= baseCost && !!me && cost > me?.msats
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon,
// cost must be greater than user's balance, and user has not disabled freebies
const freebie = (parentId || bio) && cost <= baseCost && !!me &&
me?.msats < cost && !me?.disableFreebies && me?.mcredits < cost
return freebie ? BigInt(0) : BigInt(cost)
}
@ -32,6 +61,16 @@ export async function perform (args, context) {
const { tx, me, cost } = context
const boostMsats = satsToMsats(boost)
const deletedUploads = []
for (const uploadId of uploadIds) {
if (!await tx.upload.findUnique({ where: { id: uploadId } })) {
deletedUploads.push(uploadId)
}
}
if (deletedUploads.length > 0) {
throw new Error(`upload(s) ${deletedUploads.join(', ')} are expired, consider reuploading.`)
}
let invoiceData = {}
if (invoiceId) {
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
@ -51,8 +90,7 @@ export async function perform (args, context) {
itemActs.push({
msats: cost - boostMsats, act: 'FEE', userId: data.userId, ...invoiceData
})
} else {
data.freebie = true
data.cost = msatsToSats(cost - boostMsats)
}
const mentions = await getMentions(args, context)
@ -122,7 +160,15 @@ export async function perform (args, context) {
}
})).bio
} else {
item = await tx.item.create({ data: itemData })
try {
item = await tx.item.create({ data: itemData })
} catch (err) {
if (err.message.includes('violates exclusion constraint \\"Item_unique_time_constraint\\"')) {
const message = `you already submitted this ${itemData.title ? 'post' : 'comment'}`
throw new GqlInputError(message)
}
throw err
}
}
// store a reference to the item in the invoice
@ -153,15 +199,13 @@ export async function retry ({ invoiceId, newInvoiceId }, { tx }) {
}
export async function onPaid ({ invoice, id }, context) {
const { models, tx } = context
const { tx } = context
let item
if (invoice) {
item = await tx.item.findFirst({
where: { invoiceId: invoice.id },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } },
user: true
}
})
@ -172,8 +216,6 @@ export async function onPaid ({ invoice, id }, context) {
item = await tx.item.findUnique({
where: { id },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } },
user: true,
itemUploads: { include: { upload: true } }
}
@ -194,6 +236,13 @@ export async function onPaid ({ invoice, id }, context) {
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', ${item.id}::INTEGER), 21, true, now() + interval '5 seconds')`
if (item.boost > 0) {
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil)
VALUES ('expireBoost', jsonb_build_object('id', ${item.id}::INTEGER), 21, true,
now() + interval '30 days', now() + interval '40 days')`
}
if (item.parentId) {
// denormalize ncomments, lastCommentAt, and "weightedComments" for ancestors, and insert into reply table
await tx.$executeRaw`
@ -203,30 +252,48 @@ export async function onPaid ({ invoice, id }, context) {
JOIN users ON "Item"."userId" = users.id
WHERE "Item".id = ${item.id}::INTEGER
), ancestors AS (
SELECT "Item".*
FROM "Item", comment
WHERE "Item".path @> comment.path AND "Item".id <> comment.id
ORDER BY "Item".id
), updated_ancestors AS (
UPDATE "Item"
SET ncomments = "Item".ncomments + 1,
"lastCommentAt" = now(),
"weightedComments" = "Item"."weightedComments" +
CASE WHEN comment."userId" = "Item"."userId" THEN 0 ELSE comment.trust END
FROM comment
WHERE "Item".path @> comment.path AND "Item".id <> comment.id
"lastCommentAt" = GREATEST("Item"."lastCommentAt", comment.created_at),
"nDirectComments" = "Item"."nDirectComments" +
CASE WHEN comment."parentId" = "Item".id THEN 1 ELSE 0 END
FROM comment, ancestors
WHERE "Item".id = ancestors.id
RETURNING "Item".*
)
INSERT INTO "Reply" (created_at, updated_at, "ancestorId", "ancestorUserId", "itemId", "userId", level)
SELECT comment.created_at, comment.updated_at, ancestors.id, ancestors."userId",
comment.id, comment."userId", nlevel(comment.path) - nlevel(ancestors.path)
FROM ancestors, comment
WHERE ancestors."userId" <> comment."userId"`
notifyItemParents({ item, models }).catch(console.error)
FROM ancestors, comment`
}
}
export async function nonCriticalSideEffects ({ invoice, id }, { models }) {
const item = await models.item.findFirst({
where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } },
user: true
}
})
if (item.parentId) {
notifyItemParents({ item, models }).catch(console.error)
notifyThreadSubscribers({ models, item }).catch(console.error)
}
for (const { userId } of item.mentions) {
notifyMention({ models, item, userId }).catch(console.error)
}
for (const { refereeItem } of item.itemReferrers) {
notifyItemMention({ models, referrerItem: item, refereeItem }).catch(console.error)
}
notifyUserSubscribers({ models, item }).catch(console.error)
notifyTerritorySubscribers({ models, item }).catch(console.error)
}

View File

@ -1,24 +1,34 @@
import { USER_ID } from '@/lib/constants'
import { imageFeesInfo } from '../resolvers/image'
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { uploadFees } from '../resolvers/upload'
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { notifyItemMention, notifyMention } from '@/lib/webPush'
import { satsToMsats } from '@/lib/format'
export const anonable = false
export const supportsPessimism = true
export const supportsOptimism = false
export const anonable = true
export async function getCost ({ id, boost = 0, uploadIds }, { me, models }) {
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ id, boost = 0, uploadIds, bio }, { me, models }) {
// the only reason updating items costs anything is when it has new uploads
// or more boost
const old = await models.item.findUnique({ where: { id: parseInt(id) } })
const { totalFeesMsats } = await imageFeesInfo(uploadIds, { models, me })
return BigInt(totalFeesMsats) + satsToMsats(boost - (old.boost || 0))
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me })
const cost = BigInt(totalFeesMsats) + satsToMsats(boost - old.boost)
if (cost > 0 && old.invoiceActionState && old.invoiceActionState !== 'PAID') {
throw new Error('creation invoice not paid')
}
return cost
}
export async function perform (args, context) {
const { id, boost = 0, uploadIds = [], options: pollOptions = [], forwardUsers: itemForwards = [], invoiceId, ...data } = args
const { tx, me, models } = context
const { id, boost = 0, uploadIds = [], options: pollOptions = [], forwardUsers: itemForwards = [], ...data } = args
const { tx, me } = context
const old = await tx.item.findUnique({
where: { id: parseInt(id) },
include: {
@ -30,9 +40,10 @@ export async function perform (args, context) {
}
})
const boostMsats = satsToMsats(boost - (old.boost || 0))
const newBoost = boost - old.boost
const itemActs = []
if (boostMsats > 0) {
if (newBoost > 0) {
const boostMsats = satsToMsats(newBoost)
itemActs.push({
msats: boostMsats, act: 'BOOST', userId: me?.id || USER_ID.anon
})
@ -54,15 +65,15 @@ export async function perform (args, context) {
data: { paid: true }
})
const item = await tx.item.update({
where: { id: parseInt(id) },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } }
},
// we put boost in the where clause because we don't want to update the boost
// if it has changed concurrently
await tx.item.update({
where: { id: parseInt(id), boost: old.boost },
data: {
...data,
boost,
boost: {
increment: newBoost
},
pollOptions: {
createMany: {
data: pollOptions?.map(option => ({ option }))
@ -126,11 +137,35 @@ export async function perform (args, context) {
}
})
await tx.$executeRaw`INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true, now() + interval '5 seconds')`
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil)
VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true,
now() + interval '5 seconds', now() + interval '1 day')`
if (newBoost > 0) {
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil)
VALUES ('expireBoost', jsonb_build_object('id', ${id}::INTEGER), 21, true,
now() + interval '30 days', now() + interval '40 days')`
}
await performBotBehavior(args, context)
// ltree is unsupported in Prisma, so we have to query it manually (FUCK!)
return (await tx.$queryRaw`
SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt"
FROM "Item" WHERE id = ${parseInt(id)}::INTEGER`
)[0]
}
export async function nonCriticalSideEffects ({ invoice, id }, { models }) {
const item = await models.item.findFirst({
where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } }
}
})
// compare timestamps to only notify if mention or item referral was just created to avoid duplicates on edits
for (const { userId, createdAt } of item.mentions) {
if (item.updatedAt.getTime() !== createdAt.getTime()) continue
@ -140,12 +175,6 @@ export async function perform (args, context) {
if (item.updatedAt.getTime() !== createdAt.getTime()) continue
notifyItemMention({ models, referrerItem: item, refereeItem }).catch(console.error)
}
// ltree is unsupported in Prisma, so we have to query it manually (FUCK!)
return (await tx.$queryRaw`
SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt"
FROM "Item" WHERE id = ${parseInt(id)}::INTEGER`
)[0]
}
export async function describe ({ id, parentId }, context) {

View File

@ -0,0 +1,56 @@
import { PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants'
import { datePivot } from '@/lib/time'
const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
const MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES = 10
const MAX_PENDING_DIRECT_INVOICES_PER_USER = 100
export async function assertBelowMaxPendingInvoices (context) {
const { models, me } = context
const pendingInvoices = await models.invoice.count({
where: {
userId: me?.id ?? USER_ID.anon,
actionState: {
notIn: PAID_ACTION_TERMINAL_STATES
}
}
})
if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) {
throw new Error('You have too many pending paid actions, cancel some or wait for them to expire')
}
}
export async function assertBelowMaxPendingDirectPayments (userId, context) {
const { models, me } = context
if (me?.id !== userId) {
const pendingSenderInvoices = await models.directPayment.count({
where: {
senderId: me?.id ?? USER_ID.anon,
createdAt: {
gt: datePivot(new Date(), { minutes: -MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES })
}
}
})
if (pendingSenderInvoices >= MAX_PENDING_DIRECT_INVOICES_PER_USER) {
throw new Error('You\'ve sent too many direct payments')
}
}
if (!userId) return
const pendingReceiverInvoices = await models.directPayment.count({
where: {
receiverId: userId,
createdAt: {
gt: datePivot(new Date(), { minutes: -MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES })
}
}
})
if (pendingReceiverInvoices >= MAX_PENDING_DIRECT_INVOICES_PER_USER) {
throw new Error('Receiver has too many direct payments')
}
}

View File

@ -2,11 +2,11 @@ import { USER_ID } from '@/lib/constants'
import { deleteReminders, getDeleteAt, getRemindAt } from '@/lib/item'
import { parseInternalLinks } from '@/lib/url'
export async function getMentions ({ text }, { me, models }) {
export async function getMentions ({ text }, { me, tx }) {
const mentionPattern = /\B@[\w_]+/gi
const names = text.match(mentionPattern)?.map(m => m.slice(1))
if (names?.length > 0) {
const users = await models.user.findMany({
const users = await tx.user.findMany({
where: {
name: {
in: names
@ -21,7 +21,7 @@ export async function getMentions ({ text }, { me, models }) {
return []
}
export const getItemMentions = async ({ text }, { me, models }) => {
export const getItemMentions = async ({ text }, { me, tx }) => {
const linkPattern = new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/\\d+[a-zA-Z0-9/?=]*`, 'gi')
const refs = text.match(linkPattern)?.map(m => {
try {
@ -33,7 +33,7 @@ export const getItemMentions = async ({ text }, { me, models }) => {
}).filter(r => !!r)
if (refs?.length > 0) {
const referee = await models.item.findMany({
const referee = await tx.item.findMany({
where: {
id: { in: refs },
userId: { not: me?.id || USER_ID.anon }
@ -60,23 +60,23 @@ export async function performBotBehavior ({ text, id }, { me, tx }) {
const deleteAt = getDeleteAt(text)
if (deleteAt) {
await tx.$queryRaw`
INSERT INTO pgboss.job (name, data, startafter, expirein)
INSERT INTO pgboss.job (name, data, startafter, keepuntil)
VALUES (
'deleteItem',
jsonb_build_object('id', ${id}::INTEGER),
${deleteAt}::TIMESTAMP WITH TIME ZONE,
${deleteAt}::TIMESTAMP WITH TIME ZONE - now() + interval '1 minute')`
${deleteAt}::TIMESTAMP WITH TIME ZONE + interval '1 minute')`
}
const remindAt = getRemindAt(text)
if (remindAt) {
await tx.$queryRaw`
INSERT INTO pgboss.job (name, data, startafter, expirein)
INSERT INTO pgboss.job (name, data, startafter, keepuntil)
VALUES (
'reminder',
jsonb_build_object('itemId', ${id}::INTEGER, 'userId', ${userId}::INTEGER),
${remindAt}::TIMESTAMP WITH TIME ZONE,
${remindAt}::TIMESTAMP WITH TIME ZONE - now() + interval '1 minute')`
${remindAt}::TIMESTAMP WITH TIME ZONE + interval '1 minute')`
await tx.reminder.create({
data: {
userId,

View File

@ -0,0 +1,27 @@
import { USER_ID } from '@/lib/constants'
export const GLOBAL_SEEDS = [USER_ID.k00b, USER_ID.ek]
export function initialTrust ({ name, userId }) {
const results = GLOBAL_SEEDS.map(id => ({
subName: name,
userId: id,
zapPostTrust: 1,
subZapPostTrust: 1,
zapCommentTrust: 1,
subZapCommentTrust: 1
}))
if (!GLOBAL_SEEDS.includes(userId)) {
results.push({
subName: name,
userId,
zapPostTrust: 0,
subZapPostTrust: 1,
zapCommentTrust: 0,
subZapCommentTrust: 1
})
}
return results
}

View File

@ -1,8 +1,13 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
export const anonable = false
export const supportsPessimism = true
export const supportsOptimism = true
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
]
export async function getCost ({ id }, { me, models }) {
const pollOption = await models.pollOption.findUnique({

84
api/paidAction/receive.js Normal file
View File

@ -0,0 +1,84 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { toPositiveBigInt, numWithUnits, msatsToSats, satsToMsats } from '@/lib/format'
import { notifyDeposit } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.P2P,
PAID_ACTION_PAYMENT_METHODS.DIRECT
]
export async function getCost ({ msats }) {
return toPositiveBigInt(msats)
}
export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) {
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && !me?.directReceive) return null
const wallets = await getInvoiceableWallets(me.id, { models })
if (wallets.length === 0) {
return null
}
if (cost < satsToMsats(me.receiveCreditsBelowSats)) {
return null
}
return me.id
}
export async function getSybilFeePercent () {
return 10n
}
export async function perform ({
invoiceId,
comment,
lud18Data,
noteStr
}, { me, tx }) {
return await tx.invoice.update({
where: { id: invoiceId },
data: {
comment,
lud18Data,
...(noteStr ? { desc: noteStr } : {})
},
include: { invoiceForward: true }
})
}
export async function describe ({ description }, { me, cost, paymentMethod, sybilFeePercent }) {
const fee = paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P
? cost * BigInt(sybilFeePercent) / 100n
: 0n
return description ?? `SN: ${me?.name ?? ''} receives ${numWithUnits(msatsToSats(cost - fee))}`
}
export async function onPaid ({ invoice }, { tx }) {
if (!invoice) {
throw new Error('invoice is required')
}
// P2P lnurlp does not need to update the user's balance
if (invoice?.invoiceForward) return
await tx.user.update({
where: { id: invoice.userId },
data: {
mcredits: {
increment: invoice.msatsReceived
}
}
})
}
export async function nonCriticalSideEffects ({ invoice }, { models }) {
await notifyDeposit(invoice.userId, invoice)
await models.$executeRaw`
INSERT INTO pgboss.job (name, data)
VALUES ('nip57', jsonb_build_object('hash', ${invoice.hash}))`
}

View File

@ -1,10 +1,14 @@
import { TERRITORY_PERIOD_COST } from '@/lib/constants'
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory'
export const anonable = false
export const supportsPessimism = true
export const supportsOptimism = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ name }, { models }) {
const sub = await models.sub.findUnique({

View File

@ -1,9 +1,15 @@
import { TERRITORY_PERIOD_COST } from '@/lib/constants'
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory'
import { initialTrust } from './lib/territory'
export const anonable = false
export const supportsPessimism = true
export const supportsOptimism = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ billingType }) {
return satsToMsats(TERRITORY_PERIOD_COST(billingType))
@ -15,7 +21,7 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
const billedLastAt = new Date()
const billPaidUntil = nextBilling(billedLastAt, billingType)
return await tx.sub.create({
const sub = await tx.sub.create({
data: {
...data,
billedLastAt,
@ -37,6 +43,12 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
}
}
})
await tx.userSubTrust.createMany({
data: initialTrust({ name: sub.name, userId: sub.userId })
})
return sub
}
export async function describe ({ name }) {

View File

@ -1,10 +1,15 @@
import { TERRITORY_PERIOD_COST } from '@/lib/constants'
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory'
import { initialTrust } from './lib/territory'
export const anonable = false
export const supportsPessimism = true
export const supportsOptimism = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ billingType }) {
return satsToMsats(TERRITORY_PERIOD_COST(billingType))
@ -32,6 +37,7 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
if (sub.userId !== me.id) {
await tx.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: me.id } })
await tx.subSubscription.delete({ where: { userId_subName: { userId: sub.userId, subName: name } } })
}
await tx.subAct.create({
@ -43,7 +49,24 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
}
})
return await tx.sub.update({
await tx.subSubscription.upsert({
where: {
userId_subName: {
userId: me.id,
subName: name
}
},
update: {
userId: me.id,
subName: name
},
create: {
userId: me.id,
subName: name
}
})
const updatedSub = await tx.sub.update({
data,
// optimistic concurrency control
// make sure none of the relevant fields have changed since we fetched the sub
@ -54,6 +77,12 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
}
}
})
await tx.userSubTrust.createMany({
data: initialTrust({ name: updatedSub.name, userId: updatedSub.userId })
})
return updatedSub
}
export async function describe ({ name }, context) {

View File

@ -1,11 +1,15 @@
import { TERRITORY_PERIOD_COST } from '@/lib/constants'
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { proratedBillingCost } from '@/lib/territory'
import { datePivot } from '@/lib/time'
export const anonable = false
export const supportsPessimism = true
export const supportsOptimism = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ oldName, billingType }, { models }) {
const oldSub = await models.sub.findUnique({

View File

@ -1,17 +1,63 @@
import { USER_ID } from '@/lib/constants'
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { notifyZapped } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server'
import { Prisma } from '@prisma/client'
export const anonable = true
export const supportsPessimism = true
export const supportsOptimism = true
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.P2P,
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ sats }) {
return satsToMsats(sats)
}
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) {
const feeMsats = cost / BigInt(10) // 10% fee
export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models, me, cost }) {
// if the zap is dust, or if me doesn't have a send wallet but has enough sats/credits to pay for it
// then we don't invoice the peer
if (sats < me?.sendCreditsBelowSats ||
(me && !hasSendWallet && (me.mcredits >= cost || me.msats >= cost))) {
return null
}
const item = await models.item.findUnique({
where: { id: parseInt(id) },
include: {
itemForwards: true,
user: true
}
})
// bios don't get sats
if (item.bio) {
return null
}
const wallets = await getInvoiceableWallets(item.userId, { models })
// request peer invoice if they have an attached wallet and have not forwarded the item
// and the receiver doesn't want to receive credits
if (wallets.length > 0 &&
item.itemForwards.length === 0 &&
sats >= item.user.receiveCreditsBelowSats) {
return item.userId
}
return null
}
export async function getSybilFeePercent () {
return 30n
}
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, sybilFeePercent, tx }) {
const feeMsats = cost * sybilFeePercent / 100n
const zapMsats = cost - feeMsats
itemId = parseInt(itemId)
@ -47,7 +93,7 @@ export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) {
return { id, sats: msatsToSats(cost), act: 'TIP', path }
}
export async function onPaid ({ invoice, actIds }, { models, tx }) {
export async function onPaid ({ invoice, actIds }, { tx }) {
let acts
if (invoice) {
await tx.itemAct.updateMany({
@ -68,34 +114,58 @@ export async function onPaid ({ invoice, actIds }, { models, tx }) {
const sats = msatsToSats(msats)
const itemAct = acts.find(act => act.act === 'TIP')
// give user and all forwards the sats
await tx.$executeRaw`
WITH forwardees AS (
SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS msats
FROM "ItemForward"
WHERE "itemId" = ${itemAct.itemId}::INTEGER
), total_forwarded AS (
SELECT COALESCE(SUM(msats), 0) as msats
FROM forwardees
), forward AS (
if (invoice?.invoiceForward) {
// only the op got sats and we need to add it to their stackedMsats
// because the sats were p2p
await tx.user.update({
where: { id: itemAct.item.userId },
data: { stackedMsats: { increment: itemAct.msats } }
})
} else {
// splits only use mcredits
await tx.$executeRaw`
WITH forwardees AS (
SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS mcredits
FROM "ItemForward"
WHERE "itemId" = ${itemAct.itemId}::INTEGER
), total_forwarded AS (
SELECT COALESCE(SUM(mcredits), 0) as mcredits
FROM forwardees
), recipients AS (
SELECT "userId", mcredits FROM forwardees
UNION
SELECT ${itemAct.item.userId}::INTEGER as "userId",
${itemAct.msats}::BIGINT - (SELECT mcredits FROM total_forwarded)::BIGINT as mcredits
ORDER BY "userId" ASC -- order to prevent deadlocks
)
UPDATE users
SET
msats = users.msats + forwardees.msats,
"stackedMsats" = users."stackedMsats" + forwardees.msats
FROM forwardees
WHERE users.id = forwardees."userId"
)
UPDATE users
SET
msats = msats + ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT,
"stackedMsats" = "stackedMsats" + ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT
WHERE id = ${itemAct.item.userId}::INTEGER`
mcredits = users.mcredits + recipients.mcredits,
"stackedMsats" = users."stackedMsats" + recipients.mcredits,
"stackedMcredits" = users."stackedMcredits" + recipients.mcredits
FROM recipients
WHERE users.id = recipients."userId"`
}
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
const [item] = await tx.$queryRaw`
WITH zapper AS (
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER
await tx.$queryRaw`
WITH territory AS (
SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId"
WHERE i.id = ${itemAct.itemId}::INTEGER
), zapper AS (
SELECT
COALESCE(${itemAct.item.parentId
? Prisma.sql`"zapCommentTrust"`
: Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust",
COALESCE(${itemAct.item.parentId
? Prisma.sql`"subZapCommentTrust"`
: Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust"
FROM territory
LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName"
AND ust."userId" = ${itemAct.userId}::INTEGER
), zap AS (
INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats")
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
@ -103,16 +173,30 @@ export async function onPaid ({ invoice, actIds }, { models, tx }) {
SET "zapSats" = "ItemUserAgg"."zapSats" + ${sats}::INTEGER, updated_at = now()
RETURNING ("zapSats" = ${sats}::INTEGER)::INTEGER as first_vote,
LOG("zapSats" / GREATEST("zapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
), item_zapped AS (
UPDATE "Item"
SET
"weightedVotes" = "weightedVotes" + zapper."zapTrust" * zap.log_sats,
"subWeightedVotes" = "subWeightedVotes" + zapper."subZapTrust" * zap.log_sats,
upvotes = upvotes + zap.first_vote,
msats = "Item".msats + ${msats}::BIGINT,
mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT,
"lastZapAt" = now()
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER
RETURNING "Item".*, zapper."zapTrust" * zap.log_sats as "weightedVote"
), ancestors AS (
SELECT "Item".*
FROM "Item", item_zapped
WHERE "Item".path @> item_zapped.path AND "Item".id <> item_zapped.id
ORDER BY "Item".id
)
UPDATE "Item"
SET
"weightedVotes" = "weightedVotes" + (zapper.trust * zap.log_sats),
upvotes = upvotes + zap.first_vote,
msats = "Item".msats + ${msats}::BIGINT,
"lastZapAt" = now()
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER
RETURNING "Item".*`
SET "weightedComments" = "Item"."weightedComments" + item_zapped."weightedVote",
"commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT,
"commentMcredits" = "Item"."commentMcredits" + ${invoice?.invoiceForward ? 0n : msats}::BIGINT
FROM item_zapped, ancestors
WHERE "Item".id = ancestors.id`
// record potential bounty payment
// NOTE: we are at least guaranteed that we see the update "ItemUserAgg" from our tx so we can trust
@ -132,18 +216,24 @@ export async function onPaid ({ invoice, actIds }, { models, tx }) {
SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL)
FROM bounty
WHERE "Item".id = bounty.id AND bounty.paid`
}
// update commentMsats on ancestors
await tx.$executeRaw`
WITH zapped AS (
SELECT * FROM "Item" WHERE id = ${itemAct.itemId}::INTEGER
)
UPDATE "Item"
SET "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT
FROM zapped
WHERE "Item".path @> zapped.path AND "Item".id <> zapped.id`
notifyZapped({ models, item }).catch(console.error)
export async function nonCriticalSideEffects ({ invoice, actIds }, { models }) {
const itemAct = await models.itemAct.findFirst({
where: invoice ? { invoiceId: invoice.id } : { id: { in: actIds } },
include: { item: true }
})
// avoid duplicate notifications with the same zap amount
// by checking if there are any other pending acts on the item
const pendingActs = await models.itemAct.count({
where: {
itemId: itemAct.itemId,
createdAt: {
gt: itemAct.createdAt
}
}
})
if (pendingActs === 0) notifyZapped({ models, item: itemAct.item }).catch(console.error)
}
export async function onFail ({ invoice }, { tx }) {

64
api/payingAction/index.js Normal file
View File

@ -0,0 +1,64 @@
import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
import { msatsToSats, satsToMsats, toPositiveBigInt } from '@/lib/format'
import { Prisma } from '@prisma/client'
import { parsePaymentRequest, payViaPaymentRequest } from 'ln-service'
// paying actions are completely distinct from paid actions
// and there's only one paying action: send
// ... still we want the api to at least be similar
export default async function performPayingAction ({ bolt11, maxFee, walletId }, { me, models, lnd }) {
try {
console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, walletId)
if (!me) {
throw new Error('You must be logged in to perform this action')
}
const decoded = await parsePaymentRequest({ request: bolt11 })
const cost = toPositiveBigInt(toPositiveBigInt(decoded.mtokens) + satsToMsats(maxFee))
console.log('cost', cost)
const withdrawal = await models.$transaction(async tx => {
await tx.user.update({
where: {
id: me.id
},
data: { msats: { decrement: cost } }
})
return await tx.withdrawl.create({
data: {
hash: decoded.id,
bolt11,
msatsPaying: toPositiveBigInt(decoded.mtokens),
msatsFeePaying: satsToMsats(maxFee),
userId: me.id,
walletId,
autoWithdraw: !!walletId
}
})
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
payViaPaymentRequest({
lnd,
request: withdrawal.bolt11,
max_fee: msatsToSats(withdrawal.msatsFeePaying),
pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS,
confidence: LND_PATHFINDING_TIME_PREF_PPM
}).catch(console.error)
return withdrawal
} catch (e) {
if (e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
throw new Error('insufficient funds')
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new Error('you cannot withdraw to the same invoice twice')
}
console.error('performPayingAction failed', e)
throw e
} finally {
console.groupEnd()
}
}

View File

@ -1,3 +1,5 @@
import { SN_ADMIN_IDS } from '@/lib/constants'
export default {
Query: {
snl: async (parent, _, { models }) => {
@ -7,7 +9,7 @@ export default {
},
Mutation: {
onAirToggle: async (parent, _, { models, me }) => {
if (me.id !== 616) {
if (!me || !SN_ADMIN_IDS.includes(me.id)) {
throw new Error('not an admin')
}
const { id, live } = await models.snl.findFirst()

View File

@ -1,7 +1,7 @@
import { GraphQLError } from 'graphql'
import { GqlAuthorizationError } from '@/lib/error'
export default function assertApiKeyNotPermitted ({ me }) {
if (me?.apiKey === true) {
throw new GraphQLError('this operation is not allowed to be performed via API Key', { extensions: { code: 'FORBIDDEN' } })
throw new GqlAuthorizationError('this operation is not allowed to be performed via API Key')
}
}

View File

@ -1,37 +1,27 @@
import lndService from 'ln-service'
import lnd from '@/api/lnd'
import { isServiceEnabled } from '@/lib/sndev'
import { cachedFetcher } from '@/lib/fetch'
import { getHeight } from 'ln-service'
const cache = new Map()
const expiresIn = 1000 * 30 // 30 seconds in milliseconds
async function fetchBlockHeight () {
let blockHeight = 0
const getBlockHeight = cachedFetcher(async function fetchBlockHeight ({ lnd }) {
try {
const height = await lndService.getHeight({ lnd })
blockHeight = height.current_block_height
const { current_block_height: height } = await getHeight({ lnd })
return height
} catch (err) {
console.error('fetchBlockHeight', err)
console.error('getBlockHeight', err)
return 0
}
cache.set('block', { height: blockHeight, createdAt: Date.now() })
return blockHeight
}
async function getBlockHeight () {
if (cache.has('block')) {
const { height, createdAt } = cache.get('block')
const expired = createdAt + expiresIn < Date.now()
if (expired) fetchBlockHeight().catch(console.error) // update cache
return height // serve stale block height (this on the SSR critical path)
} else {
fetchBlockHeight().catch(console.error)
}
return 0
}
}, {
maxSize: 1,
cacheExpiry: 60 * 1000, // 1 minute
forceRefreshThreshold: 0,
keyGenerator: () => 'getBlockHeight'
})
export default {
Query: {
blockHeight: async (parent, opts, ctx) => {
return await getBlockHeight()
blockHeight: async (parent, opts, { lnd }) => {
if (!isServiceEnabled('payments')) return 0
return await getBlockHeight({ lnd }) || 0
}
}
}

View File

@ -1,36 +1,26 @@
const cache = new Map()
const expiresIn = 1000 * 30 // 30 seconds in milliseconds
import { cachedFetcher } from '@/lib/fetch'
async function fetchChainFeeRate () {
const getChainFeeRate = cachedFetcher(async function fetchChainFeeRate () {
const url = 'https://mempool.space/api/v1/fees/recommended'
const chainFee = await fetch(url)
.then((res) => res.json())
.then((body) => body.hourFee)
.catch((err) => {
console.error('fetchChainFee', err)
return 0
})
cache.set('fee', { fee: chainFee, createdAt: Date.now() })
return chainFee
}
async function getChainFeeRate () {
if (cache.has('fee')) {
const { fee, createdAt } = cache.get('fee')
const expired = createdAt + expiresIn < Date.now()
if (expired) fetchChainFeeRate().catch(console.error) // update cache
return fee
} else {
fetchChainFeeRate().catch(console.error)
try {
const res = await fetch(url)
const body = await res.json()
return body.hourFee
} catch (err) {
console.error('fetchChainFee', err)
return 0
}
return 0
}
}, {
maxSize: 1,
cacheExpiry: 60 * 1000, // 1 minute
forceRefreshThreshold: 0, // never force refresh
keyGenerator: () => 'getChainFeeRate'
})
export default {
Query: {
chainFee: async (parent, opts, ctx) => {
return await getChainFeeRate()
return await getChainFeeRate() || 0
}
}
}

View File

@ -121,6 +121,39 @@ export default {
FROM ${viewGroup(range, 'stacking_growth')}
GROUP BY time
ORDER BY time ASC`, ...range)
},
itemGrowthSubs: async (parent, { when, to, from, sub }, { models }) => {
const range = whenRange(when, from, to)
const subExists = await models.sub.findUnique({ where: { name: sub } })
if (!subExists) throw new Error('Sub not found')
return await models.$queryRawUnsafe(`
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time, json_build_array(
json_build_object('name', 'posts', 'value', coalesce(sum(posts),0)),
json_build_object('name', 'comments', 'value', coalesce(sum(comments),0))
) AS data
FROM ${viewGroup(range, 'sub_stats')}
WHERE sub_name = $3
GROUP BY time
ORDER BY time ASC`, ...range, sub)
},
revenueGrowthSubs: async (parent, { when, to, from, sub }, { models }) => {
const range = whenRange(when, from, to)
const subExists = await models.sub.findUnique({ where: { name: sub } })
if (!subExists) throw new Error('Sub not found')
return await models.$queryRawUnsafe(`
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time, json_build_array(
json_build_object('name', 'revenue', 'value', coalesce(sum(msats_revenue/1000),0)),
json_build_object('name', 'stacking', 'value', coalesce(sum(msats_stacked/1000),0)),
json_build_object('name', 'spending', 'value', coalesce(sum(msats_spent/1000),0))
) AS data
FROM ${viewGroup(range, 'sub_stats')}
WHERE sub_name = $3
GROUP BY time
ORDER BY time ASC`, ...range, sub)
}
}
}

View File

@ -1,25 +0,0 @@
import { USER_ID, AWS_S3_URL_REGEXP } from '@/lib/constants'
import { msatsToSats } from '@/lib/format'
export default {
Query: {
imageFeesInfo: async (parent, { s3Keys }, { models, me }) => {
return imageFeesInfo(s3Keys, { models, me })
}
}
}
export function uploadIdsFromText (text, { models }) {
if (!text) return []
return [...new Set([...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])))]
}
export async function imageFeesInfo (s3Keys, { models, me }) {
// returns info object in this format:
// { bytes24h: int, bytesUnpaid: int, nUnpaid: int, imageFeeMsats: BigInt }
const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : USER_ID.anon, s3Keys)
const imageFee = msatsToSats(info.imageFeeMsats)
const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
const totalFees = msatsToSats(totalFeesMsats)
return { ...info, imageFee, totalFees, totalFeesMsats }
}

View File

@ -16,10 +16,10 @@ import { GraphQLJSONObject as JSONObject } from 'graphql-type-json'
import admin from './admin'
import blockHeight from './blockHeight'
import chainFee from './chainFee'
import image from './image'
import { GraphQLScalarType, Kind } from 'graphql'
import { createIntScalar } from 'graphql-scalar'
import paidAction from './paidAction'
import vault from './vault'
const date = new GraphQLScalarType({
name: 'Date',
@ -56,4 +56,4 @@ const limit = createIntScalar({
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
image, { JSONObject }, { Date: date }, { Limit: limit }, paidAction]
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]

View File

@ -1,15 +1,15 @@
import { GraphQLError } from 'graphql'
import { inviteSchema, ssValidate } from '@/lib/validate'
import { inviteSchema, validateSchema } from '@/lib/validate'
import { msatsToSats } from '@/lib/format'
import assertApiKeyNotPermitted from './apiKey'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { Prisma } from '@prisma/client'
export default {
Query: {
invites: async (parent, args, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new GqlAuthenticationError()
}
return await models.invite.findMany({
where: {
userId: me.id
@ -29,27 +29,48 @@ export default {
},
Mutation: {
createInvite: async (parent, { gift, limit }, { me, models }) => {
createInvite: async (parent, { id, gift, limit, description }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new GqlAuthenticationError()
}
assertApiKeyNotPermitted({ me })
await ssValidate(inviteSchema, { gift, limit })
return await models.invite.create({
data: { gift, limit, userId: me.id }
})
await validateSchema(inviteSchema, { id, gift, limit, description })
try {
return await models.invite.create({
data: {
id,
gift,
limit,
userId: me.id,
description
}
})
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002' && error.meta.target.includes('id')) {
throw new GqlInputError('an invite with this code already exists')
}
}
throw error
}
},
revokeInvite: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new GqlAuthenticationError()
}
return await models.invite.update({
where: { id },
data: { revoked: true }
})
try {
return await models.invite.update({
where: { id, userId: me.id },
data: { revoked: true }
})
} catch (err) {
if (err.code === 'P2025') {
throw new GqlInputError('invite not found')
}
throw err
}
}
},
@ -62,7 +83,10 @@ export default {
},
poor: async (invite, args, { me, models }) => {
const user = await models.user.findUnique({ where: { id: invite.userId } })
return msatsToSats(user.msats) < invite.gift
return msatsToSats(user.msats) < invite.gift && msatsToSats(user.mcredits) < invite.gift
},
description: (invite, args, { me }) => {
return invite.userId === me?.id ? invite.description : undefined
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
import { randomBytes } from 'crypto'
import { bech32 } from 'bech32'
import { GraphQLError } from 'graphql'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
import { GqlAuthenticationError } from '@/lib/error'
function encodedUrl (iurl, tag, k1) {
const url = new URL(iurl)
@ -35,7 +35,7 @@ export default {
await assertGofacYourself({ models, headers })
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
assertApiKeyNotPermitted({ me })

View File

@ -1,4 +1,4 @@
import { GraphQLError } from 'graphql'
import { GqlInputError } from '@/lib/error'
export default {
Query: {
@ -11,7 +11,7 @@ export default {
Mutation: {
createMessage: async (parent, { text }, { me, models }) => {
if (!text) {
throw new GraphQLError('Must have text', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('must have text')
}
return await models.message.create({

View File

@ -1,17 +1,18 @@
import { GraphQLError } from 'graphql'
import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor'
import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item'
import { getInvoice, getWithdrawl } from './wallet'
import { pushSubscriptionSchema, ssValidate } from '@/lib/validate'
import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
import { replyToSubscription } from '@/lib/webPush'
import { getSub } from './sub'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
export default {
Query: {
notifications: async (parent, { cursor, inc }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
const meFull = await models.user.findUnique({ where: { id: me.id } })
@ -179,17 +180,6 @@ export default {
)`
)
queries.push(
`(SELECT "Item".id::text, "Item"."statusUpdatedAt" AS "sortTime", NULL as "earnedSats",
'JobChanged' AS type
FROM "Item"
WHERE "Item"."userId" = $1
AND "maxBid" IS NOT NULL
AND "statusUpdatedAt" < $2 AND "statusUpdatedAt" <> created_at
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
// territory transfers
queries.push(
`(SELECT "TerritoryTransfer".id::text, "TerritoryTransfer"."created_at" AS "sortTime", NULL as "earnedSats",
@ -228,14 +218,20 @@ export default {
if (meFull.noteDeposits) {
queries.push(
`(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime", FLOOR("msatsReceived" / 1000) as "earnedSats",
`(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime",
FLOOR("Invoice"."msatsReceived" / 1000) as "earnedSats",
'InvoicePaid' AS type
FROM "Invoice"
WHERE "Invoice"."userId" = $1
AND "confirmedAt" IS NOT NULL
AND "isHeld" IS NULL
AND "actionState" IS NULL
AND created_at < $2
AND "Invoice"."confirmedAt" IS NOT NULL
AND "Invoice"."created_at" < $2
AND (
("Invoice"."isHeld" IS NULL AND "Invoice"."actionType" IS NULL)
OR (
"Invoice"."actionType" = 'RECEIVE'
AND "Invoice"."actionState" = 'PAID'
)
)
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
@ -243,12 +239,17 @@ export default {
if (meFull.noteWithdrawals) {
queries.push(
`(SELECT "Withdrawl".id::text, "Withdrawl".created_at AS "sortTime", FLOOR("msatsPaid" / 1000) as "earnedSats",
`(SELECT "Withdrawl".id::text, MAX(COALESCE("Invoice"."confirmedAt", "Withdrawl".created_at)) AS "sortTime",
FLOOR(MAX("Withdrawl"."msatsPaid" / 1000)) as "earnedSats",
'WithdrawlPaid' AS type
FROM "Withdrawl"
LEFT JOIN "InvoiceForward" ON "InvoiceForward"."withdrawlId" = "Withdrawl".id
LEFT JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id
WHERE "Withdrawl"."userId" = $1
AND status = 'CONFIRMED'
AND created_at < $2
AND "Withdrawl".status = 'CONFIRMED'
AND "Withdrawl".created_at < $2
AND "InvoiceForward"."id" IS NULL
GROUP BY "Withdrawl".id
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
@ -345,16 +346,31 @@ export default {
)
queries.push(
`(SELECT "Invoice".id::text, "Invoice"."updated_at" AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type
`(SELECT "Invoice".id::text,
CASE
WHEN
"Invoice"."paymentAttempt" < ${WALLET_MAX_RETRIES}
AND "Invoice"."userCancel" = false
AND "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
THEN "Invoice"."cancelledAt" + interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
ELSE "Invoice"."updated_at"
END AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type
FROM "Invoice"
WHERE "Invoice"."userId" = $1
AND "Invoice"."updated_at" < $2
AND "Invoice"."actionState" = 'FAILED'
AND (
-- this is the inverse of the filter for automated retries
"Invoice"."paymentAttempt" >= ${WALLET_MAX_RETRIES}
OR "Invoice"."userCancel" = true
OR "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
)
AND (
"Invoice"."actionType" = 'ITEM_CREATE' OR
"Invoice"."actionType" = 'ZAP' OR
"Invoice"."actionType" = 'DOWN_ZAP' OR
"Invoice"."actionType" = 'POLL_VOTE'
"Invoice"."actionType" = 'POLL_VOTE' OR
"Invoice"."actionType" = 'BOOST'
)
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
@ -382,10 +398,10 @@ export default {
Mutation: {
savePushSubscription: async (parent, { endpoint, p256dh, auth, oldEndpoint }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
await ssValidate(pushSubscriptionSchema, { endpoint, p256dh, auth })
await validateSchema(pushSubscriptionSchema, { endpoint, p256dh, auth })
let dbPushSubscription
if (oldEndpoint) {
@ -406,12 +422,12 @@ export default {
},
deletePushSubscription: async (parent, { endpoint }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
const subscription = await models.pushSubscription.findFirst({ where: { endpoint, userId: Number(me.id) } })
if (!subscription) {
throw new GraphQLError('endpoint not found', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('endpoint not found')
}
const deletedSubscription = await models.pushSubscription.delete({ where: { id: subscription.id } })
console.log(`[webPush] deleted subscription ${deletedSubscription.id} of user ${deletedSubscription.userId} due to client request`)
@ -466,6 +482,24 @@ export default {
return subAct.subName
}
},
ReferralSource: {
__resolveType: async (n, args, { models }) => n.type
},
Referral: {
source: async (n, args, { models, me }) => {
// retrieve the referee landing record
const referral = await models.oneDayReferral.findFirst({ where: { refereeId: Number(n.id), landing: true } })
if (!referral) return null // if no landing record, it will return a generic referral
switch (referral.type) {
case 'POST':
case 'COMMENT': return { ...await getItem(n, { id: referral.typeId }, { models, me }), type: 'Item' }
case 'TERRITORY': return { ...await getSub(n, { name: referral.typeId }, { models, me }), type: 'Sub' }
case 'PROFILE': return { ...await models.user.findUnique({ where: { id: Number(referral.typeId) }, select: { name: true } }), type: 'User' }
default: return null
}
}
},
Streak: {
days: async (n, args, { models }) => {
const res = await models.$queryRaw`
@ -475,6 +509,14 @@ export default {
`
return res.length ? res[0].days : null
},
type: async (n, args, { models }) => {
const res = await models.$queryRaw`
SELECT "type"
FROM "Streak"
WHERE id = ${Number(n.id)}
`
return res.length ? res[0].type : null
}
},
Earn: {

View File

@ -1,13 +1,11 @@
import { GraphQLError } from 'graphql'
import { GqlAuthorizationError } from '@/lib/error'
// this function makes america more secure apparently
export default async function assertGofacYourself ({ models, headers, ip }) {
const country = await gOFACYourself({ models, headers, ip })
if (!country) return
throw new GraphQLError(
`Your IP address is in ${country}. We cannot provide financial services to residents of ${country}.`,
{ extensions: { code: 'FORBIDDEN' } })
throw new GqlAuthorizationError(`Your IP address is in ${country}. We cannot provide financial services to residents of ${country}.`)
}
export async function gOFACYourself ({ models, headers = {}, ip }) {

View File

@ -1,5 +1,5 @@
import { retryPaidAction } from '../paidAction'
import { USER_ID } from '@/lib/constants'
import { USER_ID, WALLET_MAX_RETRIES, WALLET_RETRY_TIMEOUT_MS } from '@/lib/constants'
function paidActionType (actionType) {
switch (actionType) {
@ -8,6 +8,7 @@ function paidActionType (actionType) {
return 'ItemPaidAction'
case 'ZAP':
case 'DOWN_ZAP':
case 'BOOST':
return 'ItemActPaidAction'
case 'TERRITORY_CREATE':
case 'TERRITORY_UPDATE':
@ -18,6 +19,10 @@ function paidActionType (actionType) {
return 'DonatePaidAction'
case 'POLL_VOTE':
return 'PollVotePaidAction'
case 'RECEIVE':
return 'ReceivePaidAction'
case 'BUY_CREDITS':
return 'BuyCreditsPaidAction'
default:
throw new Error('Unknown action type')
}
@ -26,7 +31,12 @@ function paidActionType (actionType) {
export default {
Query: {
paidAction: async (parent, { invoiceId }, { models, me }) => {
const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me?.id ?? USER_ID.anon } })
const invoice = await models.invoice.findUnique({
where: {
id: invoiceId,
userId: me?.id ?? USER_ID.anon
}
})
if (!invoice) {
throw new Error('Invoice not found')
}
@ -35,22 +45,37 @@ export default {
type: paidActionType(invoice.actionType),
invoice,
result: invoice.actionResult,
paymentMethod: invoice.preimage ? 'PESSIMISTIC' : 'OPTIMISTIC'
paymentMethod: invoice.actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC'
}
}
},
Mutation: {
retryPaidAction: async (parent, { invoiceId }, { models, me, lnd }) => {
retryPaidAction: async (parent, { invoiceId, newAttempt }, { models, me, lnd }) => {
if (!me) {
throw new Error('You must be logged in')
}
const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me.id } })
// make sure only one client at a time can retry by acquiring a lock that expires
const [invoice] = await models.$queryRaw`
UPDATE "Invoice"
SET "retryPendingSince" = now()
WHERE
id = ${invoiceId} AND
"userId" = ${me.id} AND
"actionState" = 'FAILED' AND
("retryPendingSince" IS NULL OR "retryPendingSince" < now() - ${`${WALLET_RETRY_TIMEOUT_MS} milliseconds`}::interval)
RETURNING *`
if (!invoice) {
throw new Error('Invoice not found')
throw new Error('Invoice not found or retry pending')
}
const result = await retryPaidAction(invoice.actionType, { invoiceId }, { models, me, lnd })
// do we want to retry a payment from the beginning with all sender and receiver wallets?
const paymentAttempt = newAttempt ? invoice.paymentAttempt + 1 : invoice.paymentAttempt
if (paymentAttempt > WALLET_MAX_RETRIES) {
throw new Error('Payment has been retried too many times')
}
const result = await retryPaidAction(invoice.actionType, { invoice }, { paymentAttempt, models, me, lnd })
return {
...result,

View File

@ -1,36 +1,27 @@
const cache = new Map()
const expiresIn = 30000 // in milliseconds
import { SUPPORTED_CURRENCIES } from '@/lib/currency'
import { cachedFetcher } from '@/lib/fetch'
async function fetchPrice (fiat) {
const getPrice = cachedFetcher(async function fetchPrice (fiat = 'USD') {
const url = `https://api.coinbase.com/v2/prices/BTC-${fiat}/spot`
const price = await fetch(url)
.then((res) => res.json())
.then((body) => parseFloat(body.data.amount))
.catch((err) => {
console.error(err)
return -1
})
cache.set(fiat, { price, createdAt: Date.now() })
return price
}
async function getPrice (fiat) {
fiat ??= 'USD'
if (cache.has(fiat)) {
const { price, createdAt } = cache.get(fiat)
const expired = createdAt + expiresIn < Date.now()
if (expired) fetchPrice(fiat).catch(console.error) // update cache
return price // serve stale price (this on the SSR critical path)
} else {
fetchPrice(fiat).catch(console.error)
try {
const res = await fetch(url)
const body = await res.json()
return parseFloat(body.data.amount)
} catch (err) {
console.error(err)
return -1
}
return null
}
}, {
maxSize: SUPPORTED_CURRENCIES.length,
cacheExpiry: 60 * 1000, // 1 minute
forceRefreshThreshold: 0, // never force refresh
keyGenerator: (fiat = 'USD') => fiat
})
export default {
Query: {
price: async (parent, { fiatCurrency }, ctx) => {
return await getPrice(fiatCurrency)
return await getPrice(fiatCurrency) || -1
}
}
}

View File

@ -1,12 +1,12 @@
import { GraphQLError } from 'graphql'
import { timeUnitForRange, whenRange } from '@/lib/time'
import { viewGroup } from './growth'
import { GqlAuthenticationError } from '@/lib/error'
export default {
Query: {
referrals: async (parent, { when, from, to }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
const range = whenRange(when, from, to)

View File

@ -1,8 +1,8 @@
import { GraphQLError } from 'graphql'
import { amountSchema, ssValidate } from '@/lib/validate'
import { getItem } from './item'
import { amountSchema, validateSchema } from '@/lib/validate'
import { getAd, getItem } from './item'
import { topUsers } from './user'
import performPaidAction from '../paidAction'
import { GqlInputError } from '@/lib/error'
let rewardCache
@ -63,21 +63,21 @@ async function getMonthlyRewards (when, models) {
async function getRewards (when, models) {
if (when) {
if (when.length > 1) {
throw new GraphQLError('too many dates', { extensions: { code: 'BAD_USER_INPUT' } })
throw new GqlInputError('too many dates')
}
when.forEach(w => {
if (isNaN(new Date(w))) {
throw new GraphQLError('invalid date', { extensions: { code: 'BAD_USER_INPUT' } })
throw new GqlInputError('invalid date')
}
})
if (new Date(when[0]) > new Date(when[when.length - 1])) {
throw new GraphQLError('bad date range', { extensions: { code: 'BAD_USER_INPUT' } })
throw new GqlInputError('bad date range')
}
if (new Date(when[0]).getTime() > new Date('2024-03-01').getTime() && new Date(when[0]).getTime() < new Date('2024-05-02').getTime()) {
// after 3/1/2024 and until 5/1/2024, we reward monthly on the 1st
if (new Date(when[0]).getUTCDate() !== 1) {
throw new GraphQLError('invalid reward date', { extensions: { code: 'BAD_USER_INPUT' } })
throw new GqlInputError('bad reward date')
}
return await getMonthlyRewards(when, models)
@ -119,11 +119,11 @@ export default {
}
if (!when || when.length > 2) {
throw new GraphQLError('invalid date range', { extensions: { code: 'BAD_USER_INPUT' } })
throw new GqlInputError('bad date range')
}
for (const w of when) {
if (isNaN(new Date(w))) {
throw new GraphQLError('invalid date', { extensions: { code: 'BAD_USER_INPUT' } })
throw new GqlInputError('invalid date')
}
}
@ -141,6 +141,7 @@ export default {
(SELECT FLOOR("Earn".msats / 1000.0) as sats, type, rank, "typeId"
FROM "Earn"
WHERE "Earn"."userId" = ${me.id}
AND (type IS NULL OR type NOT IN ('FOREVER_REFERRAL', 'ONE_DAY_REFERRAL'))
AND date_trunc('day', "Earn".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = days_cte.day
ORDER BY "Earn".msats DESC)
) "Earn"
@ -156,18 +157,21 @@ export default {
const [{ to, from }] = await models.$queryRaw`
SELECT date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago' as from,
(date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago') + interval '1 day - 1 second' as to`
return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 100 }, { models, ...context })
return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 500 }, { models, ...context })
},
total: async (parent, args, { models }) => {
if (!parent.total) {
return 0
}
return parent.total
},
ad: async (parent, args, { me, models }) => {
return await getAd(parent, { }, { me, models })
}
},
Mutation: {
donateToRewards: async (parent, { sats }, { me, models, lnd }) => {
await ssValidate(amountSchema, { amount: sats })
await validateSchema(amountSchema, { amount: sats })
return await performPaidAction('DONATE', { sats }, { me, models, lnd })
}

View File

@ -174,7 +174,6 @@ export default {
search: async (parent, { q, cursor, sort, what, when, from: whenFrom, to: whenTo }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor)
let sitems = null
let termQueries = []
// short circuit: return empty result if either:
// 1. no query provided, or
@ -186,56 +185,120 @@ export default {
}
}
const whatArr = []
// build query in parts:
// filters: determine the universe of potential search candidates
// termQueries: queries related to the actual search terms
// functions: rank modifiers to boost by recency or popularity
const filters = []
const termQueries = []
const functions = []
// filters for item types
switch (what) {
case 'posts':
whatArr.push({ bool: { must_not: { exists: { field: 'parentId' } } } })
case 'posts': // posts only
filters.push({ bool: { must_not: { exists: { field: 'parentId' } } } })
break
case 'comments':
whatArr.push({ bool: { must: { exists: { field: 'parentId' } } } })
case 'comments': // comments only
filters.push({ bool: { must: { exists: { field: 'parentId' } } } })
break
case 'bookmarks':
if (me?.id) {
whatArr.push({ match: { bookmarkedBy: me?.id } })
filters.push({ match: { bookmarkedBy: me?.id } })
}
break
default:
break
}
// filter for active posts
filters.push(
me
? {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } }
]
}
}
: {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
}
)
// filter for time range
const whenRange = when === 'custom'
? {
gte: whenFrom,
lte: new Date(Math.min(new Date(Number(whenTo)), decodedCursor.time))
}
: {
lte: decodedCursor.time,
gte: whenToFrom(when)
}
filters.push({ range: { createdAt: whenRange } })
// filter for non negative wvotes
filters.push({ range: { wvotes: { gte: 0 } } })
// decompose the search terms
const { query: _query, quotes, nym, url, territory } = queryParts(q)
let query = _query
const isUrlSearch = url && query.length === 0 // exclusively searching for an url
const query = _query
// if search contains a url term, modify the query text
if (url) {
const isFQDN = url.startsWith('url:www.')
const domain = isFQDN ? url.slice(8) : url.slice(4)
const fqdn = `www.${domain}`
query = (isUrlSearch) ? `${domain} ${fqdn}` : `${query.trim()} ${domain}`
}
if (nym) {
whatArr.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
}
if (territory) {
whatArr.push({ match: { 'sub.name': territory.slice(1) } })
}
termQueries.push({
// all terms are matched in fields
multi_match: {
query,
type: 'best_fields',
fields: ['title^100', 'text'],
minimum_should_match: (isUrlSearch) ? 1 : '100%',
boost: 1000
const uri = url.slice(4)
let uriObj
try {
uriObj = new URL(uri)
} catch {
try {
uriObj = new URL(`https://${uri}`)
} catch {}
}
})
if (uriObj) {
termQueries.push({
wildcard: { url: `*${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}*` }
})
termQueries.push({
match: { text: `${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}` }
})
}
}
// if nym, items must contain nym
if (nym) {
filters.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
// push same requirement to termQueries to avoid empty should clause
termQueries.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
}
// if territory, item must be from territory
if (territory) {
filters.push({ match: { 'sub.name': territory.slice(1) } })
// push same requirement to termQueries to avoid empty should clause
termQueries.push({ match: { 'sub.name': territory.slice(1) } })
}
// if quoted phrases, items must contain entire phrase
for (const quote of quotes) {
whatArr.push({
termQueries.push({
multi_match: {
query: quote,
type: 'phrase',
fields: ['title', 'text']
}
})
// force the search to include the quoted phrase
filters.push({
multi_match: {
query: quote,
type: 'phrase',
@ -244,84 +307,104 @@ export default {
})
}
// if we search for an exact string only, everything must match
// so score purely on sort field
let boostMode = query ? 'multiply' : 'replace'
let sortField
let sortMod = 'log1p'
// functions for boosting search rank by recency or popularity
switch (sort) {
case 'comments':
sortField = 'ncomments'
sortMod = 'square'
functions.push({
field_value_factor: {
field: 'ncomments',
modifier: 'log1p'
}
})
break
case 'sats':
sortField = 'sats'
functions.push({
field_value_factor: {
field: 'sats',
modifier: 'log1p'
}
})
break
case 'recent':
sortField = 'createdAt'
sortMod = 'square'
boostMode = 'replace'
functions.push({
gauss: {
createdAt: {
origin: 'now',
scale: '7d',
decay: 0.5
}
}
})
break
case 'zaprank':
functions.push({
field_value_factor: {
field: 'wvotes',
modifier: 'log1p'
}
})
break
default:
sortField = 'wvotes'
sortMod = 'none'
break
}
const functions = [
{
field_value_factor: {
field: sortField,
modifier: sortMod,
factor: 1.2
}
let osQuery = {
function_score: {
query: {
bool: {
filter: filters,
should: termQueries,
minimum_should_match: termQueries.length > 0 ? 1 : 0
}
},
functions,
score_mode: 'multiply',
boost_mode: 'multiply'
}
]
if (sort === 'recent' && !isUrlSearch) {
// prioritize exact matches
termQueries.push({
multi_match: {
query,
type: 'phrase',
fields: ['title^100', 'text'],
boost: 1000
}
})
} else {
// allow fuzzy matching with partial matches
termQueries.push({
multi_match: {
query,
type: 'most_fields',
fields: ['title^100', 'text'],
fuzziness: 'AUTO',
prefix_length: 3,
minimum_should_match: (isUrlSearch) ? 1 : '60%'
}
})
functions.push({
// small bias toward posts with comments
field_value_factor: {
field: 'ncomments',
modifier: 'ln1p',
factor: 1
}
},
{
// small bias toward recent posts
field_value_factor: {
field: 'createdAt',
modifier: 'log1p',
factor: 1
}
})
}
// query for search terms
if (query.length) {
// if we have a model id and we aren't sort by recent, use neural search
if (process.env.OPENSEARCH_MODEL_ID && sort !== 'recent') {
termQueries = {
// keyword based subquery, to be used on its own or in conjunction with a neural
// search
const subquery = [
{
multi_match: {
query,
type: 'best_fields',
fields: ['title^10', 'text'],
fuzziness: 'AUTO',
minimum_should_match: 1
}
},
// all match matches higher
{
multi_match: {
query,
type: 'best_fields',
fields: ['title^10', 'text'],
minimum_should_match: '100%',
boost: 100
}
},
// phrase match matches higher
{
multi_match: {
query,
type: 'phrase',
fields: ['title^10', 'text'],
boost: 1000
}
}
]
osQuery.function_score.query.bool.should = [...termQueries, ...subquery]
osQuery.function_score.query.bool.minimum_should_match = 1
// use hybrid neural search if model id is available, otherwise use only
// keyword search
if (process.env.OPENSEARCH_MODEL_ID) {
osQuery = {
hybrid: {
queries: [
{
@ -345,32 +428,18 @@ export default {
}
}
}
]
],
filter: filters,
minimum_should_match: 1
}
},
{
bool: {
should: termQueries
}
}
osQuery
]
}
}
}
} else {
termQueries = []
}
const whenRange = when === 'custom'
? {
gte: whenFrom,
lte: new Date(Math.min(new Date(Number(whenTo)), decodedCursor.time))
}
: {
lte: decodedCursor.time,
gte: whenToFrom(when)
}
try {
sitems = await search.search({
index: process.env.OPENSEARCH_INDEX,
@ -384,45 +453,7 @@ export default {
},
from: decodedCursor.offset,
body: {
query: {
function_score: {
query: {
bool: {
must: termQueries,
filter: [
...whatArr,
me
? {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } }
]
}
}
: {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
},
{
range:
{
createdAt: whenRange
}
},
{ range: { wvotes: { gte: 0 } } }
]
}
},
functions,
boost_mode: boostMode
}
},
query: osQuery,
highlight: {
fields: {
title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
@ -458,7 +489,7 @@ export default {
${SELECT}, rank
FROM "Item"
JOIN r ON "Item".id = r.id`,
orderBy: 'ORDER BY rank ASC'
orderBy: 'ORDER BY rank ASC, msats DESC'
})).map((item, i) => {
const e = sitems.body.hits.hits[i]
item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title

View File

@ -1,76 +0,0 @@
import { GraphQLError } from 'graphql'
import retry from 'async-retry'
import Prisma from '@prisma/client'
import { msatsToSats, numWithUnits } from '@/lib/format'
import { BALANCE_LIMIT_MSATS } from '@/lib/constants'
export default async function serialize (trx, { models, lnd }) {
// wrap first argument in array if not array already
const isArray = Array.isArray(trx)
if (!isArray) trx = [trx]
// conditional queries can be added inline using && syntax
// we filter any falsy value out here
trx = trx.filter(q => !!q)
const results = await retry(async bail => {
try {
const [, ...results] = await models.$transaction(
[models.$executeRaw`SELECT ASSERT_SERIALIZED()`, ...trx],
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable })
return results
} catch (error) {
console.log(error)
// two cases where we get insufficient funds:
// 1. plpgsql function raises
// 2. constraint violation via a prisma call
// XXX prisma does not provide a way to distinguish these cases so we
// have to check the error message
if (error.message.includes('SN_INSUFFICIENT_FUNDS') ||
error.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
bail(new GraphQLError('insufficient funds', { extensions: { code: 'BAD_INPUT' } }))
}
if (error.message.includes('SN_NOT_SERIALIZABLE')) {
bail(new Error('wallet balance transaction is not serializable'))
}
if (error.message.includes('SN_CONFIRMED_WITHDRAWL_EXISTS')) {
bail(new Error('withdrawal invoice already confirmed (to withdraw again create a new invoice)'))
}
if (error.message.includes('SN_PENDING_WITHDRAWL_EXISTS')) {
bail(new Error('withdrawal invoice exists and is pending'))
}
if (error.message.includes('SN_INELIGIBLE')) {
bail(new Error('user ineligible for gift'))
}
if (error.message.includes('SN_UNSUPPORTED')) {
bail(new Error('unsupported action'))
}
if (error.message.includes('SN_DUPLICATE')) {
bail(new Error('duplicate not allowed'))
}
if (error.message.includes('SN_REVOKED_OR_EXHAUSTED')) {
bail(new Error('faucet has been revoked or is exhausted'))
}
if (error.message.includes('SN_INV_PENDING_LIMIT')) {
bail(new Error('too many pending invoices'))
}
if (error.message.includes('SN_INV_EXCEED_BALANCE')) {
bail(new Error(`pending invoices and withdrawals must not cause balance to exceed ${numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS))}`))
}
if (error.message.includes('40001') || error.code === 'P2034') {
throw new Error('wallet balance serialization failure - try again')
}
if (error.message.includes('23514') || ['P2002', 'P2003', 'P2004'].includes(error.code)) {
bail(new Error('constraint failure'))
}
bail(error)
}
}, {
minTimeout: 10,
maxTimeout: 100,
retries: 10
})
// if first argument was not an array, unwrap the result
return isArray ? results : results[0]
}

View File

@ -1,10 +1,10 @@
import { GraphQLError } from 'graphql'
import { whenRange } from '@/lib/time'
import { ssValidate, territorySchema } from '@/lib/validate'
import { validateSchema, territorySchema } from '@/lib/validate'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { viewGroup } from './growth'
import { notifyTerritoryTransfer } from '@/lib/webPush'
import performPaidAction from '../paidAction'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
export async function getSub (parent, { name }, { models, me }) {
if (!name) return null
@ -108,12 +108,12 @@ export default {
},
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models }) => {
if (!name) {
throw new GraphQLError('must supply user name', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('must supply user name')
}
const user = await models.user.findUnique({ where: { name } })
if (!user) {
throw new GraphQLError('no user has that name', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('no user has that name')
}
const decodedCursor = decodeCursor(cursor)
@ -154,10 +154,10 @@ export default {
Mutation: {
upsertSub: async (parent, { ...data }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
await ssValidate(territorySchema, data, { models, me, sub: { name: data.oldName } })
await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } })
if (data.oldName) {
return await updateSub(parent, data, { me, models, lnd })
@ -174,11 +174,11 @@ export default {
})
if (!sub) {
throw new GraphQLError('sub not found', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('sub not found')
}
if (sub.userId !== me.id) {
throw new GraphQLError('you do not own this sub', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('you do not own this sub')
}
if (sub.status === 'ACTIVE') {
@ -189,7 +189,7 @@ export default {
},
toggleMuteSub: async (parent, { name }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
const lookupData = { userId: Number(me.id), subName: name }
@ -205,7 +205,7 @@ export default {
},
toggleSubSubscription: async (sub, { name }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
const lookupData = { userId: me.id, subName: name }
@ -221,7 +221,7 @@ export default {
},
transferTerritory: async (parent, { subName, userName }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
const sub = await models.sub.findUnique({
@ -230,18 +230,18 @@ export default {
}
})
if (!sub) {
throw new GraphQLError('sub not found', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('sub not found')
}
if (sub.userId !== me.id) {
throw new GraphQLError('you do not own this sub', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('you do not own this sub')
}
const user = await models.user.findFirst({ where: { name: userName } })
if (!user) {
throw new GraphQLError('user not found', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('user not found')
}
if (user.id === me.id) {
throw new GraphQLError('cannot transfer territory to yourself', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('cannot transfer territory to yourself')
}
const [, updatedSub] = await models.$transaction([
@ -255,25 +255,25 @@ export default {
},
unarchiveTerritory: async (parent, { ...data }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
const { name } = data
await ssValidate(territorySchema, data, { models, me, sub: { name } })
await validateSchema(territorySchema, data, { models, me })
const oldSub = await models.sub.findUnique({ where: { name } })
if (!oldSub) {
throw new GraphQLError('sub not found', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('sub not found')
}
if (oldSub.status !== 'STOPPED') {
throw new GraphQLError('sub is not archived', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('sub is not archived')
}
if (oldSub.billingType === 'ONCE') {
// sanity check. this should never happen but leaving this comment here
// to stop error propagation just in case and document that this should never happen.
// #defensivecode
throw new GraphQLError('sub should not be archived', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('sub should not be archived')
}
return await performPaidAction('TERRITORY_UNARCHIVE', data, { me, models, lnd })
@ -319,7 +319,7 @@ async function createSub (parent, data, { me, models, lnd }) {
return await performPaidAction('TERRITORY_CREATE', data, { me, models, lnd })
} catch (error) {
if (error.code === 'P2002') {
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('name taken')
}
throw error
}
@ -339,14 +339,14 @@ async function updateSub (parent, { oldName, ...data }, { me, models, lnd }) {
})
if (!oldSub) {
throw new GraphQLError('sub not found', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('sub not found')
}
try {
return await performPaidAction('TERRITORY_UPDATE', { oldName, ...data }, { me, models, lnd })
} catch (error) {
if (error.code === 'P2002') {
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('name taken')
}
throw error
}

View File

@ -1,27 +1,40 @@
import { GraphQLError } from 'graphql'
import { USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '@/lib/constants'
import { USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW, AWS_S3_URL_REGEXP, AVATAR_TYPES_ALLOW } from '@/lib/constants'
import { createPresignedPost } from '@/api/s3'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { msatsToSats } from '@/lib/format'
export default {
Query: {
uploadFees: async (parent, { s3Keys }, { models, me }) => {
return uploadFees(s3Keys, { models, me })
}
},
Mutation: {
getSignedPOST: async (parent, { type, size, width, height, avatar }, { models, me }) => {
if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) {
throw new GraphQLError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`, { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError(`upload must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace(/^(image|video)\//, '')).join(', ')}`)
}
if (size > UPLOAD_SIZE_MAX) {
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX / (1024 ** 2)} megabytes`, { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError(`upload must be less than ${UPLOAD_SIZE_MAX / (1024 ** 2)} megabytes`)
}
if (avatar && size > UPLOAD_SIZE_MAX_AVATAR) {
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX_AVATAR / (1024 ** 2)} megabytes`, { extensions: { code: 'BAD_INPUT' } })
if (avatar) {
if (AVATAR_TYPES_ALLOW.indexOf(type) === -1) {
throw new GqlInputError(`avatar must be ${AVATAR_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`)
}
if (size > UPLOAD_SIZE_MAX_AVATAR) {
throw new GqlInputError(`avatar must be less than ${UPLOAD_SIZE_MAX_AVATAR / (1024 ** 2)} megabytes`)
}
}
// width and height is 0 for videos
if (width * height > IMAGE_PIXELS_MAX) {
throw new GraphQLError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`, { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`)
}
const imgParams = {
const fileParams = {
type,
size,
width,
@ -31,12 +44,27 @@ export default {
}
if (avatar) {
if (!me) throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
imgParams.paid = undefined
if (!me) throw new GqlAuthenticationError()
fileParams.paid = undefined
}
const upload = await models.upload.create({ data: { ...imgParams } })
const upload = await models.upload.create({ data: { ...fileParams } })
return createPresignedPost({ key: String(upload.id), type, size })
}
}
}
export function uploadIdsFromText (text, { models }) {
if (!text) return []
return [...new Set([...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])))]
}
export async function uploadFees (s3Keys, { models, me }) {
// returns info object in this format:
// { bytes24h: int, bytesUnpaid: int, nUnpaid: int, uploadFeesMsats: BigInt }
const [info] = await models.$queryRawUnsafe('SELECT * FROM upload_fees($1::INTEGER, $2::INTEGER[])', me ? me.id : USER_ID.anon, s3Keys)
const uploadFees = msatsToSats(info.uploadFeesMsats)
const totalFeesMsats = info.nUnpaid * Number(info.uploadFeesMsats)
const totalFees = msatsToSats(totalFeesMsats)
return { ...info, uploadFees, totalFees, totalFeesMsats }
}

View File

@ -1,16 +1,16 @@
import { readFile } from 'fs/promises'
import { join, resolve } from 'path'
import { GraphQLError } from 'graphql'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { msatsToSats } from '@/lib/format'
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate'
import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema } from '@/lib/validate'
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item'
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES } from '@/lib/constants'
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES, WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
import { viewGroup } from './growth'
import { timeUnitForRange, whenRange } from '@/lib/time'
import { datePivot, timeUnitForRange, whenRange } from '@/lib/time'
import assertApiKeyNotPermitted from './apiKey'
import { hashEmail } from '@/lib/crypto'
import { isMuted } from '@/lib/user'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
const contributors = new Set()
@ -66,11 +66,12 @@ export async function topUsers (parent, { cursor, when, by, from, to, limit = LI
case 'comments': column = 'ncomments'; break
case 'referrals': column = 'referrals'; break
case 'stacking': column = 'stacked'; break
case 'value':
default: column = 'proportion'; break
}
const users = (await models.$queryRawUnsafe(`
SELECT *
SELECT * ${column === 'proportion' ? ', proportion' : ''}
FROM
(SELECT users.*,
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
@ -125,13 +126,14 @@ export default {
},
settings: async (parent, args, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
return await models.user.findUnique({ where: { id: me.id } })
},
user: async (parent, { name }, { models }) => {
return await models.user.findUnique({ where: { name } })
user: async (parent, { id, name }, { models }) => {
if (id) id = Number(id)
return await models.user.findUnique({ where: { id, name } })
},
users: async (parent, args, { models }) =>
await models.user.findMany(),
@ -144,7 +146,7 @@ export default {
},
mySubscribedUsers: async (parent, { cursor }, { models, me }) => {
if (!me) {
throw new GraphQLError('You must be logged in to view subscribed users', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
const decodedCursor = decodeCursor(cursor)
@ -165,7 +167,7 @@ export default {
},
myMutedUsers: async (parent, { cursor }, { models, me }) => {
if (!me) {
throw new GraphQLError('You must be logged in to view muted users', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
const decodedCursor = decodeCursor(cursor)
@ -283,6 +285,7 @@ export default {
'"ThreadSubscription"."userId" = $1',
'r.created_at > $2',
'r.created_at >= "ThreadSubscription".created_at',
'r."userId" <> $1',
activeOrMine(me),
await filterClause(me, models),
muteClause(me),
@ -394,22 +397,6 @@ export default {
}
}
const job = await models.item.findFirst({
where: {
maxBid: {
not: null
},
userId: me.id,
statusUpdatedAt: {
gt: lastChecked
}
}
})
if (job && job.statusUpdatedAt > job.createdAt) {
foundNotes()
return true
}
if (user.noteEarning) {
const earn = await models.earn.findFirst({
where: {
@ -435,8 +422,16 @@ export default {
confirmedAt: {
gt: lastChecked
},
isHeld: null,
actionType: null
OR: [
{
isHeld: null,
actionType: null
},
{
actionType: 'RECEIVE',
actionState: 'PAID'
}
]
}
})
if (invoice) {
@ -450,9 +445,13 @@ export default {
where: {
userId: me.id,
status: 'CONFIRMED',
hash: {
not: null
},
updatedAt: {
gt: lastChecked
}
},
invoiceForward: { is: null }
}
})
if (wdrwl) {
@ -544,7 +543,17 @@ export default {
actionType: {
in: INVOICE_ACTION_NOTIFICATION_TYPES
},
actionState: 'FAILED'
actionState: 'FAILED',
OR: [
{
paymentAttempt: {
gte: WALLET_MAX_RETRIES
}
},
{
userCancel: true
}
]
}
})
@ -553,6 +562,31 @@ export default {
return true
}
const invoiceActionFailed2 = await models.invoice.findFirst({
where: {
userId: me.id,
updatedAt: {
gt: datePivot(lastChecked, { milliseconds: -WALLET_RETRY_BEFORE_MS })
},
actionType: {
in: INVOICE_ACTION_NOTIFICATION_TYPES
},
actionState: 'FAILED',
paymentAttempt: {
lt: WALLET_MAX_RETRIES
},
userCancel: false,
cancelledAt: {
lte: datePivot(new Date(), { milliseconds: -WALLET_RETRY_BEFORE_MS })
}
}
})
if (invoiceActionFailed2) {
foundNotes()
return true
}
// update checkedNotesAt to prevent rechecking same time period
models.user.update({
where: { id: me.id },
@ -621,29 +655,49 @@ export default {
},
Mutation: {
setName: async (parent, data, { me, models }) => {
disableFreebies: async (parent, args, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
await ssValidate(userSchema, data, { models })
// disable freebies if it hasn't been set yet
try {
await models.user.update({
where: { id: me.id, disableFreebies: null },
data: { disableFreebies: true }
})
} catch (err) {
// ignore 'record not found' errors
if (err.code !== 'P2025') {
throw err
}
}
return true
},
setName: async (parent, data, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await validateSchema(userSchema, data, { models })
try {
await models.user.update({ where: { id: me.id }, data })
return data.name
} catch (error) {
if (error.code === 'P2002') {
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('name taken')
}
throw error
}
},
setSettings: async (parent, { settings: { nostrRelays, ...data } }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
await ssValidate(settingsSchema, { nostrRelays, ...data })
await validateSchema(settingsSchema, { nostrRelays, ...data })
if (nostrRelays?.length) {
const connectOrCreate = []
@ -666,7 +720,7 @@ export default {
},
setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
await models.user.update({ where: { id: me.id }, data: { upvotePopover, tipPopover } })
@ -675,7 +729,7 @@ export default {
},
setPhoto: async (parent, { photoId }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
await models.user.update({
@ -685,31 +739,29 @@ export default {
return Number(photoId)
},
upsertBio: async (parent, { bio }, { me, models }) => {
upsertBio: async (parent, { text }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
await ssValidate(bioSchema, { bio })
await validateSchema(bioSchema, { text })
const user = await models.user.findUnique({ where: { id: me.id } })
if (user.bioId) {
await updateItem(parent, { id: user.bioId, text: bio, title: `@${user.name}'s bio` }, { me, models })
return await updateItem(parent, { id: user.bioId, bio: true, text, title: `@${user.name}'s bio` }, { me, models, lnd })
} else {
await createItem(parent, { bio: true, text: bio, title: `@${user.name}'s bio` }, { me, models })
return await createItem(parent, { bio: true, text, title: `@${user.name}'s bio` }, { me, models, lnd })
}
return await models.user.findUnique({ where: { id: me.id } })
},
generateApiKey: async (parent, { id }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
const user = await models.user.findUnique({ where: { id: me.id } })
if (!user.apiKeyEnabled) {
throw new GraphQLError('you are not allowed to generate api keys', { extensions: { code: 'FORBIDDEN' } })
throw new GqlAuthorizationError('you are not allowed to generate api keys')
}
// I trust postgres CSPRNG more than the one from JS
@ -724,14 +776,14 @@ export default {
},
deleteApiKey: async (parent, { id }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
return await models.user.update({ where: { id: me.id }, data: { apiKeyHash: null } })
},
unlinkAuth: async (parent, { authType }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
assertApiKeyNotPermitted({ me })
@ -740,7 +792,7 @@ export default {
user = await models.user.findUnique({ where: { id: me.id } })
const account = await models.account.findFirst({ where: { userId: me.id, provider: authType } })
if (!account) {
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('no such account')
}
await models.account.delete({ where: { id: account.id } })
if (authType === 'twitter') {
@ -755,18 +807,18 @@ export default {
} else if (authType === 'email') {
user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null, emailHash: null } })
} else {
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('no such account')
}
return await authMethods(user, undefined, { models, me })
},
linkUnverifiedEmail: async (parent, { email }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
assertApiKeyNotPermitted({ me })
await ssValidate(emailSchema, { email })
await validateSchema(emailSchema, { email })
try {
await models.user.update({
@ -775,7 +827,7 @@ export default {
})
} catch (error) {
if (error.code === 'P2002') {
throw new GraphQLError('email taken', { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError('email taken')
}
throw error
}
@ -788,12 +840,12 @@ export default {
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
if (existing) {
if (muted && !existing.postsSubscribedAt) {
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError("you can't subscribe to a stacker that you've muted")
}
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { postsSubscribedAt: existing.postsSubscribedAt ? null : new Date() } })
} else {
if (muted) {
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError("you can't subscribe to a stacker that you've muted")
}
await models.userSubscription.create({ data: { ...lookupData, postsSubscribedAt: new Date() } })
}
@ -805,12 +857,12 @@ export default {
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
if (existing) {
if (muted && !existing.commentsSubscribedAt) {
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError("you can't subscribe to a stacker that you've muted")
}
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { commentsSubscribedAt: existing.commentsSubscribedAt ? null : new Date() } })
} else {
if (muted) {
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError("you can't subscribe to a stacker that you've muted")
}
await models.userSubscription.create({ data: { ...lookupData, commentsSubscribedAt: new Date() } })
}
@ -833,7 +885,7 @@ export default {
}
})
if (subscription?.postsSubscribedAt || subscription?.commentsSubscribedAt) {
throw new GraphQLError("you can't mute a stacker to whom you've subscribed", { extensions: { code: 'BAD_INPUT' } })
throw new GqlInputError("you can't mute a stacker to whom you've subscribed")
}
await models.mute.create({ data: { ...lookupData } })
}
@ -841,7 +893,7 @@ export default {
},
hideWelcomeBanner: async (parent, data, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GqlAuthenticationError()
}
await models.user.update({ where: { id: me.id }, data: { hideWelcomeBanner: true } })
@ -898,7 +950,8 @@ export default {
// get the user's first item
const item = await models.item.findFirst({
where: {
userId: user.id
userId: user.id,
OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }]
},
orderBy: {
createdAt: 'asc'
@ -918,7 +971,8 @@ export default {
createdAt: {
gte,
lte
}
},
OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }]
}
})
},
@ -935,7 +989,8 @@ export default {
createdAt: {
gte,
lte
}
},
OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }]
}
})
},
@ -952,7 +1007,8 @@ export default {
createdAt: {
gte,
lte
}
},
OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }]
}
})
},
@ -983,7 +1039,13 @@ export default {
if (!me || me.id !== user.id) {
return 0
}
return msatsToSats(user.msats)
return msatsToSats(user.msats + user.mcredits)
},
credits: async (user, args, { models, me }) => {
if (!me || me.id !== user.id) {
return 0
}
return msatsToSats(user.mcredits)
},
authMethods,
hasInvites: async (user, args, { models }) => {
@ -1003,6 +1065,12 @@ export default {
})
return relays?.map(r => r.nostrRelayAddr)
},
tipRandom: async (user, args, { me }) => {
if (!me || me.id !== user.id) {
return false
}
return !!user.tipRandomMin && !!user.tipRandomMax
}
},
@ -1014,6 +1082,20 @@ export default {
return user.streak
},
gunStreak: async (user, args, { models }) => {
if (user.hideCowboyHat) {
return null
}
return user.gunStreak
},
horseStreak: async (user, args, { models }) => {
if (user.hideCowboyHat) {
return null
}
return user.horseStreak
},
maxStreak: async (user, args, { models }) => {
if (user.hideCowboyHat) {
return null
@ -1045,7 +1127,7 @@ export default {
if (!when || when === 'forever') {
// forever
return (user.stackedMsats && msatsToSats(user.stackedMsats)) || 0
return ((user.stackedMsats && msatsToSats(user.stackedMsats)) || 0)
}
const range = whenRange(when, from, to)

75
api/resolvers/vault.js Normal file
View File

@ -0,0 +1,75 @@
import { E_VAULT_KEY_EXISTS, GqlAuthenticationError, GqlInputError } from '@/lib/error'
export default {
Query: {
getVaultEntry: async (parent, { key }, { me, models }, info) => {
if (!me) throw new GqlAuthenticationError()
if (!key) throw new GqlInputError('must have key')
const k = await models.vault.findUnique({
where: {
key,
userId: me.id
}
})
return k
},
getVaultEntries: async (parent, { keysFilter }, { me, models }, info) => {
if (!me) throw new GqlAuthenticationError()
const entries = await models.vaultEntry.findMany({
where: {
userId: me.id,
key: keysFilter?.length
? {
in: keysFilter
}
: undefined
}
})
return entries
}
},
Mutation: {
// atomic vault migration
updateVaultKey: async (parent, { entries, hash }, { me, models }) => {
if (!me) throw new GqlAuthenticationError()
if (!hash) throw new GqlInputError('hash required')
const txs = []
const { vaultKeyHash: oldKeyHash } = await models.user.findUnique({ where: { id: me.id } })
if (oldKeyHash) {
if (oldKeyHash !== hash) {
throw new GqlInputError('vault key already set', E_VAULT_KEY_EXISTS)
} else {
return true
}
} else {
txs.push(models.user.update({
where: { id: me.id },
data: { vaultKeyHash: hash }
}))
}
for (const entry of entries) {
txs.push(models.vaultEntry.update({
where: { userId_key: { userId: me.id, key: entry.key } },
data: { value: entry.value, iv: entry.iv }
}))
}
await models.$transaction(txs)
return true
},
clearVault: async (parent, args, { me, models }) => {
if (!me) throw new GqlAuthenticationError()
const txs = []
txs.push(models.user.update({
where: { id: me.id },
data: { vaultKeyHash: '' }
}))
txs.push(models.vaultEntry.deleteMany({ where: { userId: me.id } }))
await models.$transaction(txs)
return true
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,9 @@ import { BLOCK_HEIGHT } from '@/fragments/blockHeight'
import { CHAIN_FEE } from '@/fragments/chainFee'
import { getServerSession } from 'next-auth/next'
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
import { NOFOLLOW_LIMIT } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { MULTI_AUTH_ANON, MULTI_AUTH_LIST } from '@/lib/auth'
export default async function getSSRApolloClient ({ req, res, me = null }) {
const session = req && await getServerSession(req, res, getAuthOptions(req))
@ -40,17 +43,17 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
watchQuery: {
fetchPolicy: 'no-cache',
nextFetchPolicy: 'no-cache',
canonizeResults: true,
ssr: true
},
query: {
fetchPolicy: 'no-cache',
nextFetchPolicy: 'no-cache',
canonizeResults: true,
ssr: true
}
}
})
await client.clearStore()
return client
}
@ -64,7 +67,17 @@ function oneDayReferral (request, { me }) {
let prismaPromise, getData
if (referrer.startsWith('item-')) {
prismaPromise = models.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } })
prismaPromise = models.item.findUnique({
where: {
id: parseInt(referrer.slice(5)),
msats: {
gt: satsToMsats(NOFOLLOW_LIMIT)
},
weightedVotes: {
gt: 0
}
}
})
getData = item => ({
referrerId: item.userId,
refereeId: parseInt(me.id),
@ -139,10 +152,20 @@ export function getGetServerSideProps (
const client = await getSSRApolloClient({ req, res })
const { data: { me } } = await client.query({ query: ME })
let { data: { me } } = await client.query({ query: ME })
// required to redirect to /signup on page reload
// if we switched to anon and authentication is required
if (req.cookies[MULTI_AUTH_LIST] === MULTI_AUTH_ANON) {
me = null
}
if (authRequired && !me) {
const callback = process.env.NEXT_PUBLIC_URL + req.url
let callback = process.env.NEXT_PUBLIC_URL + req.url
// On client-side routing, the callback is a NextJS URL
// so we need to remove the NextJS stuff.
// Example: /_next/data/development/territory.json
callback = callback.replace(/\/_next\/data\/\w+\//, '/').replace(/\.json$/, '')
return {
redirect: {
destination: `/signup?callbackUrl=${encodeURIComponent(callback)}`
@ -174,6 +197,7 @@ export function getGetServerSideProps (
}
if (error || !data || (notFound && notFound(data, vars, me))) {
error && console.error(error)
res.writeHead(302, {
Location: '/404'
}).end()

View File

@ -13,6 +13,8 @@ export default gql`
spenderGrowth(when: String, from: String, to: String): [TimeData!]!
stackingGrowth(when: String, from: String, to: String): [TimeData!]!
stackerGrowth(when: String, from: String, to: String): [TimeData!]!
itemGrowthSubs(when: String, from: String, to: String, sub: String): [TimeData!]!
revenueGrowthSubs(when: String, from: String, to: String, sub: String): [TimeData!]!
}
type TimeData {

View File

@ -1,16 +0,0 @@
import { gql } from 'graphql-tag'
export default gql`
type ImageFeesInfo {
totalFees: Int!
totalFeesMsats: Int!
imageFee: Int!
imageFeeMsats: Int!
nUnpaid: Int!
bytesUnpaid: Int!
bytes24h: Int!
}
extend type Query {
imageFeesInfo(s3Keys: [Int]!): ImageFeesInfo!
}
`

View File

@ -17,8 +17,8 @@ import price from './price'
import admin from './admin'
import blockHeight from './blockHeight'
import chainFee from './chainFee'
import image from './image'
import paidAction from './paidAction'
import vault from './vault'
const common = gql`
type Query {
@ -39,4 +39,4 @@ const common = gql`
`
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, image, paidAction]
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault]

View File

@ -7,7 +7,7 @@ export default gql`
}
extend type Mutation {
createInvite(gift: Int!, limit: Int): Invite
createInvite(id: String, gift: Int!, limit: Int!, description: String): Invite
revokeInvite(id: ID!): Invite
}
@ -20,5 +20,6 @@ export default gql`
user: User!
revoked: Boolean!
poor: Boolean!
description: String
}
`

View File

@ -8,10 +8,18 @@ export default gql`
dupes(url: String!): [Item!]
related(cursor: String, title: String, id: ID, minMatch: String, limit: Limit): Items
search(q: String, sub: String, cursor: String, what: String, sort: String, when: String, from: String, to: String): Items
auctionPosition(sub: String, id: ID, bid: Int!): Int!
auctionPosition(sub: String, id: ID, boost: Int): Int!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int!
}
type BoostPositions {
home: Boolean!
sub: Boolean!
homeMaxBoost: Int!
subMaxBoost: Int!
}
type TitleUnshorted {
title: String
unshorted: String
@ -35,15 +43,24 @@ export default gql`
pinItem(id: ID): Item
subscribeItem(id: ID): Item
deleteItem(id: ID): Item
upsertLink(id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput]): ItemPaidAction!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput]): ItemPaidAction!
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, boost: Int, forward: [ItemForwardInput]): ItemPaidAction!
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): ItemPaidAction!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date): ItemPaidAction!
upsertLink(
id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput],
hash: String, hmac: String): ItemPaidAction!
upsertDiscussion(
id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput],
hash: String, hmac: String): ItemPaidAction!
upsertBounty(
id: ID, sub: String, title: String!, text: String, bounty: Int, boost: Int, forward: [ItemForwardInput],
hash: String, hmac: String): ItemPaidAction!
upsertJob(
id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, boost: Int, status: String, logo: Int): ItemPaidAction!
upsertPoll(
id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date,
hash: String, hmac: String): ItemPaidAction!
updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id:ID, text: String!, parentId: ID): ItemPaidAction!
act(id: ID!, sats: Int, act: String, idempotent: Boolean): ItemActPaidAction!
upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction!
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
pollVote(id: ID!): PollVotePaidAction!
toggleOutlaw(id: ID!): Item!
}
@ -70,6 +87,7 @@ export default gql`
cursor: String
items: [Item!]!
pins: [Item!]
ad: Item
}
type Comments {
@ -89,6 +107,7 @@ export default gql`
id: ID!
createdAt: Date!
updatedAt: Date!
invoicePaidAt: Date
deletedAt: Date
deleteScheduledAt: Date
reminderScheduledAt: Date
@ -109,10 +128,13 @@ export default gql`
bountyPaidTo: [Int]
noteId: String
sats: Int!
credits: Int!
commentSats: Int!
commentCredits: Int!
lastCommentAt: Date
upvotes: Int!
meSats: Int!
meCredits: Int!
meDontLikeSats: Int!
meBookmark: Boolean!
meSubscription: Boolean!
@ -123,11 +145,11 @@ export default gql`
bio: Boolean!
paidImgLink: Boolean
ncomments: Int!
comments(sort: String): [Item!]!
nDirectComments: Int!
comments(sort: String, cursor: String): Comments!
path: String
position: Int
prior: Int
maxBid: Int
isJob: Boolean!
pollCost: Int
poll: Poll
@ -137,7 +159,7 @@ export default gql`
remote: Boolean
sub: Sub
subName: String
status: String
status: String!
uploadId: Int
otsHash: String
parentOtsHash: String
@ -146,6 +168,7 @@ export default gql`
rel: String
apiKey: Boolean
invoice: Invoice
cost: Int!
}
input ItemForwardInput {

View File

@ -79,6 +79,7 @@ export default gql`
id: ID!
sortTime: Date!
days: Int
type: String!
}
type Earn {
@ -123,9 +124,12 @@ export default gql`
withdrawl: Withdrawl!
}
union ReferralSource = Item | Sub | User
type Referral {
id: ID!
sortTime: Date!
source: ReferralSource
}
type SubStatus {

View File

@ -7,11 +7,13 @@ extend type Query {
}
extend type Mutation {
retryPaidAction(invoiceId: Int!): PaidAction!
retryPaidAction(invoiceId: Int!, newAttempt: Boolean): PaidAction!
}
enum PaymentMethod {
REWARD_SATS
FEE_CREDIT
ZERO_COST
OPTIMISTIC
PESSIMISTIC
}
@ -51,4 +53,9 @@ type DonatePaidAction implements PaidAction {
paymentMethod: PaymentMethod!
}
type BuyCreditsPaidAction implements PaidAction {
result: BuyCreditsResult
invoice: Invoice
paymentMethod: PaymentMethod!
}
`

View File

@ -19,6 +19,7 @@ export default gql`
time: Date!
sources: [NameValue!]!
leaderboard: UsersNullable
ad: Item
}
type Reward {

View File

@ -16,7 +16,8 @@ export default gql`
extend type Mutation {
upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, allowFreebies: Boolean!,
replyCost: Int!,
postTypes: [String!]!,
billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
paySub(name: String!): SubPaidAction!
@ -24,13 +25,13 @@ export default gql`
toggleSubSubscription(name: String!): Boolean!
transferTerritory(subName: String!, userName: String!): Sub
unarchiveTerritory(name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, allowFreebies: Boolean!,
replyCost: Int!, postTypes: [String!]!,
billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
}
type Sub {
name: ID!
name: String!
createdAt: Date!
userId: Int!
user: User!
@ -45,6 +46,7 @@ export default gql`
billedLastAt: Date!
billPaidUntil: Date
baseCost: Int!
replyCost: Int!
status: String!
moderated: Boolean!
moderatedCount: Int!

View File

@ -1,12 +1,26 @@
import { gql } from 'graphql-tag'
export default gql`
extend type Mutation {
getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!, avatar: Boolean): SignedPost!
type UploadFees {
totalFees: Int!
totalFeesMsats: Int!
uploadFees: Int!
uploadFeesMsats: Int!
nUnpaid: Int!
bytesUnpaid: Int!
bytes24h: Int!
}
type SignedPost {
url: String!
fields: JSONObject!
}
extend type Query {
uploadFees(s3Keys: [Int]!): UploadFees!
}
extend type Mutation {
getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!, avatar: Boolean): SignedPost!
}
`

View File

@ -4,7 +4,7 @@ export default gql`
extend type Query {
me: User
settings: User
user(name: String!): User
user(id: ID, name: String): User
users: [User!]
nameAvailable(name: String!): Boolean!
topUsers(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): UsersNullable!
@ -33,7 +33,7 @@ export default gql`
setName(name: String!): String
setSettings(settings: SettingsInput!): User
setPhoto(photoId: ID!): Int!
upsertBio(bio: String!): User!
upsertBio(text: String!): ItemPaidAction!
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
unlinkAuth(authType: String!): AuthMethods!
linkUnverifiedEmail(email: String!): Boolean
@ -43,12 +43,13 @@ export default gql`
toggleMute(id: ID): User
generateApiKey(id: ID!): String
deleteApiKey(id: ID!): User
disableFreebies: Boolean
}
type User {
id: ID!
createdAt: Date!
name: String
name: String!
nitems(when: String, from: String, to: String): Int!
nposts(when: String, from: String, to: String): Int!
nterritories(when: String, from: String, to: String): Int!
@ -58,6 +59,11 @@ export default gql`
photoId: Int
since: Int
"""
this is only returned when we sort stackers by value
"""
proportion: Float
optional: UserOptional!
privates: UserPrivates
@ -71,7 +77,8 @@ export default gql`
diagnostics: Boolean!
noReferralLinks: Boolean!
fiatCurrency: String!
greeterMode: Boolean!
satsFilter: Int!
disableFreebies: Boolean
hideBookmarks: Boolean!
hideCowboyHat: Boolean!
hideGithub: Boolean!
@ -82,6 +89,7 @@ export default gql`
hideIsContributor: Boolean!
hideWalletBalance: Boolean!
imgproxyOnly: Boolean!
showImagesAndVideos: Boolean!
nostrCrossposting: Boolean!
nostrPubkey: String
nostrRelays: [String!]
@ -98,10 +106,16 @@ export default gql`
noteItemMentions: Boolean!
nsfwMode: Boolean!
tipDefault: Int!
tipRandomMin: Int
tipRandomMax: Int
turboTipping: Boolean!
zapUndos: Int
wildWestMode: Boolean!
withdrawMaxFeeDefault: Int!
proxyReceive: Boolean
directReceive: Boolean
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
}
type AuthMethods {
@ -118,6 +132,7 @@ export default gql`
extremely sensitive
"""
sats: Int!
credits: Int!
authMethods: AuthMethods!
lnAddr: String
@ -138,6 +153,8 @@ export default gql`
diagnostics: Boolean!
noReferralLinks: Boolean!
fiatCurrency: String!
satsFilter: Int!
disableFreebies: Boolean
greeterMode: Boolean!
hideBookmarks: Boolean!
hideCowboyHat: Boolean!
@ -149,6 +166,7 @@ export default gql`
hideIsContributor: Boolean!
hideWalletBalance: Boolean!
imgproxyOnly: Boolean!
showImagesAndVideos: Boolean!
nostrCrossposting: Boolean!
nostrPubkey: String
nostrRelays: [String!]
@ -165,12 +183,22 @@ export default gql`
noteItemMentions: Boolean!
nsfwMode: Boolean!
tipDefault: Int!
tipRandom: Boolean!
tipRandomMin: Int
tipRandomMax: Int
turboTipping: Boolean!
zapUndos: Int
wildWestMode: Boolean!
withdrawMaxFeeDefault: Int!
autoWithdrawThreshold: Int
autoWithdrawMaxFeePercent: Float
autoWithdrawMaxFeeTotal: Int
vaultKeyHash: String
walletsUpdatedAt: Date
proxyReceive: Boolean
directReceive: Boolean
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
}
type UserOptional {
@ -181,13 +209,15 @@ export default gql`
spent(when: String, from: String, to: String): Int
referrals(when: String, from: String, to: String): Int
streak: Int
gunStreak: Int
horseStreak: Int
maxStreak: Int
isContributor: Boolean
githubId: String
twitterId: String
nostrAuthPubkey: String
}
type NameValue {
name: String!
value: Float!

29
api/typeDefs/vault.js Normal file
View File

@ -0,0 +1,29 @@
import { gql } from 'graphql-tag'
export default gql`
type VaultEntry {
id: ID!
key: String!
iv: String!
value: String!
createdAt: Date!
updatedAt: Date!
}
input VaultEntryInput {
key: String!
iv: String!
value: String!
walletId: ID
}
extend type Query {
getVaultEntry(key: String!): VaultEntry
getVaultEntries(keysFilter: [String!]): [VaultEntry!]!
}
extend type Mutation {
clearVault: Boolean
updateVaultKey(entries: [VaultEntryInput!]!, hash: String!): Boolean
}
`

View File

@ -1,95 +1,125 @@
import { gql } from 'graphql-tag'
import { generateResolverName } from '@/lib/wallet'
import walletDefs from 'wallets/server'
import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName } from '@/wallets/graphql'
import { isServerField } from '@/wallets/common'
import walletDefs from '@/wallets/server'
function injectTypeDefs (typeDefs) {
console.group('injected GraphQL type defs:')
const injected = walletDefs.map(
(w) => {
let args = 'id: ID, '
args += w.fields.map(f => {
let arg = `${f.name}: String`
if (!f.optional) {
arg += '!'
}
return arg
}).join(', ')
args += ', settings: AutowithdrawSettings!'
const resolverName = generateResolverName(w.walletField)
const typeDef = `${resolverName}(${args}): Boolean`
console.log(typeDef)
return typeDef
})
const injected = [rawTypeDefs(), mutationTypeDefs()]
return `${typeDefs}\n\n${injected.join('\n\n')}\n`
}
function mutationTypeDefs () {
console.group('injected GraphQL mutations:')
const typeDefs = walletDefs.map((w) => {
let args = 'id: ID, '
const serverFields = w.fields
.filter(isServerField)
.map(fieldToGqlArgOptional)
if (serverFields.length > 0) args += serverFields.join(', ') + ','
args += 'enabled: Boolean, priority: Int, vaultEntries: [VaultEntryInput!], settings: AutowithdrawSettings, validateLightning: Boolean'
const resolverName = generateResolverName(w.walletField)
const typeDef = `${resolverName}(${args}): Wallet`
console.log(typeDef)
return typeDef
})
console.groupEnd()
return `${typeDefs}\n\nextend type Mutation {\n${injected.join('\n')}\n}`
return `extend type Mutation {\n${typeDefs.join('\n')}\n}`
}
function rawTypeDefs () {
console.group('injected GraphQL type defs:')
const typeDefs = walletDefs.map((w) => {
let args = w.fields
.filter(isServerField)
.map(fieldToGqlArg)
.map(s => ' ' + s)
.join('\n')
if (!args) {
// add a placeholder arg so the type is not empty
args = ' _empty: Boolean'
}
const typeDefName = generateTypeDefName(w.walletType)
const typeDef = `type ${typeDefName} {\n${args}\n}`
console.log(typeDef)
return typeDef
})
let union = 'union WalletDetails = '
union += walletDefs.map((w) => {
const typeDefName = generateTypeDefName(w.walletType)
return typeDefName
}).join(' | ')
console.log(union)
console.groupEnd()
return typeDefs.join('\n\n') + union
}
const typeDefs = `
extend type Query {
invoice(id: ID!): Invoice!
withdrawl(id: ID!): Withdrawl!
direct(id: ID!): Direct!
numBolt11s: Int!
connectAddress: String!
walletHistory(cursor: String, inc: String): History
wallets: [Wallet!]!
wallets(includeReceivers: Boolean, includeSenders: Boolean, onlyEnabled: Boolean, prioritySort: String): [Wallet!]!
wallet(id: ID!): Wallet
walletByType(type: String!): Wallet
walletLogs: [WalletLog]!
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
failedInvoices: [Invoice!]!
}
extend type Mutation {
createInvoice(amount: Int!, expireSecs: Int, hodlInvoice: Boolean): Invoice!
createInvoice(amount: Int!): InvoiceOrDirect!
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
cancelInvoice(hash: String!, hmac: String!): Invoice!
dropBolt11(id: ID): Withdrawl
cancelInvoice(hash: String!, hmac: String, userCancel: Boolean): Invoice!
dropBolt11(hash: String!): Boolean
removeWallet(id: ID!): Boolean
deleteWalletLogs(wallet: String): Boolean
setWalletPriority(id: ID!, priority: Int!): Boolean
buyCredits(credits: Int!): BuyCreditsPaidAction!
}
type BuyCreditsResult {
credits: Int!
}
interface InvoiceOrDirect {
id: ID!
}
type Wallet {
id: ID!
createdAt: Date!
updatedAt: Date!
type: String!
enabled: Boolean!
priority: Int!
wallet: WalletDetails!
vaultEntries: [VaultEntry!]!
}
type WalletLNAddr {
address: String!
}
type WalletLND {
socket: String!
macaroon: String!
cert: String
}
type WalletCLN {
socket: String!
rune: String!
cert: String
}
union WalletDetails = WalletLNAddr | WalletLND | WalletCLN
input AutowithdrawSettings {
autoWithdrawThreshold: Int!
autoWithdrawMaxFeePercent: Float!
priority: Int
enabled: Boolean
autoWithdrawMaxFeeTotal: Int!
}
type Invoice {
type Invoice implements InvoiceOrDirect {
id: ID!
createdAt: Date!
hash: String!
bolt11: String!
expiresAt: Date!
cancelled: Boolean!
cancelledAt: Date
confirmedAt: Date
satsReceived: Int
satsRequested: Int!
@ -102,8 +132,11 @@ const typeDefs = `
actionState: String
actionType: String
actionError: String
invoiceForward: Boolean
item: Item
itemAct: ItemAct
forwardedSats: Int
forwardStatus: String
}
type Withdrawl {
@ -118,6 +151,19 @@ const typeDefs = `
status: String
autoWithdraw: Boolean!
preimage: String
forwardedActionType: String
}
type Direct implements InvoiceOrDirect {
id: ID!
createdAt: Date!
bolt11: String
hash: String
sats: Int
preimage: String
nostr: JSONObject
comment: String
lud18Data: JSONObject
}
type Fact {
@ -141,11 +187,17 @@ const typeDefs = `
}
type WalletLog {
entries: [WalletLogEntry!]!
cursor: String
}
type WalletLogEntry {
id: ID!
createdAt: Date!
wallet: ID!
level: String!
message: String!
context: JSONObject
}
`

View File

@ -18,7 +18,7 @@ felipebueno,pr,#948,,,,,,100k,felipe@stacker.news,2024-03-26
benalleng,pr,#972,#923,good-first-issue,,,,20k,BenAllenG@stacker.news,2024-03-26
SatsAllDay,issue,#972,#923,good-first-issue,,,,2k,weareallsatoshi@getalby.com,2024-03-26
felipebueno,pr,#974,#884,good-first-issue,,,,20k,felipe@stacker.news,2024-03-26
h0dlr,issue,#974,#884,good-first-issue,,,,2k,0xe14b9b5981c729a3@ln.tips,2024-04-04
h0dlr,issue,#974,#884,good-first-issue,,,,2k,HODLR@stacker.news,2024-04-04
benalleng,pr,#975,,,,,,20k,BenAllenG@stacker.news,2024-03-26
SatsAllDay,security,#980,GHSA-qg4g-m4xq-695p,,,,,100k,weareallsatoshi@getalby.com,2024-03-28
SatsAllDay,code review,#980,GHSA-qg4g-m4xq-695p,medium,,,,25k,weareallsatoshi@getalby.com,2024-03-28
@ -115,3 +115,93 @@ cointastical,issue,#1223,#107,medium,,2,,20k,cointastical@stacker.news,2024-06-2
kravhen,pr,#1215,#253,medium,,2,upgraded to medium,200k,nichro@getalby.com,2024-06-28
dillon-co,pr,#1140,#633,hard,,,requested advance,500k,bolt11,2024-07-02
takitakitanana,issue,,#1257,good-first-issue,,,,2k,takitakitanana@stacker.news,2024-07-11
SatsAllDay,pr,#1263,#1112,medium,,,1,225k,weareallsatoshi@getalby.com,2024-07-31
OneOneSeven117,issue,#1272,#1268,easy,,,,10k,OneOneSeven@stacker.news,2024-07-31
aniskhalfallah,pr,#1264,#1226,good-first-issue,,,,20k,aniskhalfallah@stacker.news,2024-07-31
Gudnessuche,issue,#1264,#1226,good-first-issue,,,,2k,everythingsatoshi@getalby.com,2024-08-10
aniskhalfallah,pr,#1289,,easy,,,,100k,aniskhalfallah@blink.sv,2024-08-12
riccardobl,pr,#1293,#1142,medium,high,,,500k,rblb@getalby.com,2024-08-18
tsmith123,pr,#1306,#832,medium,,,,250k,stickymarch60@walletofsatoshi.com,2024-08-20
riccardobl,pr,#1311,#864,medium,high,,pending unrelated refactor,500k,rblb@getalby.com,2024-08-27
brugeman,issue,#1311,#864,medium,high,,,50k,brugeman@stacker.news,2024-08-27
riccardobl,pr,#1342,#1141,hard,high,,pending unrelated rearchitecture,1m,rblb@getalby.com,2024-09-09
SatsAllDay,issue,#1368,#1331,medium,,,,25k,weareallsatoshi@getalby.com,2024-09-16
benalleng,helpfulness,#1368,#1170,medium,,,did a lot of it in #1175,25k,BenAllenG@stacker.news,2024-09-16
humble-GOAT,issue,#1412,#1407,good-first-issue,,,,2k,humble_GOAT@stacker.news,2024-09-18
felipebueno,issue,#1425,#986,medium,,,,25k,felipebueno@getalby.com,2024-09-26
riccardobl,pr,#1373,#1304,hard,high,,,2m,bolt11,2024-10-01
tsmith123,pr,#1428,#1397,easy,,1,superceded,90k,stickymarch60@walletofsatoshi.com,2024-10-02
toyota-corolla0,pr,#1449,,good-first-issue,,,,20k,toyota_corolla0@stacker.news,2024-10-02
toyota-corolla0,pr,#1455,#1437,good-first-issue,,,,20k,toyota_corolla0@stacker.news,2024-10-02
SouthKoreaLN,issue,#1436,,easy,,,,10k,south_korea_ln@stacker.news,2024-10-02
TonyGiorgio,issue,#1462,,easy,urgent,,,30k,TonyGiorgio@stacker.news,2024-10-07
hkarani,issue,#1369,#1458,good-first-issue,,,,2k,asterisk32@stacker.news,2024-10-21
toyota-corolla0,pr,#1369,#1458,good-first-issue,,,,20k,toyota_corolla0@stacker.news,2024-10-20
Soxasora,pr,#1593,#1569,good-first-issue,,,,20k,soxasora@blink.sv,2024-11-19
Soxasora,pr,#1599,#1258,medium,,,,250k,soxasora@blink.sv,2024-11-19
aegroto,pr,#1585,#1522,easy,high,,1,180k,aegroto@blink.sv,2024-11-19
sig47,issue,#1585,#1522,easy,high,,1,18k,siggy47@stacker.news,2024-11-19
aegroto,pr,#1583,#1572,easy,,,2,80k,aegroto@blink.sv,2024-11-19
Soxasora,pr,#1617,#1616,easy,,,,100k,soxasora@blink.sv,2024-11-20
Soxasora,issue,#1617,#1616,easy,,,,10k,soxasora@blink.sv,2024-11-20
AndreaDiazCorreia,helpfulness,#1605,#1566,good-first-issue,,,tried in pr,2k,andrea@lawallet.ar,2024-11-20
Soxasora,pr,#1653,,medium,,,determined unecessary,250k,soxasora@blink.sv,2024-12-07
Soxasora,pr,#1659,#1657,easy,,,,100k,soxasora@blink.sv,2024-12-07
sig47,issue,#1659,#1657,easy,,,,10k,siggy47@stacker.news,2024-12-07
Gudnessuche,issue,#1662,#1661,good-first-issue,,,,2k,everythingsatoshi@getalby.com,2024-12-07
aegroto,pr,#1589,#1586,easy,,,,100k,aegroto@blink.sv,2024-12-07
aegroto,issue,#1589,#1586,easy,,,,10k,aegroto@blink.sv,2024-12-07
aegroto,pr,#1619,#914,easy,,,,100k,aegroto@blink.sv,2024-12-07
felipebueno,pr,#1620,,medium,,,1,225k,felipebueno@getalby.com,2024-12-09
Soxasora,pr,#1647,#1645,easy,,,,100k,soxasora@blink.sv,2024-12-07
Soxasora,pr,#1667,#1568,easy,,,,100k,soxasora@blink.sv,2024-12-07
aegroto,pr,#1633,#1471,easy,,,1,90k,aegroto@blink.sv,2024-12-07
Darth-Coin,issue,#1649,#1421,medium,,,,25k,darthcoin@stacker.news,2024-12-07
Soxasora,pr,#1685,,medium,,,,250k,soxasora@blink.sv,2024-12-07
aegroto,pr,#1606,#1242,medium,,,,250k,aegroto@blink.sv,2024-12-07
sfr0xyz,issue,#1696,#1196,good-first-issue,,,,2k,sefiro@getalby.com,2024-12-10
Soxasora,pr,#1794,#756,hard,urgent,,includes #411,3m,bolt11,2025-01-09
Soxasora,pr,#1786,#363,easy,,,,100k,bolt11,2025-01-09
Soxasora,pr,#1768,#1186,medium-hard,,,,500k,bolt11,2025-01-09
Soxasora,pr,#1750,#1035,medium,,,,250k,bolt11,2025-01-09
SatsAllDay,issue,#1794,#411,hard,high,,,200k,weareallsatoshi@getalby.com,2025-01-20
felipebueno,issue,#1786,#363,easy,,,,10k,felipebueno@blink.sv,2025-01-27
cyphercosmo,pr,#1745,#1648,good-first-issue,,,2,16k,cyphercosmo@getalby.com,2025-01-27
Radentor,issue,#1768,#1186,medium-hard,,,,50k,revisedbird84@walletofsatoshi.com,2025-01-27
Soxasora,pr,#1841,#1692,good-first-issue,,,,20k,soxasora@blink.sv,2025-01-27
Soxasora,pr,#1839,#1790,easy,,,1,90k,soxasora@blink.sv,2025-01-27
Soxasora,pr,#1820,#1819,easy,,,1,90k,soxasora@blink.sv,2025-01-27
SatsAllDay,issue,#1820,#1819,easy,,,1,9k,weareallsatoshi@getalby.com,2025-01-27
Soxasora,pr,#1814,#1736,easy,,,,100k,soxasora@blink.sv,2025-01-27
jason-me,pr,#1857,,easy,,,,100k,rrbtc@vlt.ge,2025-02-08
ed-kung,pr,#1901,#323,good-first-issue,,,,20k,simplestacker@getalby.com,2025-02-14
Scroogey-SN,pr,#1911,#1905,good-first-issue,,,1,18k,Scroogey@coinos.io,2025-03-10
Scroogey-SN,pr,#1928,#1924,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10
dtonon,issue,#1928,#1924,good-first-issue,,,,2k,???,???
ed-kung,pr,#1926,#1914,medium-hard,,,,500k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1926,#1914,medium-hard,,,,50k,simplestacker@getalby.com,2025-03-10
ed-kung,pr,#1926,#1927,easy,,,,100k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1926,#1927,easy,,,,10k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1913,#1890,good-first-issue,,,,2k,simplestacker@getalby.com,2025-03-10
Scroogey-SN,pr,#1930,#1167,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10
itsrealfake,issue,#1930,#1167,good-first-issue,,,,2k,smallimagination100035@getalby.com,???
Scroogey-SN,pr,#1948,#1849,medium,urgent,,,750k,Scroogey@coinos.io,2025-03-10
felipebueno,issue,#1947,#1945,good-first-issue,,,,2k,felipebueno@blink.sv,2025-03-10
ed-kung,pr,#1952,#1951,easy,,,,100k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1952,#1951,easy,,,,10k,simplestacker@getalby.com,2025-03-10
Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,???
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,???,???
ed-kung,pr,#2012,#2004,easy,,,,100k,simplestacker@getalby.com,???
ed-kung,issue,#2012,#2004,easy,,,,10k,simplestacker@getalby.com,???
ed-kung,pr,#1993,#1982,good-first-issue,,,,20k,simplestacker@getalby.com,???
rideandslide,issue,#1993,#1982,good-first-issue,,,,2k,???,???
ed-kung,pr,#1972,#1254,good-first-issue,,,,20k,simplestacker@getalby.com,???
SatsAllDay,issue,#1972,#1254,good-first-issue,,,,2k,weareallsatoshi@getalby.com,???
ed-kung,pr,#1962,#1343,good-first-issue,,,,20k,simplestacker@getalby.com,???
ed-kung,pr,#1962,#1217,good-first-issue,,,,20k,simplestacker@getalby.com,???
ed-kung,pr,#1962,#866,easy,,,,100k,simplestacker@getalby.com,???
felipebueno,issue,#1962,#866,easy,,,,10k,felipebueno@blink.sv,???
cointastical,issue,#1962,#1217,good-first-issue,,,,2k,cointastical@stacker.news,???
Scroogey-SN,pr,#1975,#1964,good-first-issue,,,,20k,Scroogey@coinos.io,???
rideandslide,issue,#1986,#1985,good-first-issue,,,,2k,???,???
kristapsk,issue,#1976,#841,good-first-issue,,,,2k,???,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
18 benalleng pr #972 #923 good-first-issue 20k BenAllenG@stacker.news 2024-03-26
19 SatsAllDay issue #972 #923 good-first-issue 2k weareallsatoshi@getalby.com 2024-03-26
20 felipebueno pr #974 #884 good-first-issue 20k felipe@stacker.news 2024-03-26
21 h0dlr issue #974 #884 good-first-issue 2k 0xe14b9b5981c729a3@ln.tips HODLR@stacker.news 2024-04-04
22 benalleng pr #975 20k BenAllenG@stacker.news 2024-03-26
23 SatsAllDay security #980 GHSA-qg4g-m4xq-695p 100k weareallsatoshi@getalby.com 2024-03-28
24 SatsAllDay code review #980 GHSA-qg4g-m4xq-695p medium 25k weareallsatoshi@getalby.com 2024-03-28
115 kravhen pr #1215 #253 medium 2 upgraded to medium 200k nichro@getalby.com 2024-06-28
116 dillon-co pr #1140 #633 hard requested advance 500k bolt11 2024-07-02
117 takitakitanana issue #1257 good-first-issue 2k takitakitanana@stacker.news 2024-07-11
118 SatsAllDay pr #1263 #1112 medium 1 225k weareallsatoshi@getalby.com 2024-07-31
119 OneOneSeven117 issue #1272 #1268 easy 10k OneOneSeven@stacker.news 2024-07-31
120 aniskhalfallah pr #1264 #1226 good-first-issue 20k aniskhalfallah@stacker.news 2024-07-31
121 Gudnessuche issue #1264 #1226 good-first-issue 2k everythingsatoshi@getalby.com 2024-08-10
122 aniskhalfallah pr #1289 easy 100k aniskhalfallah@blink.sv 2024-08-12
123 riccardobl pr #1293 #1142 medium high 500k rblb@getalby.com 2024-08-18
124 tsmith123 pr #1306 #832 medium 250k stickymarch60@walletofsatoshi.com 2024-08-20
125 riccardobl pr #1311 #864 medium high pending unrelated refactor 500k rblb@getalby.com 2024-08-27
126 brugeman issue #1311 #864 medium high 50k brugeman@stacker.news 2024-08-27
127 riccardobl pr #1342 #1141 hard high pending unrelated rearchitecture 1m rblb@getalby.com 2024-09-09
128 SatsAllDay issue #1368 #1331 medium 25k weareallsatoshi@getalby.com 2024-09-16
129 benalleng helpfulness #1368 #1170 medium did a lot of it in #1175 25k BenAllenG@stacker.news 2024-09-16
130 humble-GOAT issue #1412 #1407 good-first-issue 2k humble_GOAT@stacker.news 2024-09-18
131 felipebueno issue #1425 #986 medium 25k felipebueno@getalby.com 2024-09-26
132 riccardobl pr #1373 #1304 hard high 2m bolt11 2024-10-01
133 tsmith123 pr #1428 #1397 easy 1 superceded 90k stickymarch60@walletofsatoshi.com 2024-10-02
134 toyota-corolla0 pr #1449 good-first-issue 20k toyota_corolla0@stacker.news 2024-10-02
135 toyota-corolla0 pr #1455 #1437 good-first-issue 20k toyota_corolla0@stacker.news 2024-10-02
136 SouthKoreaLN issue #1436 easy 10k south_korea_ln@stacker.news 2024-10-02
137 TonyGiorgio issue #1462 easy urgent 30k TonyGiorgio@stacker.news 2024-10-07
138 hkarani issue #1369 #1458 good-first-issue 2k asterisk32@stacker.news 2024-10-21
139 toyota-corolla0 pr #1369 #1458 good-first-issue 20k toyota_corolla0@stacker.news 2024-10-20
140 Soxasora pr #1593 #1569 good-first-issue 20k soxasora@blink.sv 2024-11-19
141 Soxasora pr #1599 #1258 medium 250k soxasora@blink.sv 2024-11-19
142 aegroto pr #1585 #1522 easy high 1 180k aegroto@blink.sv 2024-11-19
143 sig47 issue #1585 #1522 easy high 1 18k siggy47@stacker.news 2024-11-19
144 aegroto pr #1583 #1572 easy 2 80k aegroto@blink.sv 2024-11-19
145 Soxasora pr #1617 #1616 easy 100k soxasora@blink.sv 2024-11-20
146 Soxasora issue #1617 #1616 easy 10k soxasora@blink.sv 2024-11-20
147 AndreaDiazCorreia helpfulness #1605 #1566 good-first-issue tried in pr 2k andrea@lawallet.ar 2024-11-20
148 Soxasora pr #1653 medium determined unecessary 250k soxasora@blink.sv 2024-12-07
149 Soxasora pr #1659 #1657 easy 100k soxasora@blink.sv 2024-12-07
150 sig47 issue #1659 #1657 easy 10k siggy47@stacker.news 2024-12-07
151 Gudnessuche issue #1662 #1661 good-first-issue 2k everythingsatoshi@getalby.com 2024-12-07
152 aegroto pr #1589 #1586 easy 100k aegroto@blink.sv 2024-12-07
153 aegroto issue #1589 #1586 easy 10k aegroto@blink.sv 2024-12-07
154 aegroto pr #1619 #914 easy 100k aegroto@blink.sv 2024-12-07
155 felipebueno pr #1620 medium 1 225k felipebueno@getalby.com 2024-12-09
156 Soxasora pr #1647 #1645 easy 100k soxasora@blink.sv 2024-12-07
157 Soxasora pr #1667 #1568 easy 100k soxasora@blink.sv 2024-12-07
158 aegroto pr #1633 #1471 easy 1 90k aegroto@blink.sv 2024-12-07
159 Darth-Coin issue #1649 #1421 medium 25k darthcoin@stacker.news 2024-12-07
160 Soxasora pr #1685 medium 250k soxasora@blink.sv 2024-12-07
161 aegroto pr #1606 #1242 medium 250k aegroto@blink.sv 2024-12-07
162 sfr0xyz issue #1696 #1196 good-first-issue 2k sefiro@getalby.com 2024-12-10
163 Soxasora pr #1794 #756 hard urgent includes #411 3m bolt11 2025-01-09
164 Soxasora pr #1786 #363 easy 100k bolt11 2025-01-09
165 Soxasora pr #1768 #1186 medium-hard 500k bolt11 2025-01-09
166 Soxasora pr #1750 #1035 medium 250k bolt11 2025-01-09
167 SatsAllDay issue #1794 #411 hard high 200k weareallsatoshi@getalby.com 2025-01-20
168 felipebueno issue #1786 #363 easy 10k felipebueno@blink.sv 2025-01-27
169 cyphercosmo pr #1745 #1648 good-first-issue 2 16k cyphercosmo@getalby.com 2025-01-27
170 Radentor issue #1768 #1186 medium-hard 50k revisedbird84@walletofsatoshi.com 2025-01-27
171 Soxasora pr #1841 #1692 good-first-issue 20k soxasora@blink.sv 2025-01-27
172 Soxasora pr #1839 #1790 easy 1 90k soxasora@blink.sv 2025-01-27
173 Soxasora pr #1820 #1819 easy 1 90k soxasora@blink.sv 2025-01-27
174 SatsAllDay issue #1820 #1819 easy 1 9k weareallsatoshi@getalby.com 2025-01-27
175 Soxasora pr #1814 #1736 easy 100k soxasora@blink.sv 2025-01-27
176 jason-me pr #1857 easy 100k rrbtc@vlt.ge 2025-02-08
177 ed-kung pr #1901 #323 good-first-issue 20k simplestacker@getalby.com 2025-02-14
178 Scroogey-SN pr #1911 #1905 good-first-issue 1 18k Scroogey@coinos.io 2025-03-10
179 Scroogey-SN pr #1928 #1924 good-first-issue 20k Scroogey@coinos.io 2025-03-10
180 dtonon issue #1928 #1924 good-first-issue 2k ??? ???
181 ed-kung pr #1926 #1914 medium-hard 500k simplestacker@getalby.com 2025-03-10
182 ed-kung issue #1926 #1914 medium-hard 50k simplestacker@getalby.com 2025-03-10
183 ed-kung pr #1926 #1927 easy 100k simplestacker@getalby.com 2025-03-10
184 ed-kung issue #1926 #1927 easy 10k simplestacker@getalby.com 2025-03-10
185 ed-kung issue #1913 #1890 good-first-issue 2k simplestacker@getalby.com 2025-03-10
186 Scroogey-SN pr #1930 #1167 good-first-issue 20k Scroogey@coinos.io 2025-03-10
187 itsrealfake issue #1930 #1167 good-first-issue 2k smallimagination100035@getalby.com ???
188 Scroogey-SN pr #1948 #1849 medium urgent 750k Scroogey@coinos.io 2025-03-10
189 felipebueno issue #1947 #1945 good-first-issue 2k felipebueno@blink.sv 2025-03-10
190 ed-kung pr #1952 #1951 easy 100k simplestacker@getalby.com 2025-03-10
191 ed-kung issue #1952 #1951 easy 10k simplestacker@getalby.com 2025-03-10
192 Scroogey-SN pr #1973 #1959 good-first-issue 20k Scroogey@coinos.io ???
193 benthecarman issue #1953 #1950 good-first-issue 2k ??? ???
194 ed-kung pr #2012 #2004 easy 100k simplestacker@getalby.com ???
195 ed-kung issue #2012 #2004 easy 10k simplestacker@getalby.com ???
196 ed-kung pr #1993 #1982 good-first-issue 20k simplestacker@getalby.com ???
197 rideandslide issue #1993 #1982 good-first-issue 2k ??? ???
198 ed-kung pr #1972 #1254 good-first-issue 20k simplestacker@getalby.com ???
199 SatsAllDay issue #1972 #1254 good-first-issue 2k weareallsatoshi@getalby.com ???
200 ed-kung pr #1962 #1343 good-first-issue 20k simplestacker@getalby.com ???
201 ed-kung pr #1962 #1217 good-first-issue 20k simplestacker@getalby.com ???
202 ed-kung pr #1962 #866 easy 100k simplestacker@getalby.com ???
203 felipebueno issue #1962 #866 easy 10k felipebueno@blink.sv ???
204 cointastical issue #1962 #1217 good-first-issue 2k cointastical@stacker.news ???
205 Scroogey-SN pr #1975 #1964 good-first-issue 20k Scroogey@coinos.io ???
206 rideandslide issue #1986 #1985 good-first-issue 2k ??? ???
207 kristapsk issue #1976 #841 good-first-issue 2k ??? ???

View File

@ -11,7 +11,7 @@ RUN npm ci
COPY . .
ADD http://ftp.de.debian.org/debian/pool/main/f/fonts-noto-color-emoji/fonts-noto-color-emoji_0~20200916-1_all.deb fonts-noto-color-emoji.deb
ADD https://deb.debian.org/debian/pool/main/f/fonts-noto-color-emoji/fonts-noto-color-emoji_0~20200916-1_all.deb fonts-noto-color-emoji.deb
RUN dpkg -i fonts-noto-color-emoji.deb
CMD [ "node", "index.js" ]
USER pptruser
USER pptruser

View File

@ -4,6 +4,7 @@ import { useAccordionButton } from 'react-bootstrap/AccordionButton'
import ArrowRight from '@/svgs/arrow-right-s-fill.svg'
import ArrowDown from '@/svgs/arrow-down-s-fill.svg'
import { useContext, useEffect, useState } from 'react'
import classNames from 'classnames'
const KEY_ID = '0'
@ -30,7 +31,7 @@ function ContextAwareToggle ({ children, headerColor = 'var(--theme-grey)', even
)
}
export default function AccordianItem ({ header, body, headerColor = 'var(--theme-grey)', show }) {
export default function AccordianItem ({ header, body, className, headerColor = 'var(--theme-grey)', show }) {
const [activeKey, setActiveKey] = useState()
useEffect(() => {
@ -43,8 +44,8 @@ export default function AccordianItem ({ header, body, headerColor = 'var(--them
return (
<Accordion defaultActiveKey={activeKey} activeKey={activeKey} onSelect={handleOnSelect}>
<ContextAwareToggle show={show} eventKey={KEY_ID}><div style={{ color: headerColor }}>{header}</div></ContextAwareToggle>
<Accordion.Collapse eventKey={KEY_ID} className='mt-2'>
<ContextAwareToggle show={show} eventKey={KEY_ID} headerColor={headerColor}><div style={{ color: headerColor }}>{header}</div></ContextAwareToggle>
<Accordion.Collapse eventKey={KEY_ID} className={classNames('mt-2', className)}>
<div>{body}</div>
</Accordion.Collapse>
</Accordion>

177
components/account.js Normal file
View File

@ -0,0 +1,177 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import * as cookie from 'cookie'
import { useMe } from '@/components/me'
import { USER_ID, SSR } from '@/lib/constants'
import { USER } from '@/fragments/users'
import { useQuery } from '@apollo/client'
import { UserListRow } from '@/components/user-list'
import Link from 'next/link'
import AddIcon from '@/svgs/add-fill.svg'
import { MultiAuthErrorBanner } from '@/components/banners'
import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_LIST, MULTI_AUTH_POINTER } from '@/lib/auth'
const AccountContext = createContext()
const CHECK_ERRORS_INTERVAL_MS = 5_000
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
export const AccountProvider = ({ children }) => {
const [accounts, setAccounts] = useState([])
const [meAnon, setMeAnon] = useState(true)
const [errors, setErrors] = useState([])
const updateAccountsFromCookie = useCallback(() => {
const { [MULTI_AUTH_LIST]: listCookie } = cookie.parse(document.cookie)
const accounts = listCookie
? JSON.parse(b64Decode(listCookie))
: []
setAccounts(accounts)
}, [])
const nextAccount = useCallback(async () => {
const { status } = await fetch('/api/next-account', { credentials: 'include' })
// if status is 302, this means the server was able to switch us to the next available account
// and the current account was simply removed from the list of available accounts including the corresponding JWT.
const switchSuccess = status === 302
if (switchSuccess) updateAccountsFromCookie()
return switchSuccess
}, [updateAccountsFromCookie])
const checkErrors = useCallback(() => {
const {
[MULTI_AUTH_LIST]: listCookie,
[MULTI_AUTH_POINTER]: pointerCookie
} = cookie.parse(document.cookie)
const errors = []
if (!listCookie) errors.push(`${MULTI_AUTH_LIST} cookie not found`)
if (!pointerCookie) errors.push(`${MULTI_AUTH_POINTER} cookie not found`)
setErrors(errors)
}, [])
useEffect(() => {
if (SSR) return
updateAccountsFromCookie()
const { [MULTI_AUTH_POINTER]: pointerCookie } = cookie.parse(document.cookie)
setMeAnon(pointerCookie === 'anonymous')
const interval = setInterval(checkErrors, CHECK_ERRORS_INTERVAL_MS)
return () => clearInterval(interval)
}, [updateAccountsFromCookie, checkErrors])
const value = useMemo(
() => ({
accounts,
meAnon,
setMeAnon,
nextAccount,
multiAuthErrors: errors
}),
[accounts, meAnon, setMeAnon, nextAccount])
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
}
export const useAccounts = () => useContext(AccountContext)
const AccountListRow = ({ account, ...props }) => {
const { meAnon, setMeAnon } = useAccounts()
const { me, refreshMe } = useMe()
const anonRow = account.id === USER_ID.anon
const selected = (meAnon && anonRow) || Number(me?.id) === Number(account.id)
const router = useRouter()
// fetch updated names and photo ids since they might have changed since we were issued the JWTs
const { data, error } = useQuery(USER,
{
variables: { id: account.id }
}
)
if (error) console.error(`query for user ${account.id} failed:`, error)
const name = data?.user?.name || account.name
const photoId = data?.user?.photoId || account.photoId
const onClick = async (e) => {
// prevent navigation
e.preventDefault()
// update pointer cookie
const options = cookieOptions({ httpOnly: false })
document.cookie = cookie.serialize(MULTI_AUTH_POINTER, anonRow ? MULTI_AUTH_ANON : account.id, options)
// update state
if (anonRow) {
// order is important to prevent flashes of no session
setMeAnon(true)
await refreshMe()
} else {
await refreshMe()
// order is important to prevent flashes of inconsistent data in switch account dialog
setMeAnon(account.id === USER_ID.anon)
}
// reload whatever page we're on to avoid any bugs due to missing authorization etc.
router.reload()
}
return (
<div className='d-flex flex-row'>
<UserListRow
user={{ ...account, photoId, name }}
className='d-flex align-items-center me-2'
{...props}
onNymClick={onClick}
selected={selected}
/>
</div>
)
}
export default function SwitchAccountList () {
const { accounts, multiAuthErrors } = useAccounts()
const router = useRouter()
const hasError = multiAuthErrors.length > 0
if (hasError) {
return (
<>
<div className='my-2'>
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
<MultiAuthErrorBanner errors={multiAuthErrors} />
</div>
</div>
</>
)
}
// can't show hat since the streak is not included in the JWT payload
return (
<>
<div className='my-2'>
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
<h4 className='text-muted'>Accounts</h4>
<AccountListRow account={{ id: USER_ID.anon, name: 'anon' }} showHat={false} />
{
accounts.map((account) => <AccountListRow key={account.id} account={account} showHat={false} />)
}
</div>
<Link
href={{
pathname: '/login',
query: { callbackUrl: window.location.origin + router.asPath, multiAuth: true }
}}
className='text-reset fw-bold'
>
<AddIcon height={20} width={20} /> another account
</Link>
</div>
</>
)
}

View File

@ -1,16 +1,20 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo, useCallback } from 'react'
import AccordianItem from './accordian-item'
import { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
import InputGroup from 'react-bootstrap/InputGroup'
import { BOOST_MIN, BOOST_MULT, MAX_FORWARDS } from '@/lib/constants'
import { BOOST_MIN, BOOST_MULT, MAX_FORWARDS, SSR } from '@/lib/constants'
import { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
import Info from './info'
import { numWithUnits } from '@/lib/format'
import { abbrNum, numWithUnits } from '@/lib/format'
import styles from './adv-post-form.module.css'
import { useMe } from './me'
import { useFeeButton } from './fee-button'
import { useRouter } from 'next/router'
import { useFormikContext } from 'formik'
import { gql, useQuery } from '@apollo/client'
import useDebounceCallback from './use-debounce-callback'
import { Button } from 'react-bootstrap'
import classNames from 'classnames'
const EMPTY_FORWARD = { nym: '', pct: '' }
@ -26,9 +30,153 @@ const FormStatus = {
ERROR: 'error'
}
export default function AdvPostForm ({ children, item, storageKeyPrefix }) {
const me = useMe()
const { merge } = useFeeButton()
export function BoostHelp () {
return (
<ol style={{ lineHeight: 1.25 }}>
<li>Boost ranks items higher based on the amount</li>
<li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li>
<li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li>
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
<li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker (very rare)
<ul>
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li>
</ul>
</li>
<li>boost can take a few minutes to show higher ranking in feed</li>
<li>100% of boost goes to the territory founder and top stackers as rewards</li>
</ol>
)
}
export function BoostInput ({ onChange, ...props }) {
const feeButton = useFeeButton()
let merge
if (feeButton) {
({ merge } = feeButton)
}
return (
<Input
label={
<div className='d-flex align-items-center'>boost
<Info>
<BoostHelp />
</Info>
</div>
}
name='boost'
onChange={(_, e) => {
merge?.({
boost: {
term: `+ ${e.target.value}`,
label: 'boost',
op: '+',
modifier: cost => cost + Number(e.target.value)
}
})
onChange && onChange(_, e)
}}
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
{...props}
/>
)
}
const BoostMaxes = ({ subName, homeMax, subMax, boost, updateBoost }) => {
return (
<div className='d-flex flex-row mb-2'>
<Button
className={classNames(styles.boostMax, 'me-2', homeMax + BOOST_MULT <= (boost || 0) && 'invisible')}
size='sm'
onClick={() => updateBoost(homeMax + BOOST_MULT)}
>
{abbrNum(homeMax + BOOST_MULT)} <small>top of homepage</small>
</Button>
{subName &&
<Button
className={classNames(styles.boostMax, subMax + BOOST_MULT <= (boost || 0) && 'invisible')}
size='sm'
onClick={() => updateBoost(subMax + BOOST_MULT)}
>
{abbrNum(subMax + BOOST_MULT)} <small>top of ~{subName}</small>
</Button>}
</div>
)
}
// act means we are adding to existing boost
export function BoostItemInput ({ item, sub, act = false, ...props }) {
// act adds boost to existing boost
const existingBoost = act ? Number(item?.boost || 0) : 0
const [boost, setBoost] = useState(act ? 0 : Number(item?.boost || 0))
const { data, previousData, refetch } = useQuery(gql`
query BoostPosition($sub: String, $id: ID, $boost: Int) {
boostPosition(sub: $sub, id: $id, boost: $boost) {
home
sub
homeMaxBoost
subMaxBoost
}
}`,
{
variables: { sub: item?.subName || sub?.name, boost: existingBoost + boost, id: item?.id },
fetchPolicy: 'cache-and-network',
skip: !!item?.parentId || SSR
})
const getPositionDebounce = useDebounceCallback((...args) => refetch(...args), 1000, [refetch])
const updateBoost = useCallback((boost) => {
const boostToUse = Number(boost || 0)
setBoost(boostToUse)
getPositionDebounce({ sub: item?.subName || sub?.name, boost: Number(existingBoost + boostToUse), id: item?.id })
}, [getPositionDebounce, item?.id, item?.subName, sub?.name, existingBoost])
const dat = data || previousData
const boostMessage = useMemo(() => {
if (!item?.parentId && boost >= BOOST_MULT) {
if (dat?.boostPosition?.home || dat?.boostPosition?.sub || boost > dat?.boostPosition?.homeMaxBoost || boost > dat?.boostPosition?.subMaxBoost) {
const boostPinning = []
if (dat?.boostPosition?.home || boost > dat?.boostPosition?.homeMaxBoost) {
boostPinning.push('homepage')
}
if ((item?.subName || sub?.name) && (dat?.boostPosition?.sub || boost > dat?.boostPosition?.subMaxBoost)) {
boostPinning.push(`~${item?.subName || sub?.name}`)
}
return `pins to the top of ${boostPinning.join(' and ')}`
}
}
return 'ranks posts higher based on the amount'
}, [boost, dat?.boostPosition?.home, dat?.boostPosition?.sub, item?.subName, sub?.name])
return (
<>
<BoostInput
hint={<span className='text-muted'>{boostMessage}</span>}
onChange={(_, e) => {
if (e.target.value >= 0) {
updateBoost(Number(e.target.value))
}
}}
overrideValue={boost}
{...props}
groupClassName='mb-1'
/>
{!item?.parentId &&
<BoostMaxes
subName={item?.subName || sub?.name}
homeMax={(dat?.boostPosition?.homeMaxBoost || 0) - existingBoost}
subMax={(dat?.boostPosition?.subMaxBoost || 0) - existingBoost}
boost={existingBoost + boost}
updateBoost={updateBoost}
/>}
</>
)
}
export default function AdvPostForm ({ children, item, sub, storageKeyPrefix }) {
const { me } = useMe()
const router = useRouter()
const [itemType, setItemType] = useState()
const formik = useFormikContext()
@ -111,39 +259,7 @@ export default function AdvPostForm ({ children, item, storageKeyPrefix }) {
body={
<>
{children}
<Input
label={
<div className='d-flex align-items-center'>boost
<Info>
<ol className='fw-bold'>
<li>Boost ranks posts higher temporarily based on the amount</li>
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
<li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to one trusted upvote
<ul>
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like 5 votes</li>
</ul>
</li>
<li>The decay of boost "votes" increases at 1.25x the rate of organic votes
<ul>
<li>i.e. boost votes fall out of ranking faster</li>
</ul>
</li>
<li>100% of sats from boost are given back to top stackers as rewards</li>
</ol>
</Info>
</div>
}
name='boost'
onChange={(_, e) => merge({
boost: {
term: `+ ${e.target.value}`,
label: 'boost',
modifier: cost => cost + Number(e.target.value)
}
})}
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<BoostItemInput item={item} sub={sub} />
<VariableInput
label='forward sats to'
name='forward'
@ -179,7 +295,7 @@ export default function AdvPostForm ({ children, item, storageKeyPrefix }) {
label={
<div className='d-flex align-items-center'>crosspost to nostr
<Info>
<ul className='fw-bold'>
<ul>
{renderCrosspostDetails(itemType)}
<li>requires NIP-07 extension for signing</li>
<li>we use your NIP-05 relays if set</li>

View File

@ -9,4 +9,11 @@
display: flex;
flex: 0 1 fit-content;
height: fit-content;
}
.boostMax small {
font-weight: 400;
margin-left: 0.25rem;
margin-right: 0.25rem;
opacity: 0.5;
}

View File

@ -1,8 +1,9 @@
import { InputGroup } from 'react-bootstrap'
import { Checkbox, Input } from './form'
import { Input } from './form'
import { useMe } from './me'
import { useEffect, useState } from 'react'
import { isNumber } from 'mathjs'
import { isNumber } from '@/lib/format'
import Link from 'next/link'
function autoWithdrawThreshold ({ me }) {
return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000
@ -11,12 +12,13 @@ function autoWithdrawThreshold ({ me }) {
export function autowithdrawInitial ({ me }) {
return {
autoWithdrawThreshold: autoWithdrawThreshold({ me }),
autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1
autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1,
autoWithdrawMaxFeeTotal: isNumber(me?.privates?.autoWithdrawMaxFeeTotal) ? me?.privates?.autoWithdrawMaxFeeTotal : 1
}
}
export function AutowithdrawSettings ({ wallet }) {
const me = useMe()
export function AutowithdrawSettings () {
const { me } = useMe()
const threshold = autoWithdrawThreshold({ me })
const [sendThreshold, setSendThreshold] = useState(Math.max(Math.floor(threshold / 10), 1))
@ -27,12 +29,6 @@ export function AutowithdrawSettings ({ wallet }) {
return (
<>
<Checkbox
disabled={!wallet.isConfigured}
label='enabled'
id='enabled'
name='enabled'
/>
<div className='my-4 border border-3 rounded'>
<div className='p-3'>
<h3 className='text-center text-muted'>desired balance</h3>
@ -48,13 +44,30 @@ export function AutowithdrawSettings ({ wallet }) {
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
required
/>
<h3 className='text-center text-muted pt-3'>network fees</h3>
<h6 className='text-center pb-3'>
we'll use whichever setting is higher during{' '}
<Link
target='_blank'
href='https://docs.lightning.engineering/the-lightning-network/pathfinding'
rel='noreferrer'
>pathfinding
</Link>
</h6>
<Input
label='max fee'
label='max fee rate'
name='autoWithdrawMaxFeePercent'
hint='max fee as percent of withdrawal amount'
append={<InputGroup.Text>%</InputGroup.Text>}
required
/>
<Input
label='max fee total'
name='autoWithdrawMaxFeeTotal'
hint='max fee for any withdrawal amount'
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
required
/>
</div>
</div>
</>

View File

@ -5,7 +5,7 @@ import BootstrapForm from 'react-bootstrap/Form'
import EditImage from '@/svgs/image-edit-fill.svg'
import Moon from '@/svgs/moon-fill.svg'
import { useShowModal } from './modal'
import { ImageUpload } from './image'
import { FileUpload } from './file-upload'
export default function Avatar ({ onSuccess }) {
const [uploading, setUploading] = useState()
@ -49,7 +49,8 @@ export default function Avatar ({ onSuccess }) {
}
return (
<ImageUpload
<FileUpload
allow='image/*'
avatar
onError={e => {
console.log(e)
@ -84,6 +85,6 @@ export default function Avatar ({ onSuccess }) {
? <Moon className='fill-white spin' />
: <EditImage className='fill-white' />}
</div>
</ImageUpload>
</FileUpload>
)
}

87
components/badge.js Normal file
View File

@ -0,0 +1,87 @@
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
import Tooltip from 'react-bootstrap/Tooltip'
import CowboyHatIcon from '@/svgs/cowboy.svg'
import AnonIcon from '@/svgs/spy-fill.svg'
import { numWithUnits } from '@/lib/format'
import { USER_ID } from '@/lib/constants'
import GunIcon from '@/svgs/revolver.svg'
import HorseIcon from '@/svgs/horse.svg'
import classNames from 'classnames'
const BADGES = [
{
icon: CowboyHatIcon,
streakName: 'streak'
},
{
icon: HorseIcon,
streakName: 'horseStreak'
},
{
icon: GunIcon,
streakName: 'gunStreak',
sizeDelta: 2
}
]
export default function Badges ({ user, badge, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) {
if (!user || Number(user.id) === USER_ID.ad) return null
if (Number(user.id) === USER_ID.anon) {
return (
<BadgeTooltip overlayText='anonymous'>
<span className={className}><AnonIcon className={`${badgeClassName} align-middle`} height={height} width={width} /></span>
</BadgeTooltip>
)
}
return (
<span className={className}>
{BADGES.map(({ icon, streakName, sizeDelta }, i) => (
<SNBadge
key={streakName}
user={user}
badge={badge}
streakName={streakName}
badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)}
IconForBadge={icon}
height={height}
width={width}
sizeDelta={sizeDelta}
/>
))}
</span>
)
}
function SNBadge ({ user, badge, streakName, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
const streak = user.optional[streakName]
if (streak === null) {
return null
}
return (
<BadgeTooltip
overlayText={streak
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
: 'new'}
>
<span><IconForBadge className={badgeClassName} height={height + sizeDelta} width={width + sizeDelta} /></span>
</BadgeTooltip>
)
}
export function BadgeTooltip ({ children, overlayText, placement }) {
return (
<OverlayTrigger
placement={placement || 'bottom'}
overlay={
<Tooltip style={{ position: 'fixed' }}>
{overlayText}
</Tooltip>
}
trigger={['hover', 'focus']}
>
{children}
</OverlayTrigger>
)
}

View File

@ -5,11 +5,11 @@ import { useMe } from '@/components/me'
import { useMutation } from '@apollo/client'
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
import { useToast } from '@/components/toast'
import { BALANCE_LIMIT_MSATS } from '@/lib/constants'
import { msatsToSats, numWithUnits } from '@/lib/format'
import Link from 'next/link'
import AccordianItem from '@/components/accordian-item'
export function WelcomeBanner ({ Banner }) {
const me = useMe()
const { me } = useMe()
const toaster = useToast()
const [hidden, setHidden] = useState(true)
const handleClose = async () => {
@ -70,7 +70,7 @@ export function WelcomeBanner ({ Banner }) {
}
export function MadnessBanner ({ handleClose }) {
const me = useMe()
const { me } = useMe()
return (
<Alert className={styles.banner} key='info' variant='info' onClose={handleClose} dismissible>
<Alert.Heading>
@ -101,39 +101,17 @@ export function MadnessBanner ({ handleClose }) {
)
}
export function WalletLimitBanner () {
const me = useMe()
const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
if (!me || !limitReached) return
export function WalletSecurityBanner ({ isActive }) {
return (
<Alert className={styles.banner} key='info' variant='warning'>
<Alert.Heading>
Your wallet is over the current limit ({numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS))})
Gunslingin' Safety Tips
</Alert.Heading>
<p className='mb-1'>
Deposits to your wallet from <strong>outside</strong> of SN are blocked.
<p className='mb-3 line-height-md'>
Listen up, pardner! Put a limit on yer spendin' wallet or hook up a wallet that's only for Stacker News. It'll keep them varmints from cleanin' out yer whole goldmine if they rustle up yer wallet.
</p>
<p>
Please spend or withdraw sats to restore full wallet functionality.
</p>
</Alert>
)
}
export function WalletSecurityBanner () {
return (
<Alert className={styles.banner} key='info' variant='warning'>
<Alert.Heading>
Wallet Security Disclaimer
</Alert.Heading>
<p className='mb-1'>
Your wallet's credentials are stored in the browser and never go to the server.<br />
However, you should definitely <strong>set a budget in your wallet</strong>.
</p>
<p>
Also, for the time being, you will have to reenter your credentials on other devices.
<p className='line-height-md'>
Your spending wallet's credentials are never sent to our servers in plain text. To sync across devices, <Alert.Link as={Link} href='/settings/passphrase'>enable device sync in your settings</Alert.Link>.
</p>
</Alert>
)
@ -146,3 +124,24 @@ export function AuthBanner () {
</Alert>
)
}
export function MultiAuthErrorBanner ({ errors }) {
return (
<Alert className={styles.banner} key='info' variant='danger'>
<div className='fw-bold mb-3'>Account switching is currently unavailable</div>
<AccordianItem
className='my-3'
header='We have detected the following issues:'
headerColor='var(--bs-danger-text-emphasis)'
body={
<ul>
{errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
}
/>
<div className='mt-3'>To resolve these issues, please sign out and sign in again.</div>
</Alert>
)
}

View File

@ -17,7 +17,8 @@ export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
id: `Item:${id}`,
fields: {
meBookmark: () => bookmarkItem.meBookmark
}
},
optimistic: true
})
}
}

View File

@ -0,0 +1,65 @@
import { useShowModal } from './modal'
import { useToast } from './toast'
import ItemAct from './item-act'
import AccordianItem from './accordian-item'
import { useMemo } from 'react'
import getColor from '@/lib/rainbow'
import BoostIcon from '@/svgs/arrow-up-double-line.svg'
import styles from './upvote.module.css'
import { BoostHelp } from './adv-post-form'
import { BOOST_MULT } from '@/lib/constants'
import classNames from 'classnames'
export default function Boost ({ item, className, ...props }) {
const { boost } = item
const [color, nextColor] = useMemo(() => [getColor(boost), getColor(boost + BOOST_MULT)], [boost])
const style = useMemo(() => ({
'--hover-fill': nextColor,
'--hover-filter': `drop-shadow(0 0 6px ${nextColor}90)`,
'--fill': color,
'--filter': `drop-shadow(0 0 6px ${color}90)`
}), [color, nextColor])
return (
<Booster
item={item} As={oprops =>
<div className='upvoteParent'>
<div
className={classNames(styles.upvoteWrapper, item.deletedAt && styles.noSelfTips)}
>
<BoostIcon
{...props}
{...oprops}
style={style}
width={26}
height={26}
className={classNames(styles.boost, className, boost && styles.boosted)}
/>
</div>
</div>}
/>
)
}
function Booster ({ item, As, children }) {
const toaster = useToast()
const showModal = useShowModal()
return (
<As
onClick={async () => {
try {
showModal(onClose =>
<ItemAct onClose={onClose} item={item} act='BOOST' step={BOOST_MULT}>
<AccordianItem header='what is boost?' body={<BoostHelp />} />
</ItemAct>)
} catch (error) {
toaster.danger('failed to boost item')
}
}}
>
{children}
</As>
)
}

View File

@ -23,7 +23,7 @@ export function BountyForm ({
children
}) {
const client = useApolloClient()
const me = useMe()
const { me } = useMe()
const schema = bountySchema({ client, me, existingBoost: item?.boost })
const onSubmit = useItemSubmit(UPSERT_BOUNTY, { item, sub })
@ -73,14 +73,14 @@ export function BountyForm ({
hint={
editThreshold
? (
<div className='text-muted fw-bold'>
<div className='text-muted fw-bold font-monospace'>
<Countdown date={editThreshold} />
</div>
)
: null
}
/>
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} />
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub} />
<ItemButtonBar itemId={item?.id} canDelete={false} />
</Form>
)

View File

@ -4,6 +4,6 @@ import Button from 'react-bootstrap/Button'
export default function CancelButton ({ onClick }) {
const router = useRouter()
return (
<Button className='me-4 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
<Button className='me-3 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
)
}

133
components/carousel.js Normal file
View File

@ -0,0 +1,133 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import classNames from 'classnames'
import ArrowLeft from '@/svgs/arrow-left-line.svg'
import ArrowRight from '@/svgs/arrow-right-line.svg'
import styles from './carousel.module.css'
import { useShowModal } from './modal'
import { Dropdown } from 'react-bootstrap'
function useSwiping ({ moveLeft, moveRight }) {
const [touchStartX, setTouchStartX] = useState(null)
const onTouchStart = useCallback((e) => {
if (e.touches.length === 1) {
setTouchStartX(e.touches[0].clientX)
}
}, [])
const onTouchEnd = useCallback((e) => {
if (touchStartX !== null) {
const touchEndX = e.changedTouches[0].clientX
const diff = touchEndX - touchStartX
if (diff > 50) {
moveLeft()
} else if (diff < -50) {
moveRight()
}
setTouchStartX(null)
}
}, [touchStartX, moveLeft, moveRight])
useEffect(() => {
document.addEventListener('touchstart', onTouchStart)
document.addEventListener('touchend', onTouchEnd)
return () => {
document.removeEventListener('touchstart', onTouchStart)
document.removeEventListener('touchend', onTouchEnd)
}
}, [onTouchStart, onTouchEnd])
}
function useArrowKeys ({ moveLeft, moveRight }) {
const onKeyDown = useCallback((e) => {
if (e.key === 'ArrowLeft') {
moveLeft()
} else if (e.key === 'ArrowRight') {
moveRight()
}
}, [moveLeft, moveRight])
useEffect(() => {
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onKeyDown])
}
export default function Carousel ({ close, mediaArr, src, originalSrc, setOptions }) {
const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src))
const [currentSrc, canGoLeft, canGoRight] = useMemo(() => {
return [mediaArr[index][0], index > 0, index < mediaArr.length - 1]
}, [mediaArr, index])
const moveLeft = useCallback(() => {
setIndex(i => Math.max(0, i - 1))
}, [setIndex])
const moveRight = useCallback(() => {
setIndex(i => Math.min(mediaArr.length - 1, i + 1))
}, [setIndex, mediaArr.length])
useSwiping({ moveLeft, moveRight })
useArrowKeys({ moveLeft, moveRight })
return (
<div className={styles.fullScreenContainer} onClick={close}>
<img className={styles.fullScreen} src={currentSrc} />
<div className={styles.fullScreenNavContainer}>
<div
className={classNames(styles.fullScreenNav, !canGoLeft && 'invisible', styles.left)}
onClick={(e) => {
e.stopPropagation()
moveLeft()
}}
>
<ArrowLeft width={34} height={34} />
</div>
<div
className={classNames(styles.fullScreenNav, !canGoRight && 'invisible', styles.right)}
onClick={(e) => {
e.stopPropagation()
moveRight()
}}
>
<ArrowRight width={34} height={34} />
</div>
</div>
</div>
)
}
const CarouselContext = createContext()
function CarouselOverflow ({ originalSrc, rel }) {
return <Dropdown.Item href={originalSrc} rel={rel} target='_blank'>view original</Dropdown.Item>
}
export function CarouselProvider ({ children }) {
const media = useRef(new Map())
const showModal = useShowModal()
const showCarousel = useCallback(({ src }) => {
showModal((close, setOptions) => {
return <Carousel close={close} mediaArr={Array.from(media.current.entries())} src={src} setOptions={setOptions} />
}, {
fullScreen: true,
overflow: <CarouselOverflow {...media.current.get(src)} />
})
}, [showModal, media.current])
const addMedia = useCallback(({ src, originalSrc, rel }) => {
media.current.set(src, { src, originalSrc, rel })
}, [media.current])
const removeMedia = useCallback((src) => {
media.current.delete(src)
}, [media.current])
const value = useMemo(() => ({ showCarousel, addMedia, removeMedia }), [showCarousel, addMedia, removeMedia])
return <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider>
}
export function useCarousel () {
return useContext(CarouselContext)
}

View File

@ -0,0 +1,63 @@
div.fullScreenNavContainer {
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
pointer-events: none;
flex-direction: row;
display: flex;
justify-content: space-between;
align-items: center;
}
img.fullScreen {
cursor: zoom-out !important;
max-height: 100%;
max-width: 100vw;
min-width: 0;
min-height: 0;
align-self: center;
justify-self: center;
user-select: none;
}
.fullScreenContainer {
--bs-columns: 1;
--bs-rows: 1;
display: grid;
width: 100%;
height: 100%;
}
div.fullScreenNav:hover > svg {
background-color: rgba(0, 0, 0, .5);
}
div.fullScreenNav {
cursor: pointer;
pointer-events: auto;
width: 72px;
height: 72px;
display: flex;
align-items: center;
}
div.fullScreenNav.left {
justify-content: flex-start;
}
div.fullScreenNav.right {
justify-content: flex-end;
}
div.fullScreenNav > svg {
border-radius: 50%;
backdrop-filter: blur(4px);
background-color: rgba(0, 0, 0, 0.7);
fill: white;
max-height: 34px;
max-width: 34px;
padding: 0.35rem;
margin: .75rem;
}

View File

@ -18,7 +18,8 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
text () {
return result.text
}
}
},
optimistic: true
})
}
},

View File

@ -2,7 +2,7 @@ import itemStyles from './item.module.css'
import styles from './comment.module.css'
import Text, { SearchText } from './text'
import Link from 'next/link'
import Reply, { ReplyOnAnotherPage } from './reply'
import Reply from './reply'
import { useEffect, useMemo, useRef, useState } from 'react'
import UpVote from './upvote'
import Eye from '@/svgs/eye-fill.svg'
@ -25,6 +25,9 @@ import Skull from '@/svgs/death-skull.svg'
import { commentSubTreeRootId } from '@/lib/item'
import Pin from '@/svgs/pushpin-fill.svg'
import LinkToContext from './link-to-context'
import Boost from './boost-button'
import { gql, useApolloClient } from '@apollo/client'
import classNames from 'classnames'
function Parent ({ item, rootText }) {
const root = useRoot()
@ -79,6 +82,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
<LinkToContext
className='py-2'
onClick={e => {
e.preventDefault()
router.push(href, as)
}}
href={href}
@ -93,13 +97,14 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
export default function Comment ({
item, children, replyOpen, includeParent, topLevel,
rootText, noComments, noReply, truncate, depth, pin
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry
}) {
const [edit, setEdit] = useState()
const me = useMe()
const isHiddenFreebie = !me?.privates?.wildWestMode && !me?.privates?.greeterMode && !item.mine && item.freebie && !item.freedFreebie
const { me } = useMe()
const isHiddenFreebie = me?.privates?.satsFilter !== 0 && !item.mine && item.freebie && !item.freedFreebie
const isDeletedChildless = item?.ncomments === 0 && item?.deletedAt
const [collapse, setCollapse] = useState(
(isHiddenFreebie || item?.user?.meMute || (item?.outlawed && !me?.privates?.wildWestMode)) && !includeParent
(isHiddenFreebie || isDeletedChildless || item?.user?.meMute || (item?.outlawed && !me?.privates?.wildWestMode)) && !includeParent
? 'yep'
: 'nope')
const ref = useRef(null)
@ -107,16 +112,32 @@ export default function Comment ({
const root = useRoot()
const { ref: textRef, quote, quoteReply, cancelQuote } = useQuoteReply({ text: item.text })
const { cache } = useApolloClient()
useEffect(() => {
const comment = cache.readFragment({
id: `Item:${router.query.commentId}`,
fragment: gql`
fragment CommentPath on Item {
path
}`
})
if (comment?.path.split('.').includes(item.id)) {
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}
setCollapse(window.localStorage.getItem(`commentCollapse:${item.id}`) || collapse)
if (Number(router.query.commentId) === Number(item.id)) {
// HACK wait for other comments to collapse if they're collapsed
// HACK wait for other comments to uncollapse if they're collapsed
setTimeout(() => {
ref.current.scrollIntoView({ behavior: 'instant', block: 'start' })
// make sure we can outline a comment again if it was already outlined before
ref.current.addEventListener('animationend', () => {
ref.current.classList.remove('outline-it')
}, { once: true })
ref.current.classList.add('outline-it')
}, 100)
}
}, [item.id, router.query.commentId])
}, [item.id, cache, router.query.commentId])
useEffect(() => {
if (router.query.commentsViewedAt &&
@ -126,7 +147,7 @@ export default function Comment ({
}
}, [item.id])
const bottomedOut = depth === COMMENT_DEPTH_LIMIT
const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0)
// Don't show OP badge when anon user comments on anon user posts
const op = root.user.name === item.user.name && Number(item.user.id) !== USER_ID.anon
? 'OP'
@ -144,9 +165,11 @@ export default function Comment ({
<div className={`${itemStyles.item} ${styles.item}`}>
{item.outlawed && !me?.privates?.wildWestMode
? <Skull className={styles.dontLike} width={24} height={24} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
: item.mine
? <Boost item={item} className={styles.upvote} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} collapsed={collapse === 'yep'} />}
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'>
{item.user?.meMute && !includeParent && collapse === 'yep'
@ -166,6 +189,8 @@ export default function Comment ({
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
onQuoteReply={quoteReply}
nested={!includeParent}
setDisableRetry={setDisableRetry}
disableRetry={disableRetry}
extraInfo={
<>
{includeParent && <Parent item={item} rootText={rootText} />}
@ -175,7 +200,8 @@ export default function Comment ({
</ActionTooltip>}
</>
}
onEdit={e => { setEdit(!edit) }}
edit={edit}
toggleEdit={e => { setEdit(!edit) }}
editText={edit ? 'cancel' : 'edit'}
/>}
@ -223,7 +249,7 @@ export default function Comment ({
</div>
{collapse !== 'yep' && (
bottomedOut
? <div className={styles.children}><ReplyOnAnotherPage item={item} /></div>
? <div className={styles.children}><div className={classNames(styles.comment, 'mt-3')}><ReplyOnAnotherPage item={item} /></div></div>
: (
<div className={styles.children}>
{item.outlawed && !me?.privates?.wildWestMode
@ -234,11 +260,17 @@ export default function Comment ({
</Reply>}
{children}
<div className={styles.comments}>
{item.comments && !noComments
? item.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
))
{!noComments && item.comments?.comments
? (
<>
{item.comments.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
))}
{item.comments.comments.length < item.nDirectComments && <ViewAllReplies id={item.id} nhas={item.ncomments} />}
</>
)
: null}
{/* TODO: add link to more comments if they're limited */}
</div>
</div>
)
@ -247,6 +279,34 @@ export default function Comment ({
)
}
export function ViewAllReplies ({ id, nshown, nhas }) {
const text = `view all ${nhas} replies`
return (
<div className={`d-block fw-bold ${styles.comment} pb-2 ps-3`}>
<Link href={`/items/${id}`} as={`/items/${id}`} className='text-muted'>
{text}
</Link>
</div>
)
}
function ReplyOnAnotherPage ({ item }) {
const root = useRoot()
const rootId = commentSubTreeRootId(item, root)
let text = 'reply on another page'
if (item.ncomments > 0) {
text = `view all ${item.ncomments} replies`
}
return (
<Link href={`/items/${rootId}?commentId=${item.id}`} as={`/items/${rootId}`} className='d-block pb-2 fw-bold text-muted'>
{text}
</Link>
)
}
export function CommentSkeleton ({ skeletonChildren }) {
return (
<div className={styles.comment}>

View File

@ -1,4 +1,4 @@
import { Fragment } from 'react'
import { Fragment, useMemo } from 'react'
import Comment, { CommentSkeleton } from './comment'
import styles from './header.module.css'
import Nav from 'react-bootstrap/Nav'
@ -6,6 +6,8 @@ import Navbar from 'react-bootstrap/Navbar'
import { numWithUnits } from '@/lib/format'
import { defaultCommentSort } from '@/lib/item'
import { useRouter } from 'next/router'
import MoreFooter from './more-footer'
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter()
@ -60,10 +62,13 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
)
}
export default function Comments ({ parentId, pinned, bio, parentCreatedAt, commentSats, comments, ...props }) {
export default function Comments ({
parentId, pinned, bio, parentCreatedAt,
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, ...props
}) {
const router = useRouter()
const pins = comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position)
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
return (
<>
@ -91,6 +96,12 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
{comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} {...props} />
))}
{ncomments > FULL_COMMENTS_THRESHOLD &&
<MoreFooter
cursor={commentsCursor} fetchMore={fetchMoreComments} noMoreText=' '
count={comments?.length}
Skeleton={CommentsSkeleton}
/>}
</>
)
}

View File

@ -43,7 +43,7 @@ export function CompactLongCountdown (props) {
? ` ${props.formatted.hours}:${props.formatted.minutes}:${props.formatted.seconds}`
: Number(props.formatted.minutes) > 0
? ` ${props.formatted.minutes}:${props.formatted.seconds}`
: Number(props.formatted.seconds) > 0
: Number(props.formatted.seconds) >= 0
? ` ${props.formatted.seconds}s`
: ' '}
</>

View File

@ -34,20 +34,36 @@ const setTheme = (dark) => {
const listenForThemeChange = (onChange) => {
const mql = window.matchMedia(PREFER_DARK_QUERY)
mql.onchange = mql => {
const onMqlChange = () => {
const { user, dark } = getTheme()
if (!user) {
handleThemeChange(dark)
onChange({ user, dark })
}
}
window.onstorage = e => {
mql.addEventListener('change', onMqlChange)
const onStorage = (e) => {
if (e.key === STORAGE_KEY) {
const dark = JSON.parse(e.newValue)
setTheme(dark)
onChange({ user: true, dark })
}
}
window.addEventListener('storage', onStorage)
const root = window.document.documentElement
const observer = new window.MutationObserver(() => {
const theme = root.getAttribute('data-bs-theme')
onChange(dark => ({ ...dark, dark: theme === 'dark' }))
})
observer.observe(root, { attributes: true, attributeFilter: ['data-bs-theme'] })
return () => {
observer.disconnect()
mql.removeEventListener('change', onMqlChange)
window.removeEventListener('storage', onStorage)
}
}
export default function useDarkMode () {
@ -56,7 +72,7 @@ export default function useDarkMode () {
useEffect(() => {
const { user, dark } = getTheme()
setDark({ user, dark })
listenForThemeChange(setDark)
return listenForThemeChange(setDark)
}, [])
return [dark?.dark, () => {

View File

@ -30,7 +30,8 @@ export default function Delete ({ itemId, children, onDelete, type = 'post' }) {
url: () => deleteItem.url,
pollCost: () => deleteItem.pollCost,
deletedAt: () => deleteItem.deletedAt
}
},
optimistic: true
})
}
}

View File

@ -22,7 +22,7 @@ export function DiscussionForm ({
}) {
const router = useRouter()
const client = useApolloClient()
const me = useMe()
const { me } = useMe()
const onSubmit = useItemSubmit(UPSERT_DISCUSSION, { item, sub })
const schema = discussionSchema({ client, me, existingBoost: item?.boost })
// if Web Share Target API was used
@ -76,10 +76,10 @@ export function DiscussionForm ({
name='text'
minRows={6}
hint={editThreshold
? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div>
? <div className='text-muted fw-bold font-monospace'><Countdown date={editThreshold} /></div>
: null}
/>
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} />
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub} />
<ItemButtonBar itemId={item?.id} />
{!item &&
<div className={`mt-3 ${related.length > 0 ? '' : 'invisible'}`}>

View File

@ -17,7 +17,12 @@ export function DownZap ({ item, ...props }) {
}
: undefined), [meDontLikeSats])
return (
<DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
<DownZapper
item={item} As={({ ...oprops }) =>
<div className='upvoteParent'>
<Flag {...props} {...oprops} style={style} />
</div>}
/>
)
}
@ -31,7 +36,7 @@ function DownZapper ({ item, As, children }) {
try {
showModal(onClose =>
<ItemAct
onClose={onClose} item={item} down
onClose={onClose} item={item} act='DONT_LIKE_THIS'
>
<AccordianItem
header='what is a downzap?' body={
@ -79,7 +84,8 @@ export function OutlawDropdownItem ({ item }) {
id: `Item:${item.id}`,
fields: {
outlawed: () => true
}
},
optimistic: true
})
}
}

215
components/embed.js Normal file
View File

@ -0,0 +1,215 @@
import { memo, useEffect, useRef, useState } from 'react'
import classNames from 'classnames'
import useDarkMode from './dark-mode'
import styles from './text.module.css'
import { Button } from 'react-bootstrap'
import { TwitterTweetEmbed } from 'react-twitter-embed'
import YouTube from 'react-youtube'
function TweetSkeleton ({ className }) {
return (
<div className={classNames(styles.tweetsSkeleton, className)}>
<div className={styles.tweetSkeleton}>
<div className={`${styles.img} clouds`} />
<div className={styles.content1}>
<div className={`${styles.line} clouds`} />
<div className={`${styles.line} clouds`} />
<div className={`${styles.line} clouds`} />
</div>
</div>
</div>
)
}
export const NostrEmbed = memo(function NostrEmbed ({ src, className, topLevel, darkMode, id }) {
const [show, setShow] = useState(false)
const iframeRef = useRef(null)
useEffect(() => {
if (!iframeRef.current) return
const setHeightFromIframe = (e) => {
if (e.origin !== 'https://njump.me' || !e?.data?.height || e.source !== iframeRef.current.contentWindow) return
iframeRef.current.height = `${e.data.height}px`
}
window?.addEventListener('message', setHeightFromIframe)
const handleIframeLoad = () => {
iframeRef.current.contentWindow.postMessage({ setDarkMode: darkMode }, '*')
}
if (iframeRef.current.complete) {
handleIframeLoad()
} else {
iframeRef.current.addEventListener('load', handleIframeLoad)
}
// https://github.com/vercel/next.js/issues/39451
iframeRef.current.src = `https://njump.me/${id}?embed=yes`
return () => {
window?.removeEventListener('message', setHeightFromIframe)
iframeRef.current?.removeEventListener('load', handleIframeLoad)
}
}, [iframeRef.current, darkMode])
return (
<div className={classNames(styles.nostrContainer, !show && styles.twitterContained, className)}>
<iframe
ref={iframeRef}
width={topLevel ? '550px' : '350px'}
style={{ maxWidth: '100%' }}
height={iframeRef.current?.height || (topLevel ? '200px' : '150px')}
frameBorder='0'
sandbox='allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox'
allow=''
/>
{!show &&
<Button size='md' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
<div>show full note</div>
<small className='fw-normal fst-italic'>or other stuff</small>
</Button>}
</div>
)
})
const SpotifyEmbed = function SpotifyEmbed ({ src, className }) {
const iframeRef = useRef(null)
// https://open.spotify.com/track/1KFxcj3MZrpBGiGA8ZWriv?si=f024c3aa52294aa1
// Remove any additional path segments
const url = new URL(src)
url.pathname = url.pathname.replace(/\/intl-\w+\//, '/')
useEffect(() => {
if (!iframeRef.current) return
const id = url.pathname.split('/').pop()
// https://developer.spotify.com/documentation/embeds/tutorials/using-the-iframe-api
window.onSpotifyIframeApiReady = (IFrameAPI) => {
const options = {
uri: `spotify:episode:${id}`
}
const callback = (EmbedController) => {}
IFrameAPI.createController(iframeRef.current, options, callback)
}
return () => { window.onSpotifyIframeApiReady = null }
}, [iframeRef.current, url.pathname])
return (
<div className={classNames(styles.spotifyWrapper, className)}>
<iframe
ref={iframeRef}
title='Spotify Web Player'
src={`https://open.spotify.com/embed${url.pathname}`}
width='100%'
height='152'
allowFullScreen
frameBorder='0'
allow='encrypted-media; clipboard-write;'
style={{ borderRadius: '12px' }}
sandbox='allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-presentation'
/>
</div>
)
}
const Embed = memo(function Embed ({ src, provider, id, meta, className, topLevel, onError }) {
const [darkMode] = useDarkMode()
const [overflowing, setOverflowing] = useState(true)
const [show, setShow] = useState(false)
// This Twitter embed could use similar logic to the video embeds below
if (provider === 'twitter') {
return (
<div className={classNames(styles.twitterContainer, !show && styles.twitterContained, className)}>
<TwitterTweetEmbed
tweetId={id}
options={{ theme: darkMode ? 'dark' : 'light', width: topLevel ? '550px' : '350px' }}
key={darkMode ? '1' : '2'}
placeholder={<TweetSkeleton className={className} />}
onLoad={() => setOverflowing(true)}
/>
{overflowing && !show &&
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
show full tweet
</Button>}
</div>
)
}
if (provider === 'nostr') {
return (
<NostrEmbed src={src} className={className} topLevel={topLevel} id={id} darkMode={darkMode} />
)
}
if (provider === 'wavlake') {
return (
<div className={classNames(styles.wavlakeWrapper, className)}>
<iframe
src={`https://embed.wavlake.com/track/${id}`} width='100%' height='380' frameBorder='0'
allow='encrypted-media'
sandbox='allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-same-origin'
/>
</div>
)
}
if (provider === 'spotify') {
return (
<SpotifyEmbed src={src} className={className} />
)
}
if (provider === 'youtube') {
return (
<div className={classNames(styles.videoWrapper, className)}>
<YouTube
videoId={id} className={styles.videoContainer} opts={{
playerVars: {
start: meta?.start || 0
}
}}
/>
</div>
)
}
if (provider === 'rumble') {
return (
<div className={classNames(styles.videoWrapper, className)}>
<div className={styles.videoContainer}>
<iframe
title='Rumble Video'
allowFullScreen
src={meta?.href}
sandbox='allow-scripts'
/>
</div>
</div>
)
}
if (provider === 'peertube') {
return (
<div className={classNames(styles.videoWrapper, className)}>
<div className={styles.videoContainer}>
<iframe
title='PeerTube Video'
allowFullScreen
src={meta?.href}
sandbox='allow-scripts'
/>
</div>
</div>
)
}
return null
})
export default Embed

View File

@ -6,7 +6,7 @@ import copy from 'clipboard-copy'
import { LoggerContext } from './logger'
import Button from 'react-bootstrap/Button'
import { useToast } from './toast'
import { decodeMinifiedStackTrace } from '@/lib/stacktrace'
class ErrorBoundary extends Component {
constructor (props) {
super(props)
@ -27,7 +27,7 @@ class ErrorBoundary extends Component {
getErrorDetails () {
let details = this.state.error.stack
if (this.state.errorInfo?.componentStack) {
details += `\n\nComponent stack:${this.state.errorInfo.componentStack}`
details += `\n\nComponent stack:\n ${this.state.errorInfo.componentStack}`
}
return details
}
@ -69,7 +69,8 @@ const CopyErrorButton = ({ errorDetails }) => {
const toaster = useToast()
const onClick = async () => {
try {
await copy(errorDetails)
const decodedDetails = await decodeMinifiedStackTrace(errorDetails)
await copy(decodedDetails)
toaster?.success?.('copied')
} catch (err) {
console.error(err)

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