Compare commits

...

287 Commits

Author SHA1 Message Date
ekzyis
71ce403b0c
Replace GIFs with WebP (#2416)
* Rename maze.gif to maze.webp

* Replace gif, mp4 with webp
2025-08-12 12:03:55 -05:00
github-actions[bot]
aef2cfc199
Extending awards.csv (#2414)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-08-10 15:43:42 -05:00
pory
d9d968f0fa
fixing line numbers on error traces in development (#2413)
Co-authored-by: porygone-z <porygone-z@kali>
2025-08-10 15:02:23 -05:00
ekzyis
21a9696ea0
Use SSR for wallets (#2397)
* Use SSR for wallet forms

* Fix back/forward navigation with useData hook

* Fix protocol fallback not working with shallow routing

* Fix wallet refetch

* Replace useEffect for default selection with smart link

* Remove unused useWalletQuery

* Move server2client wallet transform into single function

* Add comment about graphql-tag fragment warning

* Check if wallet not found

* Handle wallet is sometimes null on back or forward navigation
2025-08-10 12:15:40 -05:00
ekzyis
abfe54125a
Fix GraphQL cannot represent bigint as integer (#2412)
* Fix GraphQL cannot represent bigint as integer

* Fix uploadFeesMsats not returned
2025-08-10 12:10:49 -05:00
k00b
438b5041f1 Revert "auto show: Use textarea as the anchor element if available (#2407)"
This reverts commit 1379c419df836e064d0a3f904442a7af9c4b5842.
2025-08-09 18:19:34 -05:00
k00b
3c637e5ec2 add favicon for notifications back 2025-08-09 18:15:47 -05:00
soxa
1379c419df
auto show: Use textarea as the anchor element if available (#2407)
* enhance: use textarea as the anchor element if available

* correct only downwards vertical layout shifts
2025-08-09 15:35:48 -05:00
soxa
dea8945e43
fix wrong URL on Reply on another page (#2410)
* fix wrong URL on Reply on another page

* better naming and explanation for bottomed out comments
2025-08-09 15:14:29 -05:00
ekzyis
d5a2573657
Add proxy fee to minSendable of lnurl-pay (#2404) 2025-08-08 11:05:33 -05:00
soxa
0e842e9915
live comments: auto show new comments (#2355)
* enhance: FaviconProvider, keep track of new comment IDs to change favicon, remove new comment IDs per outline removal

* don't track oneself comments

* enhance: auto-show new comments, idempotency by ignoring already injected comments, preserveScroll utility

* fadeIn animation on comment injection; cleanup: remove unused counts and thread handling; non-critical fix: always give rootLastCommentAt a value

* reliably preserve scroll position by tracking a reference found at the center of the viewport; cleanup: add more comments, add cleanup function

* mitigate fractional scrolling subtle layout shifts by rounding the new reference element position

* enhanced outlining system, favicon context keeps track of new comments presence

- de-outlining now happens only for outlined comments
- enhanced outlining: add outline only if isNewComment
- de-outlining will remove the new comments favicon
- on unmount remove the new comments favicon

* remove the new comments favicon on new comments injection

* track only deduplicated new comments

* fix typo

* clearer unsetOutline conditions, fix typo in live comments hook

* backport: remove the injectedComment class from injected comments after animation ends

* set the new comments favicon on any new outlined comment

* enhance: directly inject new comments; cleanup: dismantle ShowNewComments, remove newComments field

* tweaks: slower injection animation, clear favicon on Comment section unmount

* change nDirectComments bug strategy to avoiding updates on comment edit

* cleanup: better naming, re-instate injected comments outline

* injection: major cache utilities refactor, don't preserve scroll if no comments have been injected

- don't preserve scroll if after deduplication we don't inject any comments

- use manual read/write cache updates to control the flow
-- allows to check if we are really injecting or not

- reduce polling to 5 seconds instead of 10

- light cleanup
-- removed update cache functions
-- added 'injected' to typeDefs (gql consistency)

* cleanup: detailed comments, refactor, remove clutter

Refactor:
+ clearer variables
+ depth calculation utility function
+ use destructured Apollo cache
+ extract item object from item query
+ skip ignored comment instead of ending the loop

CSS:
+ from-to fadeIn animation keyframes
- floatingComments unused class

Favicon:
+ provider exported by default

* fix wrong merge

* split: remove favicon context

* split: remove favicon pngs

* regression: revert to updateQuery for multiple comment fragments handling

* reverse multiple reads for deduplication on comment injection

* fix regression on apollo manipulations via fn; cleanup: remove wrong deps from outlining
2025-08-08 10:04:54 -05:00
ekzyis
1bda8a6de2
/wallets/debug: fix getDate is not a function (#2402)
* Fix getDate is not a function

* Remove 'ago' suffix because of possible DD-MM-YYY format

* Remove unnecessary string interpolation
2025-08-07 19:00:43 -05:00
ekzyis
7a7ed1745c
Fix territory edits (#2403) 2025-08-07 18:59:53 -05:00
k00b
44992fd1bf add kpi script 2025-08-06 18:32:37 -05:00
soxa
e0ddba09a8
fix: exclude child comments related fields from comment edit mutation (#2391) 2025-08-06 11:37:28 -05:00
ekzyis
0781156305
Fix protocolId missing as dependency (#2396) 2025-08-05 11:48:28 -05:00
ekzyis
b96b5d0c89
Remove unused keyCheck hook (#2395) 2025-08-05 11:47:25 -05:00
ekzyis
cbc41c0d99
Fix wallet_updated_at_trigger on wallet delete (#2394) 2025-08-04 17:07:47 -05:00
github-actions[bot]
39bbaf2942
Extending awards.csv (#2393)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-08-04 09:58:04 -05:00
pory
7a499f59a8
fix link to /daily in CCinfo loads forever (#2381)
* fix #2370

* don't rely on rewrite for internal links to daily

---------

Co-authored-by: porygone-z <porygone-z@kali>
Co-authored-by: k00b <k00b@stacker.news>
2025-08-04 09:56:31 -05:00
k00b
77e3f6aed1 reduce daily stimulus 2025-08-03 13:02:12 -05:00
ekzyis
8384f866b4
Fix autoWithdraw settings schema (#2388) 2025-08-03 12:27:11 -05:00
ekzyis
6fa1c226ae
Fix link to dust limit settings in CCInfo (#2387) 2025-08-03 12:24:17 -05:00
ekzyis
7c6a65c332
Wallet tests as separate mutations (#2385)
* Rename mutation to UPSERT_WALLET_RECEIVE_LND_GRPC

* Move wallet typedefs into individual sections

* Split wallet tests into separate mutation

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-08-03 12:23:56 -05:00
ekzyis
21532509fb
Rename mutation to UPSERT_WALLET_RECEIVE_LND_GRPC (#2384) 2025-08-03 12:18:01 -05:00
ekzyis
067d9069cb
improvements to upload fees code (#2382)
* Use $queryRaw instead of $queryRawUnsafe

* Replace comment with destructuring

* Return all upload fees as BigInt
2025-08-03 12:17:34 -05:00
ekzyis
1bcc864ef4
Better upload fee info (#2380)
* Better upload fee info

* Calculate total from info shown to user
2025-08-03 12:16:16 -05:00
ekzyis
f58b853e8b
Fix input type of url for NWC receive (#2389) 2025-08-03 07:42:52 +02:00
ekzyis
6d244a5de6
Handle uploads in territory descriptions (#2379)
* Remove unused parameter

* Mark uploads as paid on territory create and update

* Refactor upload expiry check

* Check upload expiry on territory create

* Include upload fees in territory create/update cost

* Also check for expired uploads on edits

* Find deleted uploads with one query
2025-08-01 19:40:15 -05:00
k00b
45acbaa4fa pay pory 2025-08-01 18:34:49 -05:00
ekzyis
bc569be34a
Fix spacing if no badges (#2376) 2025-07-31 18:04:12 -05:00
ekzyis
416b675a2f
Format GraphQL wallet stuff + string interpolation (#2375)
* Format GraphQL wallet stuff

* Use string interpolation for GraphQL wallet stuff
2025-07-31 15:01:54 -05:00
k00b
7247083b72 pay pending awards 2025-07-31 13:02:14 -05:00
github-actions[bot]
d392bbe3b5
Extending awards.csv (#2374)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-07-31 13:00:11 -05:00
Edward Kung
0299bbe4bc
fix hashtag links opening in new tabs (#2373) 2025-07-31 12:58:58 -05:00
github-actions[bot]
c77d10dad2
Extending awards.csv (#2372)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-07-31 10:37:39 -05:00
Bryan Mutai
da9287f715
Refactor issue extraction logic in findIssuesInPR function (#2332) 2025-07-31 10:00:51 -05:00
ekzyis
7857601c36
wallet logs: less visual clutter, refactor (#2369)
* Remove unnecessary initial state for template logs

* Rename skip to noFetch

* Remove outdated TODO

* Cleaner wallet template logs + refactor
2025-07-31 09:58:34 -05:00
soxa
1aeb206842
fix: prevent GET_NEW_COMMENTS query from running in-between renders (#2345) 2025-07-30 12:35:38 -05:00
ekzyis
d175d0e64d
Fix missing validation for NWC receive (#2365) 2025-07-30 11:37:25 -05:00
ekzyis
6aeffa7aff
Add Blitz wallet (#2353) 2025-07-29 10:59:42 -05:00
ekzyis
5f7d0ead1d
Update instructions to add new wallet (#2352) 2025-07-29 10:59:02 -05:00
ekzyis
2fa2f0baea
Add flake.lock (#2351) 2025-07-29 16:55:52 +02:00
ekzyis
1dc4018a3c
Use touch-action: pan-y to fix DnD vs scroll (#2350)
* Fix handleTouchMove not handling leaving elements

* Fix DnD vs scroll on mobile
2025-07-28 17:11:21 -05:00
ekzyis
0968c77bdf
Fix missing reuse of nav.module.css (#2349)
* Fix autoprefixer warning

* Fix missing reuse of nav.module.css
2025-07-28 17:10:30 -05:00
ekzyis
454cb55f7f
Fix autoprefixer warning (#2348) 2025-07-28 17:09:42 -05:00
soxa
9c8071339f
Declarative Web Push support (#2300)
* Declarative Web Push support, standardized JSON format

TODOs:
- sane app badge count

* URL backwards compatibility, add icon to the JSON payload, fix malformed payload recognition on classic push notifications

* typo: wrong app_badge placement in JSON payload

* adapt declarative JSON payload for legacy Push API using spec-conformant transformations
2025-07-28 17:09:13 -05:00
ekzyis
20147cae15
Fix undefined in resolved WalletTemplate id (#2344) 2025-07-27 22:04:22 -05:00
ekzyis
cf5ac8272d
Handle error property in NWC response (#2343) 2025-07-27 14:12:49 -05:00
ekzyis
a827dc6fde
Use x-overflow for tab navigation (#2337)
* Use x-overflow for tab navigation

* Define all CSS for tabs in nav.module.css
2025-07-27 12:37:25 -05:00
soxa
a4a0fdb060
Fix live comments behavior on paginated comments and threads (#2334)
* livecomments: patches for paginated comments; broader ViewMoreReplies component

live comments:
- don't show the thread button for thread comments that are shown as full items (top level)
- don't try to count/inject on paginated comments, just show the live comments dot
- dedupe new comments being fetched with already existing comments, useful for just showing the dot, but not the button

comments:
- live comments dot now appears on both paginated and bottomed out comments
-- merge ViewAllReplies with ReplyToAnotherPage

* fix thread comment recognition, now based on depth
2025-07-26 18:06:22 -05:00
ekzyis
ef1c586231
Replace text-wrap with white-space because of better support (#2338) 2025-07-26 17:24:11 -05:00
ekzyis
ec5ea1bc5f
Extending awards.csv (#2335)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-07-26 12:48:03 -05:00
ekzyis
be1b497dfd
Add review guideline for cursor bot (#2336)
see https://docs.cursor.com/bugbot#rules
2025-07-26 12:46:35 -05:00
ekzyis
f14c0ed0e4
Extending awards.csv (#2328) 2025-07-25 10:14:18 -05:00
pory
09f8f12314
reduce anon itemCreate cost from 100 to 10 (#2316)
* reduce anon itemCreate cost from 100 to 10

* chore: format code with StandardJS

---------

Co-authored-by: porygone-z <porygone-z@kali>
Co-authored-by: ekzyis <ek@stacker.news>
2025-07-25 09:56:52 -05:00
ekzyis
0155946d74
Fix carousel dropdown (#2326)
Co-authored-by: brymut <mutaiwork@gmail.com>
2025-07-24 14:18:19 -05:00
soxa
9e2c35c641
Fix non-thread live comments recursion logic (#2324)
* fix: recurse through existing comments only if we're in the newComments subtree or if it's the start of a thread

* cleanup: better comment

* cleanup: re-order parameters, comment touchup
2025-07-24 12:28:26 -05:00
ekzyis
160b04ceaa
Carousel cleanup (#2325)
* Remove unused carousel args

* Remove unused export

* Remove unnecessary ref in dependencies
2025-07-24 18:07:22 +02:00
soxa
ecac519efb
Fix clear new comments on route change (#2319) 2025-07-23 23:47:53 -05:00
ekzyis
7c10ded8a6
Fix footer visible through dropdown (#2323) 2025-07-23 23:47:16 -05:00
ekzyis
84ed0be86d
Fix missing type check in maxStreak resolver (#2320) 2025-07-23 17:41:35 -05:00
Keyan
6a3155fa93
short cirtuit out of live comment query if possible (#2318) 2025-07-23 14:34:49 -05:00
ekzyis
6cc87ceac4
Update welcome script (#2317) 2025-07-23 20:34:59 +02:00
soxa
9092d90797
Enhancements to live comments (#2269)
* check new comments every 10 seconds

* enhance: clear newComments on child comments when we show a topLevel new comment; cleanup: resolvers, logs

* handle comments of comments, new structure to clear newComments on childs

* use original recursive comments data structure

* correct comment structure after deduplication

* faster newComments query deduplication, don't need to know how many comments are there

* cleanup: comments on newComments fetches and dedupes

* cleanup, use correct function declarations

* stop polling after 30 minutes, pause polling if user is not on the page

* ActionTooltip indicating that the user is in a live comment section

* handleVisibilityChange to control polling by visibility

* paused polling styling, check activity on 1 minute intervals and visibility change, light cleanup

* user can resume polling without refreshing the page

* better naming, straightforward dedupeComment on newComment arrival

* cleanup: better naming, get latest comment creation, correct order of comment injection

* cleanup: refactor live comments related functions to use-live-comments.js

* refactor: clearer naming, optimized polling and date retrieval logic, use of constants, general cleanup

* ui: place ShowNewComments in the bottom-right corner of nested comments

* fix: make updateQuery sort-aware to correctly inject the comment in the correct Item query

* cleanup: better naming; fix: usecallback on live comments component; fix leak on useEffect because of missing sort
atomic apollo cache manipulations; manage top sort not being present in item query cache
queue nested comments without a parent, retry on the next poll
fix commit messages

* fix: don't show unpaid comments; cleanup: compact cache merge/dedupe, queue comments via state

* fix: read new comments fragments to inject fresh new comments, fixing dropped comments;

ui: show amount of new comments

refactor: correct function positioning;

cleanup: useless logs

* enhance: queuedComments Ref, cache-and-network fetch policy; freshNewComments readFragment fallback to received comment

* cleanup: detailed comments and better ShowNewComment text

* fix: while showing new comments, also update ncomments for UI and pagination

* refactor: ShowNewComments is its own component; cleanup: proven useless dedupe on ShowNewComments, count nested ncomments from fresh new comments

* enhance: direct latest comment createdAt calc with reduce

* cleanup queue on unmount

* feat: live comments indicator for bottomed-out replies, ncomments updates; fix: nested comment structures

- new comments indicator for bottomed-out replies
- ncomments sync for parent and its ancestors
- limited comments fragment for comments that don't have CommentsRecursive
- reduce cache complexity by removing useless roundtrips

ux: live comments indicator on bottomedOut replies

fix: dedupe newComments before displaying ShowNewComments to avoid false positives

enhance: store ids of new comments in the cache, instead of carrying full comments that would get discarded anyway

hotfix: newComments deduplication ID mismatch, filter null comments from freshNewComments

fix: ncomments not updating for all comment levels; refactor: share Reply update ancestors' ncomments function with ShowNewComments

cleanup: better naming to indicate the total number of comments including nested comments

fix: increment parent comment ncomments

cleanup: Items that will have comments will always have a structure where item.comments is true

cleanup: reduce code complexity checking the nested comment update result instead of preventively reading the fragment

cleanup: avoid double-updating ncomments on parent

fix: don't use CommentsRecursive for bottomed-out comments

cleanup: better fragment naming; add TODO for absolute bottom comments

* enhance: give the possibility to show all new comments of a thread, even nested

* enhance: change favicon on new comments; warn: prop-drilling

* refactor: merge ShowAllNewComments with ShowNewComments, better usage of props

* hotfix: isThread should be recognized when an item has 2 items in its path

* fix regression: topLevel comments not showing

* fix: avoid trying to show new comments even after the depth limit; todo: two recursive counts might be too much

* favicon-new-comment, fix favicon showing also when there aren't new comments

* enhance: highlight new comments when shown; nit-fixes and cleanups

fixes:
- sync local commentsViewedAt on comment injection, to avoid double outline on item re-visit
- avoid double highlighting when client-side visiting an item and injecting a new comment

cleanups:
- move ShowNewComments functions to dedicated lib/comments.js
- bust auto-show enhancement due to bad useEffect usage

todos:
- two recursive counts might be too much

* cleanup: move cache manipulation functions, comments for comments.js

- lib/comments.js explanations for its functions
- itemUpdateQuery, commentUpdateFragment, getLatestCommentCreatedAt on comments.js
- format too many imports from comments.js

todo:
- we're not deduping comments for isThread, which forces us at this state, to dedupe twice

* enhance: highlight new comment with injected field, recursive injection in every case but top level; cleanups

cleanups:
- better separation of concerns for lib/comments.js
- don't show new comment count, avoiding useless complexity
- simpler topLevel/nested logic
- add comments

* backport live comments logic enhancements

use-live-comments:
- remove useless dedupe against already present comments
- check newComments.comments length to tell if there are new comments
- code reordering

show-new-comments:
- show all new comments recursively for nested comments
- get always the newest comments to inject also their own child new comments
- update local storage commentsViewedAt on comment injection
- respect depth on comment injection

comments.js
- apollo cache manipulations now live here

* hotfix: handle undefined item.comments.comments on dedupe

* hotfix: fix lint after merge

* hotfix: limited fragment for recursive comment collection; protect from null fragments; add missing deps to memoization

* merge: missing memo deps, limited fragment for non-recursive comments; fix: don't highlight injected comments with classic outline; cleanup: comments

* docs: clarify ncomments updates

* cleanup: remove unused export

* count and show only the direct new comments and recursively their children

enhance: dedupe against existing comments only in the component
enhance: recursive count/injection share the same logic

* fix regression on top level counting

* hotfix: introduce readNestedCommentsFragment in lib/comments.js

* fix: count also existing comments of a new comment; cleanup: use readCommentFragment also for prepareComments; reduce freshNewComments usage

* add support for comments at the deepest level

fixes:
- client-side navigation re-fetched all new comments because 'after' was cached, now the latest new comment time persists in sessionStorage

enhancements:
- use CommentWithNewMinimal fragment fallback for comments at the deepest level
- tweak ReplyOnAnotherPage to show also how many direct new comments are there

cleanup:
- queue management is not needed anymore, therefore it has been removed

* cleanup: remove logs

* revert counting on ReplyOnAnotherPage, TODO for enhancements PR

* move ShowNewComments to CommentsHeader for top level comments

* fix: update commentsViewedAfterComment to support ncomments

* fix typo, lint

* cleanup: remove old CSS

* enhance: inject topLevel and its children new comments, simplify injection logic

- top-level and nested comment handling share the same recursion logic
- ShowNewComments references the item object for every type of comments
— note: item from item-full.js is passed to comments.js
- depth now starts at 0 to support top level comments
- injection and counting now reach the deepest level, updating also the deepest comment

* cleanup: remove unused topLevel prop

* fix: deepest comments don't have CommentsRecursive structure, don't access it on injection

* move top level ShowNewComments above CommentsHeader; preserve space to avoid vertical layout shifting

* cleanup: remove unused item on CommentsHeader

* enhance: scroll and load new comments via a floating button using IntersectionObserver API; fix merge: restore injected field for outlining

* style: transparent and animated floating button, new comment dot color aligned to new comments accent color

* cleanup: less redundancy between the two types of buttons; enhance: show the floating button only if we're past the element, not only if it's not visible

* enhance: outline newly injected comments using root item's lastCommentAt

* cleanup: remove transparency of floating comments button, remove other traces of the injected field

* adapt and restore showing all new comments of a thread

* fix: respect deepest comments structure on injection, adjust depth limit; cleanup: consistent naming

* fix: avoid double outlines because of all conditions being met

* cleanup: remove favicon, dedicate space for useVisibility, correct comments

* ux: show all new comments of a thread only if its children have them

* mark injected comments in the cache for reliable outlining

* cleanup: clearer structure, more explaining

* optimize: better closure usage, remove duplicate code, immutable payloads

- ncomments count logic shared with injection and counting
- don't re-create and persist closures for every injection, rather temporarily on injection
- access item hierarchy once, avoid creating new arrays
- don't create and mutate payloads, rather know what to return

fixes:
- fix wrong parameters on traverseNewComments recursion

* cleanup: further clarifications

* safer rootLastCommentAt usage for injected comments outlining

* hotfix: ignore nDirectComments server updates when the item being updated has pending newComments, fixes CommentEdit consequences

* simpler show all new comments text for thread comments, regardless of how many

* fix: reference the correct Item for newComments reading, during nDirectComments apollo merge

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-07-23 11:57:36 -05:00
ekzyis
3f74279f29
Fix useMemo returns new component on change (#2315)
This fixes setting the input value on scan if the component remounts while the scanner is open.

No need to use useRef to close the scanner on remount.
2025-07-23 11:50:39 -05:00
ekzyis
243b094fcd
Wallet debug logs (#2307)
* Add wallet debug logs

* Add checkbox to toggle diagnostics

* Require authentication for /wallets/debug

* Update debug log messages

* Use me.privates.diagnostics as source of truth
2025-07-23 10:42:14 -05:00
ekzyis
2913e9a9b5
extend-awards job: fix git commit exit code, ignore Soxasora, PR author filter (#2314)
* Don't fail extend-awards job if there are no changes

* Also ignore Soxasora in extends-awards.py

* Don't run extend-awards job if PR author is ignored
2025-07-23 10:21:05 -05:00
ekzyis
e1162b815a
Close passphrase scanner on unmount (#2313) 2025-07-23 09:32:45 -05:00
ekzyis
2a0dfd7af6
Only update status if protocolId is set (#2310) 2025-07-22 18:49:39 -05:00
k00b
452fcb3659 use cache when checking territory existence so that we don't query the server on every character input 2025-07-22 18:44:42 -05:00
k00b
f63d40196d fix variable input icon alignment 2025-07-22 18:26:29 -05:00
k00b
276bb94eb9 rerender textarea on accordian show for autoresizer 2025-07-22 17:04:08 -05:00
ekzyis
8344866fca
Fix wallet logger rerender (#2306) 2025-07-22 12:38:06 -05:00
ekzyis
efefdeb0f0
Use proper grid for wallet debug info (#2305) 2025-07-22 01:15:39 +02:00
ekzyis
eb55e6ac6c
Add 'ago' suffix (#2304) 2025-07-22 00:06:29 +02:00
ekzyis
faa26ec68f
Add wallet debug page (#2301)
* Add wallet debug page

* Show key hash information

* Show last key update

* Show last wallet update

* Show last device key update
2025-07-21 15:39:09 -05:00
soxa
6b440cfdf3
Live updates to comment threads (#2115)
* check new comments every 10 seconds

* enhance: clear newComments on child comments when we show a topLevel new comment; cleanup: resolvers, logs

* handle comments of comments, new structure to clear newComments on childs

* use original recursive comments data structure

* correct comment structure after deduplication

* faster newComments query deduplication, don't need to know how many comments are there

* cleanup: comments on newComments fetches and dedupes

* cleanup, use correct function declarations

* stop polling after 30 minutes, pause polling if user is not on the page

* ActionTooltip indicating that the user is in a live comment section

* handleVisibilityChange to control polling by visibility

* paused polling styling, check activity on 1 minute intervals and visibility change, light cleanup

* user can resume polling without refreshing the page

* better naming, straightforward dedupeComment on newComment arrival

* cleanup: better naming, get latest comment creation, correct order of comment injection

* cleanup: refactor live comments related functions to use-live-comments.js

* refactor: clearer naming, optimized polling and date retrieval logic, use of constants, general cleanup

* ui: place ShowNewComments in the bottom-right corner of nested comments

* fix: make updateQuery sort-aware to correctly inject the comment in the correct Item query

* cleanup: better naming; fix: usecallback on live comments component; fix leak on useEffect because of missing sort
atomic apollo cache manipulations; manage top sort not being present in item query cache
queue nested comments without a parent, retry on the next poll
fix commit messages

* fix: don't show unpaid comments; cleanup: compact cache merge/dedupe, queue comments via state

* fix: read new comments fragments to inject fresh new comments, fixing dropped comments;

ui: show amount of new comments

refactor: correct function positioning;

cleanup: useless logs

* enhance: queuedComments Ref, cache-and-network fetch policy; freshNewComments readFragment fallback to received comment

* cleanup: detailed comments and better ShowNewComment text

* fix: while showing new comments, also update ncomments for UI and pagination

* refactor: ShowNewComments is its own component; cleanup: proven useless dedupe on ShowNewComments, count nested ncomments from fresh new comments

* enhance: direct latest comment createdAt calc with reduce

* cleanup queue on unmount

* feat: live comments indicator for bottomed-out replies, ncomments updates; fix: nested comment structures

- new comments indicator for bottomed-out replies
- ncomments sync for parent and its ancestors
- limited comments fragment for comments that don't have CommentsRecursive
- reduce cache complexity by removing useless roundtrips

ux: live comments indicator on bottomedOut replies

fix: dedupe newComments before displaying ShowNewComments to avoid false positives

enhance: store ids of new comments in the cache, instead of carrying full comments that would get discarded anyway

hotfix: newComments deduplication ID mismatch, filter null comments from freshNewComments

fix: ncomments not updating for all comment levels; refactor: share Reply update ancestors' ncomments function with ShowNewComments

cleanup: better naming to indicate the total number of comments including nested comments

fix: increment parent comment ncomments

cleanup: Items that will have comments will always have a structure where item.comments is true

cleanup: reduce code complexity checking the nested comment update result instead of preventively reading the fragment

cleanup: avoid double-updating ncomments on parent

fix: don't use CommentsRecursive for bottomed-out comments

cleanup: better fragment naming; add TODO for absolute bottom comments

* backport live comments logic enhancements

use-live-comments:
- remove useless dedupe against already present comments
- check newComments.comments length to tell if there are new comments
- code reordering

show-new-comments:
- show all new comments recursively for nested comments
- get always the newest comments to inject also their own child new comments
- update local storage commentsViewedAt on comment injection
- respect depth on comment injection

comments.js
- apollo cache manipulations now live here

* hotfix: handle undefined item.comments.comments on dedupe

* hotfix: limited fragment for recursive comment collection; protect from null fragments; add missing deps to memoization

* docs: clarify ncomments updates

* cleanup: remove unused export

* count and show only the direct new comments and recursively their children

enhance: dedupe against existing comments only in the component
enhance: recursive count/injection share the same logic

* fix regression on top level counting

* hotfix: introduce readNestedCommentsFragment in lib/comments.js

* fix: count also existing comments of a new comment; cleanup: use readCommentFragment also for prepareComments; reduce freshNewComments usage

* add support for comments at the deepest level

fixes:
- client-side navigation re-fetched all new comments because 'after' was cached, now the latest new comment time persists in sessionStorage

enhancements:
- use CommentWithNewMinimal fragment fallback for comments at the deepest level
- tweak ReplyOnAnotherPage to show also how many direct new comments are there

cleanup:
- queue management is not needed anymore, therefore it has been removed

* cleanup: remove logs

* revert counting on ReplyOnAnotherPage, TODO for enhancements PR

* move ShowNewComments to CommentsHeader for top level comments

* fix: update commentsViewedAfterComment to support ncomments

* fix typo, lint

* cleanup: remove old CSS

* enhance: inject topLevel and its children new comments, simplify injection logic

- top-level and nested comment handling share the same recursion logic
- ShowNewComments references the item object for every type of comments
— note: item from item-full.js is passed to comments.js
- depth now starts at 0 to support top level comments
- injection and counting now reach the deepest level, updating also the deepest comment

* cleanup: remove unused topLevel prop

* fix: deepest comments don't have CommentsRecursive structure, don't access it on injection

* move top level ShowNewComments above CommentsHeader; preserve space to avoid vertical layout shifting

* cleanup: remove unused item on CommentsHeader

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-07-21 15:38:15 -05:00
ekzyis
74ef0076fa
Request persistent storage on wallet save (#2302)
* Also request persistent storage on wallet save

* Request persistent storage on push subscription
2025-07-21 12:46:06 -05:00
ekzyis
ba370eeda6
wallet logger fixes (#2296)
* Fix createdAt in logger context

* Remove duplicate WalletLogEntry field resolver

* Include invoice in wallet logger context if invoice owned by user

* Also only include withdrawal in context if withdrawal owned by user
2025-07-18 14:24:29 -05:00
soxa
d4efacadc0
Fix count of viewed comments of a post (#2297)
* fix: parse existingRootComments as Number to correctly add new comments to the count

* return commentsViewedAt and commentsViewedNum parsed as Number

* hotfix: commentsViewedNum accepts itemId directly

* consistently receive itemId on new-comments functions that only uses IDs
2025-07-18 13:02:39 -05:00
ekzyis
96f149fa79
Fix anon check in SSR apollo client (#2295) 2025-07-18 12:57:28 -05:00
ekzyis
56a185d477
Fix passphrase scanner (#2292)
* Fix passphrase scanner

* Fix scanner trying to play sound on scan
2025-07-17 17:06:58 -05:00
ekzyis
d5c9ffbddf
Fix QR code scan in dark mode (#2291) 2025-07-17 12:37:07 -05:00
ekzyis
075934e20b
Fix old key overwrites new key (#2290) 2025-07-17 12:36:38 -05:00
ekzyis
f7be43d3af
Fix KEY_STORAGE_UNAVAILABLE undefined (#2287) 2025-07-16 16:48:56 -05:00
ekzyis
5502d29d7e
Fix wallets error handling order (#2286) 2025-07-16 14:50:08 -05:00
ekzyis
2ee685d5a9
Prettier error message on OperationError (#2285) 2025-07-16 13:25:31 -05:00
ekzyis
45d7eaf1bb
Show wallets query error (#2284)
* Show wallets query error to user

* Also show decryption errors to user
2025-07-16 10:11:52 -05:00
ekzyis
d670b38d1d
Fix missing catch on IDB open in key init (#2283) 2025-07-16 09:59:54 -05:00
ekzyis
0e71a85cd6
Refactor status transitions in walletsReducer (#2282) 2025-07-16 09:59:14 -05:00
ekzyis
980f6da613
Update FAQ for Wallets v2 (#2280)
* 'plaintext' instead of 'plain text'

* Update FAQ
2025-07-16 12:44:37 +02:00
ekzyis
d89a4a429a
Wallet v2 (#2169)
* Migrate vault entries to new schema (#2092)

* Migrate existing vault entries to new schema

* Read+write new vault schema

* Drop VaultEntry table

* Refactor vaultPrismaFragments

* Remove wrong comment

* Remove TODO

* Fix possible race condition on update of vault key

* Remove lib/object.js

* Wallet schema v2 (#2146)

* Add wallet-v2 TODOs

* Update checkWallet

* Wallet list

* Delete almost all wallet v1 code

and add some code for wallet protocol forms

* Define protocol display name in JSON

* Show form per protocol

* Increase max-height of image in form

* Add JSdoc for protocols, form validation

* Use wallet cards again

My wallet list was quite ugly and I couldn't look at it anymore.

* Refactor hooks in wallet provider

* Fix PasswordInput not used

* Read encrypted wallets

* Decrypt wallets

* useWalletQuery now returns decrypted wallets
* Refactor useIndexedDB because its only purpose will be to store the key, so no need for pagination code etc.
* There is still a bug: if the wallet is not decrypted on first render, the form will not see the decrypted value. See TODO.

* Rename protocolJson to protocol

it no longer uses a JSON file

* Fix form not updated with decrypted API key

* Fix wallet template forms

* Fix optional shown as hint

* Rename to mapUserWalletResolveTypes

* Save LNbits send and recv

TODO:

* implement resolvers for other protocols
* fix double update required for trigger?
* add missing validation on server
* add missing network tests
* don't import from wallets/client on server

* Move definitions to lib/wallets.json and lib/protocols

* Fix ProtocolWallet.updated_at not updated by trigger

* Move wallet fragments into wallets/client/fragments/

* move invoice fragments to fragments/invoice.js
* remove some unused fragments that I don't think I also will not use
* move fragments that will be generated in own file

* Move wallet resolvers into wallets/server/resolvers

* Fix missing authorization check on wallet update

* Run all shared code in generic wallet update function

* Fix 'encrypt' flag not set for blink send currency

* Add mutations for all protocols

* Fix macaroon validation

* Fix CLN socket value not set

* Add server-side schema validation

* Fix JSDoc typedef for protocols

* Don't put JSDoc into separate file

* Create test invoices on save

* Also move type resolvers into wallets/server/resolvers

* Fix unconfigured protocols of UserWallet not found

* Fix Blink API key in wallet seed

* Test send payment on save (except LNC)

This does not include LNC because LNC cannot be saved yet

* Check if window.webln is defined on save

* Create new wallets from templates

* Separate protocols in wallets/lib into individual files

* Use justify-content-start for protocol tabs

and larger margin at the top

* Add LNC to client protocols

* Only return wallets from useWallets

* Query decrypted wallets

* Payments with new wallets

* More wallet logos

* Fix TypeError in useIndexedDB

* Add protocol attach docs

* Fix undefined useWalletRecvPrompt import

* Remove outdated TODOs

* First successful zap to new wallets

* Fix walletLogger imports

* Fix sequences

* the sequences for InvoiceForward and DirectPayment were still starting at 1
* when using setval() with two arguments, nextval() will return the second argument+1 (see https://www.postgresql.org/docs/current/functions-sequence.html)

* Rename ProtocolWallet columns

* Remove more outdated TODOs

* Update wallet indicator

* Fix page reset on route change

* Refactor __typename checks into functions

* Refactor protocol selection into own hook

* Add button to detach protocol

* Refetch wallet on save and detach

* Refetch wallets on change

* Always show all templates

* Refactor WalletLink component

* Also put wallet into forms context

* Remove outdated TODOs

* Use useMemo in wallets hooks

* Passphrase modals

* prompt for password if decryption failed
* add button to reveal passphrase on wallet page

TODO:
* remove button if passphrase was revealed or imported
* encrypt wallets with new key on passphrase reveal

* Fix protocol missing as callback dependency

* Encrypt wallets with new key on passphrase export

* Update 'unlock wallets' text

* Rename wallet mutation hooks

* Remove 'removeWallet' mutation

Wallets are automatically deleted when all protocols are deleted

* Passphrase reset

* Use 110px as minimum width for bip39 words

longest bip39 words are 8 characters and they fit into 103px so I rounded up to 110px.

* Also disable passphrase export on save

* Wallet settings

* Fix wallet receive prompt

* Remove unused parameters from postgres function

* Rename UserWallet to Wallet, ProtocolWallet to WalletProtocol

* Use danger variant for button to show passphrase

* Fix inconsistent imports and exports

* Remove outdated TODOs

* wallet logs

* Remove outdated comment

* Make sure wallets are used in priority order

* Separate wallets from templates in reducer

* Fix missing useCallback dependencies

* Refactor with useWalletLogger hook

* Move enabled to WalletProtocol

* Add checkbox to enable/disable protocol

* Fix migration with prod db dump

* Parse Coinos relay URLs

* Skip network tests if only enabled changed

* Allow IndexedDB calls without session

* Add code to migrate old CryptoKey

* first try to use existing CryptoKey before generating a new one
* bump IDB version to delete old object stores and create new ones
* return IDB callbacks with useMemo
* don't delete old IDB right away, wait until next release

* Fix ghost import error

*Sometimes*, I get import errors because it tries to resolve @/wallets/server to wallets/server.js instead of wallets/server/index.js.

For the files in wallets/server, it kind of makes sense because it's a circular import.

But I don't know why the files in worker/ have this problem.

Interestingly, it only seems to happen with walletLogger imports, so I guess its related to its import chain.

Anyway, this commit should make sure this never happens again ...

* Skip wallets queries if not logged in

* Split CUSTOM wallet into NWC and LN_ADDR

* Migrate local wallets

* Link to /wallets/:id/receive if send not supported

* Hide separator if there are no configured wallets

* Save LNC

* Add one-liner to attach LNC

* Update wallet priorities via DnD

* Wallet logs are part of protocol resolvers

* Fix logging to deleted protocol

* Fix trying to fetch logs for template

* also change type to Int so GraphQL layer can catch trying to fetch string IDs as is the case for templates

* Fix embedded flag for wallets logs not set

* Remove TODO

* Decrease max-height for embedded wallet logs on big screens

* Fix missing refetch on wallet priority update

* Set priorities of all wallets in one tx

* Fix nested state update

* Add DragIcon

* DnD mobile support and refactor

* Add CancelButton to wallet settings

* Remount form if path changes

This fixes the following warning in the console:

"""
Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component.
"""

* Support string and object for wallet.image JSON

* Append domain to lightning address inputs

* Remove outdated TODOs

* Add template IDs to wallet JSON

* Fix missing callback dependency

* Implement lightning address save in receive prompt

* Update TODOs

* Fix missing check for enabled

* Pay QR codes with WebLN as anon

* Add logo for NWC

* Fix trying to save logs for template

* Add template logs

* Fix inconsistent margin

* Always throw on missing key

* Remove misleading comment

Wallets are returned even if decryption fails so we can show the unlock page if a wallet is stored as encrypted in the context.

Maybe I should rethink this.

* Check for existing wallets on local wallet migration

* Fix local wallet migration causing duplicates

* Fix protocol reattached on detach due to migration

* Fix form not centered

* Fix ZEUS lightning address domain

* Add placeholder, help, hints etc. to wallet form inputs

* Fix wallet badges not updated

* Remove unused declared variables

* Rename to ATTACH_PAGE

* Fix 500 error if no amount was given to LNURLp endpoint

* Tag log messages with wallet name

* Only skip network tests if we're disabling the wallet

* Rename var to networkTests

* Continue to store key hash in IndexedDB

* Rethink wallet state management

If decryption failed, the function to decrypt the wallets didn't throw but simply returned wallets that were still encrypted.

This was bad because it meant we could not rely on the wallets in the state being decrypted, even though this was the original idea behind the query hooks: hide the details of encryption and decryption inside them.

Because of this, we had to check if the wallets were still encrypted before we ran the wallet migration since we want to check if a protocol already exists.

This commit fixes this by making encryption and decryption always throw (and catching the errors), as well as returning a ready state from hooks. A hook might not be ready because it still needs to load something (in the case of the crypto hooks, it's loading the key from IndexedDB). Callers check that ready state before they call the function returned by the hook.

So now, the wallet migration hook can itself simply check if the hook to encrypt wallets is ready and if the wallets are no longer loading to let callers know if it itself is ready.

Since we also relied on wallets stored as encrypted in the context to show the unlock page, this was also changed by comparing the local and remote key hash.

* Add empty line

* Save new key hash during wallet reset

* Only receive protocol upserts require networkTests param

* Compare key hashes on server on each save

* Delete old code

* Fix card shows attach instead of configure

* Fix empty wallets created during migration

The old schema can contain '' instead of NULL in the columns of wallets for receiving.

* Update reset passphrase text

* Wrap passphrase reset in try/catch

* Fix migrate called multiple times

* Update key hash on migration if not set

* Fetch local wallets in migrate

* Fix missing await on setKey

* Let first device set key hash

* Fix indicator not shown if wallets locked

* Check if IndexedDB is available

* Fix inconsistent WebLN error message

* Disable WebLN if not available

* Remove outdated TODO

* Cursor-based pagination for wallet logs

* Fix log message x-overflow

* Add context to wallet logs

* Wrap errors are warnings in logs

* Rename wallet v2 migrations

* Update wallet status during logging

* Fix wallet logs loading state

The loading state would go from false -> true -> false because it's false when the lazy query wasn't called yet.

* Add wallet search

* Add Alby Go wallet

* Revert "Add Alby Go wallet"

This reverts commit 926c70638f1673756480c848237e52d5889dc037.

* Fix wallet logs sent by client don't update protocol status

* Fix mutation name

* put drag icon on opposite corner

* Add wallets/README.md

* Fix inconsistent case in wallets/README.md

* Fix autoprefixer warning about mixed support

This warning was in the app logs:

app     | Warning
app     |
app     | (31:3) autoprefixer: end value has mixed support, consider using flex-end instead
app     |
app     | Import trace for requested module:
app     | ./styles/wallet.module.css
app     | ./wallets/client/hooks/prompt.js
app     | ./wallets/client/hooks/index.js
app     | ./wallets/client/context/hooks.js
app     | ./wallets/client/context/provider.js
app     | ./wallets/client/context/index.js

* fix effect of wallet indicators on logo

* Fix deleting wallet template logs

* Use name as primary key of WalletTemplate

* Fix wallet_clear_vault trigger not mentioned in README

* Fix wallet receive prompt

Also remove no longer needed templateId from wallets.json and helper functions

* Use findUnique since name is now primary key

* Merge Alby wallets into one

* Remove unused name parameter from WalletsForm component

* Fix number check to decide if wallet or template

* Update wallet encryption on click, not as effect

* add cashu.me and lightning address logos

* add images

* Use recommended typeof to check if IDB available

* Also check if IDB available on delete

* Use constraint triggers

* Add indices on columns used for joins

* Fix inconsistent CLEAR OR REPLACE TRIGGER

* Attach wallet_check_support trigger to WalletProtocol table

* Update wallets/README.md

* Remove debugging code

* Refactor reducer: replace page with status

* Show 'wallets unavailable' if device does not support IndexedDB

* Remove duplicate ELSIF condition

* Fix hasSendWallet

The useSendWallets hook was not checking if the returned send wallets are enabled.

Since the components that used that hook only need to know if there is a send wallet, I replaced the useSendWallets hook with a useHasSendWallet hook.

* Add Cash App wallet

* fix changes loglevel enum

* Fix key init race condition in strict mode if no key exists yet

* Formatting

* Fix key init race condition via transactions in readwrite mode

* Replace Promise.withResolvers with regular promises

* replace generic spinner with our usual

---------

Co-authored-by: k00b <k00b@stacker.news>
2025-07-15 16:36:43 -05:00
k00b
df299a226c pay awards 2025-07-11 23:39:07 -05:00
github-actions[bot]
2caa189957
Extending awards.csv (#2266)
* Extending awards.csv

* Extending awards.csv

* Extending awards.csv

* Extending awards.csv

* Update awards.csv

---------

Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-07-11 23:32:03 -05:00
ekzyis
90fc1a9752
Run prisma format (#2275) 2025-07-11 12:59:50 -05:00
ekzyis
b1a0abe32c
Service Worker rewrite (#2274)
* Convert all top-level arrow functions to regular functions

* Refactor webPush.sendNotification call

* Refactor webPush logging

* Rename var to title

* Rewrite service worker

This rewrite simplifies the service worker by removing

* merging of push notifications via tag property
* badge count

These features weren't properly working on iOS. We concluded that we don't really need them.

For example, this means replies will no longer get merged to "you have X new replies" but show up as individual notifications.

Only zaps still use the tag property so devices that support it can still replace any previous "your post stacked X sats" notification for the same item.

* Don't use async/await in service worker

* Support app badge count

* Fix extremely slow notificationclick

* Fix serialization and save in pushsubscriptionchange event
2025-07-10 11:54:23 -05:00
ekzyis
bfced699ea
Rename to sendPushSubscriptionReply & remove unused nid argument (#2273)
* Refactor push subscription reply

* Remove unused notification id
2025-07-09 12:50:12 -05:00
ekzyis
17aada6dbc
Fix missing dependency in snow callback (#2270) 2025-07-07 15:18:15 -05:00
k00b
c81043efa8 add vote weight threshold to social poster 2025-07-07 14:51:17 -05:00
ekzyis
ec902ebc55
Remove unnecessary service worker message types (#2268)
* Remove service worker logger

* Use async/await for togglePushSubscription

* Remove commented out logger calls in service worker

* Remove message channel between service worker and app

The listener for messages from the service worker was removed in a previous commit.

* Remove unused OS detection for service worker

* Formatting

* Remove unnecessary service worker message types

These messages types were meant to debug or fix push notifications getting lost.

We figured out the issue: push notifications were lost because of silent pushes.

So these message types are no longer needed.

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-07-07 14:43:20 -05:00
k00b
8382dda231 back to lightning animation 2025-07-07 14:35:16 -05:00
ekzyis
18a38d8363
Refactor animations (#2261)
* Fix fireworks not checking localStorage flag

* Refactor animations

* Don't import unused animations

* Remove unused hook

---------

Co-authored-by: k00b <k00b@stacker.news>
2025-07-07 14:34:37 -05:00
ekzyis
3a27057781
Remove service worker logger (#2265)
* Remove service worker logger

* Use async/await for togglePushSubscription

* Remove commented out logger calls in service worker

* Remove message channel between service worker and app

The listener for messages from the service worker was removed in a previous commit.

* Remove unused OS detection for service worker
2025-07-07 14:15:26 -05:00
ekzyis
06df4b7a8c
Add custom title rule for YouTube (#2267) 2025-07-06 15:14:14 -05:00
Bryan Mutai
a634b05bee
Fix poll expiration clear button style (#2250) 2025-07-06 12:15:00 -05:00
soxa
7b3625eeeb
fix: outlawed comments should appear at the bottom (#2246) 2025-07-06 12:11:55 -05:00
ekzyis
1eea0a3ae0
Fix fireworks not checking localStorage flag (#2260) 2025-07-05 10:41:16 -05:00
Keyan
96fd271573
add fireworks (#2258)
* add fireworks

* fix weird search background color due to canvas overlay

* prevent going off small screens
2025-07-04 01:14:45 -05:00
ekzyis
de01d9493f
Fix missing cast to number (#2254) 2025-06-30 00:09:11 +02:00
ekzyis
8337aad596
Remove unreachable code (#2249)
* Remove unreachable code

* Remove unnecessary exports
2025-06-27 02:31:41 -05:00
k00b
0436bf68eb fix territory transfer notification case sensitive join 2025-06-23 16:18:49 -05:00
k00b
a419eefe9b fix unarchive throwing due to prior edge state 2025-06-23 16:01:30 -05:00
ekzyis
462016042c
Fix unique constraint hit during territory unarchive (#2245) 2025-06-20 09:41:37 -05:00
k00b
a5de2dae01 pay all remaining bounties 2025-06-19 18:19:51 -05:00
k00b
4034755410 vibe in award payment enhancements: option to consolidate + use alt receive method 2025-06-19 17:35:57 -05:00
k00b
04daf87f40 pay some more pending awards 2025-06-19 17:35:57 -05:00
ekzyis
54ccd19a75
Add another example to question about backward compatibility (#2242) 2025-06-19 21:35:33 +02:00
k00b
3c35e905d4 improve proxy invoice info modal 2025-06-19 13:31:21 -05:00
ekzyis
6efc0be9e5
Remove direct fallback if proxy enabled (#2231)
* Remove directReceive setting

* Never fallback to direct if proxy is enabled

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-06-19 12:54:50 -05:00
ekzyis
acf042f06e
Remove directReceive setting (#2230)
* Remove directReceive setting

* Keep directReceive in typedef for backward compatibility
2025-06-19 12:36:20 -05:00
ekzyis
089fe4d57b
Remove unused injected amount param (#2241) 2025-06-19 11:54:55 -05:00
ekzyis
3a3f28beef
Update PR template (#2240)
* Add example to question about backwards compatibility

* Fix spelling: backwards compatible -> backward compatible
2025-06-19 14:24:27 +02:00
k00b
27671ae746 pay awards 2025-06-18 16:11:37 -05:00
github-actions[bot]
37d7ed59bb
Extending awards.csv (#2238)
* Extending awards.csv

* Extending awards.csv

---------

Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-17 18:58:46 -05:00
Bryan Mutai
652315e9a0
Remove defunct relays from default list (#2235)
* Remove defunct relays from default list

* add primal, remove paid relay

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-06-17 18:48:45 -05:00
Bryan Mutai
6d50f7c9fc
fix embed list bullet to render at top of embed (#2221)
* fix embed list bullet to render at top of embed

* make it work for images/video

* fix tweet skeleton and list horizontal scroll

---------

Co-authored-by: k00b <k00b@stacker.news>
2025-06-17 18:33:42 -05:00
ekzyis
a7f73fef90
Remove unused onPaid for RECEIVE paid action (#2229)
This function can be removed because it will never do anything when called.

It does not do anything for wrapped invoices, and it will never get called for direct payments (since we don't know when the invoice was paid).

These are the only two payment methods for the RECEIVE paid action since 5a8804d.
2025-06-14 20:39:02 -05:00
ekzyis
36045b8ac9
Remove unused createInvoice mutation (#2227) 2025-06-14 20:38:23 -05:00
ekzyis
67b30c6974
Fix documentation for RECEIVE paid action (#2228) 2025-06-14 20:37:24 -05:00
github-actions[bot]
dacd37aeef
Extending awards.csv (#2226)
* Extending awards.csv

* Update awards.csv

---------

Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-06-13 23:13:55 -05:00
Axel Vyrn
16da50733c
Lnurl UI update (#2220)
* Update index.js

added info tooltip for LNURL-auth button

* Update index.js

* Update index.js

* Update index.js

* Update index.js

* Update index.js

* Update index.js

* use existing lightning explainer

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-06-13 23:10:32 -05:00
github-actions[bot]
830967467a
Extending awards.csv (#2225)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-06-13 21:43:33 -05:00
Edward Kung
524b1b97f3
user and territory autocomplete in search bar (#2217)
* autocomplete in the search bar

* update some naming conventions

* create dual autocomplete

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-06-13 21:42:28 -05:00
k00b
aebba27c57 pay awards 2025-06-13 18:52:13 -05:00
github-actions[bot]
12ff0911cb
Extending awards.csv (#2224)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-06-13 15:07:12 -05:00
Bryan Mutai
dc01ebdb26
Add Territory Sub management tab in Subscriptions (#2191)
* Add Territory Sub management tab in Subscriptions

* don't use queryRawUnsafe

* auto width on select

* separate into pages for browser nav

* fix multiple separators

* simplify queries

---------

Co-authored-by: k00b <k00b@stacker.news>
2025-06-13 15:01:25 -05:00
ekzyis
874694eb10
Add zines to socials in footer (#2223) 2025-06-13 13:03:50 -05:00
ekzyis
0310611a2d
Add flake.nix for Prisma Client on NixOS (#2219) 2025-06-11 11:17:53 -05:00
github-actions[bot]
4d8743bf13
Extending awards.csv (#2218)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-06-10 18:19:32 -05:00
Abhi Shandilya
79d3eb492d
fix duplicate search highlights (#2195) 2025-06-10 17:53:01 -05:00
ekzyis
590d73ece0
Also mount tls.cert, tls.key for docker/lnd/stacker (#2211) 2025-06-06 21:06:18 +02:00
k00b
59d4fadb50 pay another award 2025-06-06 12:32:33 -05:00
k00b
2bcaea2f58 pay some awards 2025-06-05 20:43:48 -05:00
Keyan
df082f424f
Update awards.csv 2025-06-05 20:23:46 -05:00
Keyan
bf62149681
Update awards.csv 2025-06-05 20:21:32 -05:00
github-actions[bot]
f7d59196fc
Extending awards.csv (#2207)
* Extending awards.csv

* Extending awards.csv

* Extending awards.csv

---------

Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-05 20:20:26 -05:00
Will Sutton
0002c0a0f6
sn_channel_cron condtion to ensure sn_lnd conns to router_lnd (#2192)
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-06-05 19:57:54 -05:00
Will Sutton
f5dff4b0bb
generate addr in router_lnd to enable chain sync (#2190)
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-06-05 19:29:48 -05:00
Bryan Mutai
1888b19792
Add lnbits-v1 to sndev (#2184)
* Add lnbits-v1

* use v1 in dev when specified

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-06-05 19:04:22 -05:00
ekzyis
d13ba034fe
Add security advisory payout to awards.csv (#2196) 2025-06-03 20:48:15 -05:00
ekzyis
f3df1092d8
Preload HSTS (#2205) 2025-06-03 20:47:25 -05:00
ekzyis
fa177317e3
Don't wrap base64 cert output (#2199) 2025-06-02 13:07:26 -05:00
github-actions[bot]
6a29dea232
Extending awards.csv (#2186)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-05-27 18:30:04 -05:00
Will Sutton
8c1b8d3118
sn_lnd: Fix wallet.db + add new pk and addr to .env.dev (#2185) 2025-05-27 18:19:29 -05:00
ekzyis
00f0e971c9
sn_lnd: Fix self-signed certificate error (#2179)
I deleted the existing volume data and let lnd generate a new certificate and macaroons during startup.
2025-05-25 17:50:47 -05:00
k00b
97cf36d2f6 pay pending awards 2025-05-21 14:46:00 -05:00
github-actions[bot]
bf777f72c4
Extending awards.csv (#2176)
* Extending awards.csv

* Extending awards.csv

---------

Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-21 14:44:29 -05:00
Bryan Mutai
5d4f88c3bb
Refactor(dupes): filter dupes by PAID or NULL invoiceActionState (#2175) 2025-05-21 14:43:41 -05:00
Bryan Mutai
407c0a9b49
Auto-populate YouTube video titles (#2171) 2025-05-21 14:39:56 -05:00
soxa
d9213c39e7
local DNS server via dnsmasq (#2168)
* Use dnsmasq to create virtual hosts and mock DNS management for custom domains

- dnsmasq docker image
- dnsmasq network bridge
- point *.sndev to 127.0.0.1
- set-dnsmasq script
- -- add/remove/list dns records in dnsmasq.conf
- add 'domains' to sndev
- 'sndev domains dns' referencing set-dnsmasq script

* restart dnsmasq if add/remove succeeded

* add domain to /etc/hosts; cleanup

* tell if the command needs sudo permission

* add directions for dnsmasq DNS server usage

* add --no-hosts flag to skip asking to edit /etc/hosts

* add domains command to README.md

* add dnsmasq instructions to README.md

* correct exit on usage function; final cleanup and comments

* portable bash; use default network for dnsmasq; set a version for dnsmasq image

* POSIX compliance, add env var to .env.development, adjust README

* ignore dnsmasq.conf edits, use template instead

* use extra configs for dnsmasq, more POSIX compliance

* fix --no-hosts flag recognition, light cleanup

* shift 4 only if the command has enough args; more error messages; adjust TXT type only on list

* different sed syntax for macOS
2025-05-21 13:06:19 -05:00
ekzyis
8ba572d5f1
Add question to welcome post (#2174) 2025-05-20 15:38:06 -05:00
k00b
d418f01fa9 pay awards 2025-05-15 18:47:26 -05:00
Keyan
d11c60fc80
Update awards.csv 2025-05-15 18:45:56 -05:00
ekzyis
ef37fff0f8
Explain guns and horses in FAQ (#2167) 2025-05-15 18:24:21 -05:00
k00b
30911f3039 reward tweak 2025-05-15 11:13:08 -05:00
m0wer
f12c03198d
Exact search (#2135)
* feat: add exact search for quoted phrases/words

* feat: get some highlighting for exact search

* feat: Add exact search for title and text fields in OpenSearch

* simplify and make it work with nlp script

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-05-15 09:11:58 -05:00
ekzyis
d7ddfffbf0
Delete duplicate wallets (#2163)
* Delete duplicate wallets

* Add unique Wallet index to prevent duplicate user wallets
2025-05-14 19:49:04 -05:00
Will Sutton
d2c71ca08f
fix: capture lnbits v1.0+ payments model response (#2162) 2025-05-14 14:00:08 -05:00
nl
6d9d20a8f0
Upgrade OpenSearch to 2.19.0 and fix hybrid pagination (#2072)
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-05-13 19:22:09 -05:00
k00b
05e7f0dded make award payments 2025-05-13 17:32:45 -05:00
Keyan
51ec00d549
Update awards.csv 2025-05-13 17:27:06 -05:00
Bryan Mutai
92f4b93099
add contributor (#2159) 2025-05-13 13:44:12 -05:00
m0wer
6d04b40adc
add contributor (#2158) 2025-05-13 11:22:47 -05:00
Keyan
3e75671c4b
Update awards.csv - klk lightning address 2025-05-13 10:36:09 -05:00
github-actions[bot]
64a9f5e50d
Extending awards.csv (#2157)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-05-12 18:00:31 -05:00
m0wer
0edf68cab9
feat: Territory autocomplete (#2124)
* feat: Territory autocomplete

Closes #992.

* refactor: refactor UserSuggest and TerritorySuggest components

* style: lint

* refactor: unify user and territory autocomplete logic

* simplify a bit and fix unrelated onSelect re-query

* fix skipping empty string on forward draft population

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-05-12 17:59:47 -05:00
github-actions[bot]
d4e3853f27
Extending awards.csv (#2156)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-05-12 15:47:27 -05:00
Bryan Mutai
586cb86ec2
Add check to prevent Markdown Heading links from rendering in notifcations (#2152)
* Prevent Markdown Heading links in notifications from being clickable

* make it more explicit and work on other pages

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-05-12 15:46:34 -05:00
github-actions[bot]
90c6d5a336
Extending awards.csv (#2155)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-05-10 13:53:19 -05:00
Bryan Mutai
faf11138c4
feat: attribute multiple issues addressed in a single PR in awards.csv (#2153) 2025-05-10 13:51:34 -05:00
ekzyis
f01a5fde00
Fix PGP fingerprint in README (#2150) 2025-05-06 16:23:40 -05:00
ekzyis
a1bc6dc217
Remove 'originally posted at' (#2148) 2025-05-05 09:56:41 -05:00
ekzyis
f754b530ff
Fix 0 sat lnaddr invoices (#2149)
* Fix comment position

* Fix possible 0 sat lnaddr invoices
2025-05-05 09:56:04 -05:00
k00b
b864290cac fix boost prediction 2025-04-28 19:18:40 -05:00
ekzyis
9dbd9d87d4
Fix error message on content-type mismatch (#2140) 2025-04-28 15:17:44 -05:00
ekzyis
dc196be807
Remove self-referential import (#2141) 2025-04-26 16:23:45 -05:00
ekzyis
236f930a17
Fix missing method in lnurlp error (#2139) 2025-04-26 14:04:39 -05:00
ekzyis
97317d4c0c
Fix lnaddr attach fails if minSendable > 1000 (#2138)
* Fix lnaddr attach fails if minSendable > 1000

* Don't fetch lnAddr options twice for test invoice
2025-04-26 11:15:47 -05:00
ekzyis
4ad64d658f
Fix privacy setting interfering with wallet prompt (#2134) 2025-04-25 20:26:28 -05:00
ekzyis
e8d8e64bfb
Close every relay in every pool (#2130) 2025-04-24 15:47:35 -05:00
k00b
32d5f8277a make awards payouts 2025-04-23 19:20:08 -05:00
ekzyis
c72dbb0c01
Mention "phantom reads" in README (#2128) 2025-04-23 19:12:36 -05:00
soxa
addd1263ab
hotfix: production uses NEXT_PUBLIC_MEDIA_DOMAIN (#2126) 2025-04-23 15:08:23 -05:00
soxa
ac26fdfb14
hotfix: compose uploads URL with URL constructor (#2125) 2025-04-23 13:18:34 -05:00
ekzyis
eebc791683
Fix push notification sent without own thread subscription (#2123) 2025-04-23 08:37:20 -05:00
k00b
1147e1fb81 add stackedMsats to territory cut fix migration 2025-04-22 13:32:24 -05:00
k00b
e85f40e9ba fix territory reward percent default (should be 30) 2025-04-22 13:17:10 -05:00
ekzyis
9fc819ec37
Also unsubscribe from any children (#2120) 2025-04-22 08:02:36 -05:00
github-actions[bot]
838418ab81
Extending awards.csv (#2122)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-04-21 19:32:19 -05:00
Abhi Shandilya
5965b3d090
set boost max (#2109)
* set boost max

* reduce max and apply to boost act

* make boost position aware paid action state

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-04-21 19:31:18 -05:00
github-actions[bot]
ab87ad5b11
Extending awards.csv (#2121)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-04-21 19:09:19 -05:00
Bryan Mutai
ea8a28a23e
feat(socialPoster): add Nostr pubkey tagging with hideNostr check in social poster (#2100)
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-04-21 19:08:21 -05:00
ekzyis
4c2f059fb5
Fix push notifications sent without thread subscription (#2118)
* Fix push notifications sent without thread subscription

* Remove duplicate ancestor check
2025-04-21 16:00:21 -05:00
ekzyis
ff057039f5
Fix unnecessary $queryRawUnsafe and missing await (#2117)
* Fix unnecessary usage of $queryRawUnsafe

* Fix missing await
2025-04-21 10:51:39 -05:00
ekzyis
e94a231192
Remove unused vault resolver argument (#2113) 2025-04-18 07:29:47 -05:00
ekzyis
a1a8b286e7
Remove unused vault resolver (#2110) 2025-04-16 15:59:28 -05:00
k00b
24c90ec6c3 pay all pending awards 2025-04-16 12:43:32 -05:00
Keyan
9445fe6e36
find more ln addresses for awards.csv 2025-04-16 12:32:09 -05:00
Keyan
7ce3967200
Update awards.csv 2025-04-16 09:06:52 -05:00
github-actions[bot]
6ac60604cc
Extending awards.csv (#2108)
* Extending awards.csv

* Extending awards.csv

---------

Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-15 19:05:41 -05:00
Bryan Mutai
7bde3fe55b
fix(extend-awards): add check for existing branch with pending awards.csv extension (#2093) 2025-04-15 19:05:02 -05:00
Abhi Shandilya
66dbf2496e
fix: url search (#2083)
* fix: url search

* refine

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-04-15 18:23:41 -05:00
github-actions[bot]
37eb0e905c
Extending awards.csv (#2107)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-04-15 17:20:17 -05:00
Bryan Mutai
984790ed5c
feat(Poll): add option to randomize poll choices (#2082)
* feat(Poll): add option to randomize poll choices

* improve

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-04-15 17:19:14 -05:00
ekzyis
9b77cf096d
Remove unused wallet resolvers (#2104)
* Remove unused wallet resolvers

* Remove unused wallet resolver args
2025-04-15 15:42:21 -05:00
soxa
b6f6cc821c
Crop avatars with Imgproxy (#2074)
* cropPhoto mutation, crop avatars with Imgproxy

* cropjob logging, conditional uploads url

* comment typo

* use public Imgproxy URL to re-upload cropped pic

* fix avatar in dev

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-04-15 15:41:33 -05:00
github-actions[bot]
c33b62abb4
Extending awards.csv (#2103)
* Extending awards.csv

* Update awards.csv

---------

Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-04-15 14:59:21 -05:00
soxa
22e4f8acf5
show sats on full view pinned comments (#2071)
* show sats on full view pinned comments; simplify conditions; pass topLevel as full status for comments

* comment view: show Pin instead of Boost also for own items

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-04-15 14:57:01 -05:00
ekzyis
719cb2d507
Prompt to attach receive wallet on post (#2059)
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-04-14 19:40:43 -05:00
ekzyis
d3b81e4346
Change wallet badges order (#2102) 2025-04-14 15:33:00 -05:00
ekzyis
1103f04f4b
Check if token expired before refresh (#2101) 2025-04-14 13:46:15 -05:00
ekzyis
fd7ffb90f5
Fix item deletion hits constraint (#2097)
* Fix item deletion hits constraint

* Filter constraint by deletedAt
2025-04-13 11:16:34 -05:00
ekzyis
e401c6f277 Move wallet status 2025-04-13 00:50:29 +02:00
ekzyis
7be94dcfed Move wallet components into wallets/ 2025-04-13 00:50:29 +02:00
ekzyis
66d7eef617
Fix wallet indicator blink via wallet loading state (#2091)
* Fix wallet indicator blink via wallet loading state

* Fix 'attach wallet' button not showing up on page refresh
2025-04-12 14:26:30 -05:00
soxa
bc0be4f92a
enhance: helpful error message on login (#2094)
* enhance: helpful error message on login

* better message, don't edit original message

* fix comment placement
2025-04-12 14:23:08 -05:00
ekzyis
34c02ece88
Update express to 4.21.2 (#2098) 2025-04-12 14:20:35 -05:00
soxa
9b73990083
Non-word boundary Regex on user Mentions (#2096)
* fix: non-word boundary regex on user mentions; show user when mentioned with a path

* allow only a single slash after the user
2025-04-12 14:20:01 -05:00
ekzyis
a3e0b6aa9c
Better lightning address error handling (#2089) 2025-04-09 18:11:52 -05:00
ekzyis
a7245930c2
Use wallets instead of wallets.length as useZap dependency (#2088)
* Potential fix for stale useZap wallet dependency

* Memoize context value for wallets
2025-04-09 15:53:31 -05:00
ekzyis
52365c32ed
Wallet badges (#2040)
* Remove gun+horse streak

* Add wallet badges

* Fix empty recv wallet detected as enabled

* Resolve badges via columns and triggers

* Fix backwards compatibility by not dropping GQL fields

* Gun+horse notifications as streaks via triggers

* Fix error while computing streaks

* Push notifications for horse+gun

* Move logic to JS via pgboss job

* Fix argument to notifyNewStreak

* Update checkWallet comment

* Refactor notification id hack

* Formatting

* Fix missing update of possibleTypes

This didn't cause any bugs because the added types have no field resolvers.

* Add user migration

* Fix missing cast to date

* Run checkWallet queries inside transaction
2025-04-09 15:29:44 -05:00
k00b
9df5a52bd3 remove zap/downzap subname case sensitivity 2025-04-08 09:38:53 -05:00
Edward Kung
5f2c8bf380
Nlp setup quick fix (#2081)
* quick fix to set default search pipeline

* remove extraneous testing lines
2025-04-08 09:02:06 -05:00
github-actions[bot]
6e23709de1
Extending awards.csv (#2080)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-04-07 18:22:25 -05:00
nl
af096a08a0
Gradually replace default tips with user-defined ones on long press (up to 7) (#2069)
* commented the lines to make sure dupes are also checked on subdomains

* chore: fix lint issues

* add more than 3 custom tips, up to 7 total

* fix merge issue

* shorten logic

* don't check length for slice without need

---------

Co-authored-by: 김현희 <pygmal@gimhyeonhuiui-MacBookAir.local>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-04-07 18:21:51 -05:00
github-actions[bot]
1835a8f255
Extending awards.csv (#2079)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-04-07 18:04:41 -05:00
nl
4e631f0373
Fix: Treat subdomains as distinct in dupe URL detection (#2068)
* commented the lines to make sure dupes are also checked on subdomains

* chore: fix lint issues

* fix the underlying issue instead

---------

Co-authored-by: 김현희 <pygmal@gimhyeonhuiui-MacBookAir.local>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-04-07 18:03:20 -05:00
github-actions[bot]
f635514c32
Extending awards.csv (#2078)
* Extending awards.csv

* Update awards.csv

---------

Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-04-07 17:27:16 -05:00
Edward Kung
e4a2228d7c
NLP startup script + opensearch fixes (#2070)
* fix opensearch startup

* nlp setup script

* nlp setup documentation

* move script to ./scripts and update docs

---------

Co-authored-by: k00b <k00b@stacker.news>
2025-04-07 17:08:37 -05:00
ekzyis
f43a522a87
Run social poster only in prod (#2077) 2025-04-07 16:51:33 -05:00
ekzyis
40cd0ea422
Fix wrong method in error message (#2065) 2025-04-04 08:03:24 -05:00
ekzyis
78f7e006d5
Verify testCreateInvoice returns a payment request (#2063)
* Verify testCreateInvoice returns a payment request

* Use GqlInputError
2025-04-03 13:39:29 -05:00
k00b
b617ac0a56 fix nwc receive 2025-04-03 12:23:20 -05:00
ekzyis
06d05c7f37
Fix NWC recv (#2062) 2025-04-03 12:17:07 -05:00
k00b
1f5e0833db only use apollo retries if the operation is a query 2025-04-03 11:28:36 -05:00
ekzyis
2cf0f1d268
Update NDK to v2.12.2 (#2041)
* Update NDK to v2.12.2

* Fix NWC with @nostr-dev-kit/ndk-wallet

* Add test for nip-57 zap receipts
2025-04-02 17:20:47 -05:00
ekzyis
644899469f
System logger for users (#2035)
* System logger

* remove outdated credits preference check on RECEIVE

* fix developer focused error message

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-04-02 17:18:41 -05:00
ekzyis
d7a7273ca4
Wallet indicators (#2002)
* Add wallet indicator

* Reveal wallets page via button
2025-04-02 15:02:13 -05:00
k00b
78c5552e5b pay awards out 2025-04-02 13:31:33 -05:00
k00b
75a8828eeb add pay awards script 2025-04-02 13:31:33 -05:00
ekzyis
71e06f09e3 Automate more stuff 2025-04-02 19:32:19 +02:00
ekzyis
93608019cd Assert bios in fetch function 2025-04-02 19:32:19 +02:00
ekzyis
0f1818c9b9 Refactor welcome script with utils 2025-04-02 19:32:19 +02:00
ekzyis
88253e5478
external lightning address validator (#2056) 2025-04-01 17:39:33 -05:00
k00b
2d51a5def9 fix #2055 2025-04-01 17:19:22 -05:00
ekzyis
e0bfc590b2
Remove mention of autowithdrawal for lnaddr (#2054) 2025-04-01 16:36:56 -05:00
ekzyis
5700a4090a
Fix FAQ hydration error (#2050) 2025-04-01 16:18:25 -05:00
ekzyis
14fadbaed6
Replace 'autowithdraw' in wallet subtitle (#2053) 2025-04-01 16:15:17 -05:00
k00b
bcc92e54fd fix #2052 2025-04-01 15:27:48 -05:00
ekzyis
73e0b5055e
Add territory link in post form info (#2044) 2025-03-31 12:02:46 -05:00
ekzyis
fca4d1ff92
Include name in mention push notification (#2045) 2025-03-31 11:59:57 -05:00
ekzyis
3568fc1c62
Formatting (#2046) 2025-03-31 09:06:38 -05:00
k00b
8d4f99ea8d fill db seed with real text/titles/urls 2025-03-29 18:19:52 -05:00
k00b
39d0c55124 vibe coded twitter link extracting - meh 2025-03-29 15:22:15 -05:00
ekzyis
a2faa31d49
Fix automated retries retrying too much (#2037) 2025-03-28 08:10:54 -05:00
ekzyis
39dbc891b0 Fix wallet priority in FAQ 2025-03-26 23:37:02 -05:00
k00b
fb8c95c0ba let OP get full referral and add comments to social poster 2025-03-26 15:20:33 -05:00
ekzyis
ec7b05830a
Refactor indicators (#2032) 2025-03-26 14:32:42 -05:00
k00b
71fdd873c5 Revert "stop posting to the wrong nostr account"
This reverts commit 2136c1a11f4bba6fac5bed1b734ec650466d074e.
2025-03-26 12:36:55 -05:00
k00b
2136c1a11f stop posting to the wrong nostr account 2025-03-26 12:34:40 -05:00
k00b
26a23ade92 remove leaderboard 2025-03-26 11:40:28 -05:00
ekzyis
de7f96a3a4
Merge follow-up replies with normal replies (#2027) 2025-03-26 11:00:04 -05:00
ekzyis
e29c6b4842
Refactor reminder push notifications (#2026)
* Refactor reminder push notifications

items should always exist and if not, we can just immediately fail imo

* Use same text for reminders in /notifications
2025-03-26 10:59:27 -05:00
k00b
bb76c6a138 prevent resubmission of bounty payments 2025-03-26 10:23:17 -05:00
k00b
5701bf640a we can't lnurl-verify non-proxied invoices 2025-03-26 09:16:52 -05:00
ekzyis
ef8c738582
Reset multi_auth to initial state on error (#2007)
* Reset multi auth to initial state

* Also check if next-auth.session-token exists

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-03-25 18:59:26 -05:00
Keyan
8a6b825659
automated social posting (#2022)
* social posting without message selection

* message formatting and scheduled job

* small cleanup
2025-03-25 18:32:30 -05:00
secp512k2
1921c253ba
Removed OpenSearch API Password (#2016)
Removed OpenSearch API Password on Line 176
2025-03-25 17:39:28 -05:00
ekzyis
c93f658ade Format guides in FAQ 2025-03-25 16:45:56 -05:00
ekzyis
30ca04c6fe Remove section about personalized feeds in FAQ 2025-03-25 16:45:56 -05:00
ekzyis
895efd0181
Refactor multi auth with useCookie (#2019) 2025-03-25 15:57:53 -05:00
ekzyis
501bf1609b Make limit "configurable" in welcome script 2025-03-25 15:47:14 -05:00
Keyan
3878802c03
Update awards.csv 2025-03-25 12:27:09 -05:00
ekzyis
df17bc3b25
Use MULTI_AUTH_REGEXP to test for multi auth keys (#2020) 2025-03-25 12:25:55 -05:00
ekzyis
04a4092090
Reset if pointer is not a number or JWT cannot be decoded (#2021) 2025-03-25 12:25:37 -05:00
ekzyis
d7e01d0186
Remove multi auth error checking (#2018)
* Simplify state of selected account

* Remove multi auth error checking
2025-03-25 12:24:15 -05:00
392 changed files with 101135 additions and 51971 deletions

4
.cursor/BUGBOT.md Normal file
View File

@ -0,0 +1,4 @@
# Project review guidelines
- ignore ??? as a placeholder in awards.csv

View File

@ -32,6 +32,12 @@ SLACK_CHANNEL_ID=
LNAUTH_URL=http://localhost:3000/api/lnauth
LNWITH_URL=http://localhost:3000/api/lnwith
# auto social poster
TWITTER_POSTER_API_KEY=
TWITTER_POSTER_API_KEY_SECRET=
TWITTER_POSTER_ACCESS_TOKEN=
TWITTER_POSTER_ACCESS_TOKEN_SECRET=
########################################
# SNDEV STUFF WE PRESET #
# which you can override in .env.local #
@ -57,8 +63,8 @@ INVOICE_HMAC_KEY=a4c1d9c81edb87b79d28809876a18cf72293eadb39f92f3f4f2f1cfbdf907c9
# lnd
# xxd -p -c0 docker/lnd/sn/regtest/admin.macaroon
LND_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494943516a43434165696741774942416749516139493834682b48653350385a437541525854554d54414b42676771686b6a4f50515144416a41344d5238770a485159445651514b45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d5255774577594456515144457778694e6a41785a5749780a4d474d354f444d774868634e4d6a51774d7a41334d5463774d6a45355768634e4d6a55774e5441794d5463774d6a4535576a41344d523877485159445651514b0a45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d5255774577594456515144457778694e6a41785a5749784d474d354f444d770a5754415442676371686b6a4f5051494242676771686b6a4f50514d4242774e4341415365596a4b62542b4a4a4a37624b6770677a6d6c3278496130364e3174680a2f4f7033533173382b4f4a41387836647849682f326548556b4f7578675a36703549434b496f375a544c356a5963764375793941334b6e466f3448544d4948510a4d41344741315564447745422f775145417749437044415442674e56485355454444414b4267677242674546425163444154415042674e5648524d42416638450a425441444151482f4d4230474131556444675157424252545756796e653752786f747568717354727969466d6a36736c557a423542674e5648524545636a42770a676778694e6a41785a5749784d474d354f444f4343577876593246736147397a64494947633235666247356b6768526f62334e304c6d52765932746c636935700a626e526c636d356862494945645735706549494b64573570654842685932746c64494948596e566d59323975626f6345667741414159635141414141414141410a41414141414141414141414141596345724273414254414b42676771686b6a4f5051514441674e4941444246416945413873616c4a667134476671465557532f0a35347a335461746c6447736673796a4a383035425a5263334f326f434943794e6e3975716976566f5575365935345143624c3966394c575779547a516e61616e0a656977482f51696b0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a
LND_MACAROON=0201036c6e6402f801030a106cf4e146abffa5d766befbbf4c73b5a31201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e6572617465120472656164000006202c3bfd55c191e925cbffd73712c9d4b9b4a8440410bde5f8a0a6e33af8b3d876
LND_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494943516a4343416569674177494241674951484f4a69597458736c72592f4931376933574c444354414b42676771686b6a4f50515144416a41344d5238770a485159445651514b45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d52557745775944565151444577777a4e54526d4d574e690a4f546b7a595451774868634e4d6a55774e54497a4d4467784d444d345768634e4d6a59774e7a45344d4467784d444d34576a41344d523877485159445651514b0a45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d52557745775944565151444577777a4e54526d4d574e694f546b7a595451770a5754415442676371686b6a4f5051494242676771686b6a4f50514d4242774e434141524b6d733131422b4e58554e642f54574347492b4b2b5046686b485a31410a5449647732566e766a344f6130784c696c515a4d7779647149586c7a724641485064646a3566697934584c456f43364d4e427636585277706f3448544d4948510a4d41344741315564447745422f775145417749437044415442674e56485355454444414b4267677242674546425163444154415042674e5648524d42416638450a425441444151482f4d42304741315564446751574242526f433554634e58746366464f7458393171364364337a6930327a54423542674e5648524545636a42770a6767777a4e54526d4d574e694f546b7a5954534343577876593246736147397a64494947633235666247356b6768526f62334e304c6d52765932746c636935700a626e526c636d356862494945645735706549494b64573570654842685932746c64494948596e566d59323975626f6345667741414159635141414141414141410a4141414141414141414141414159634572424941427a414b42676771686b6a4f5051514441674e494144424641694541324941462b32436746704a754e5445750a34524f63322f70625870476f4934365573724a65525972614d33414349423974424c6759777a597a2b596b5a4e7a417a7077454c754935564f505959724a6f6b0a7270754d32316b690a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a
LND_MACAROON=0201036c6e6402f801030a10ba643b9c3fe23f760e1ee63e0196656e1201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620fd0027075985f7073217aa9aaae4d14db0e7ca38f4e572c3b85c81cf6bb580b3
LND_SOCKET=sn_lnd:10009
# nostr (NIP-57 zap receipts)
@ -79,6 +85,7 @@ IMGPROXY_READ_TIMEOUT=10
IMGPROXY_WRITE_TIMEOUT=10
IMGPROXY_DOWNLOAD_TIMEOUT=9
IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1
IMGPROXY_ALLOW_ORIGIN=http://localhost:3000
# IMGPROXY_DEVELOPMENT_ERRORS_MODE=1
# IMGPROXY_ENABLE_DEBUG_HEADERS=true
@ -133,8 +140,8 @@ 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
SN_LND_ADDR=bcrt1q7q06n5st4vqq3lssn0rtkrn2qqypghv9xg2xnl
SN_LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490
SN_LND_PUBKEY=03dc0de8fbe29ef3d26554c615adfd17aaca959403c4e9ecebaac4b83978d86342
SN_LND_ADDR=bcrt1qu6g49vrl8n4ay99hr04wefkfy2e8g0z4nc0sjw
# sn_lndk stuff
SN_LNDK_GRPC_PORT=10012
@ -177,10 +184,16 @@ grpc_proxy=http://tor:7050/
# lnbits
LNBITS_WEB_PORT=5001
LNBITS_WEB_PORT_V1=5002
# CPU shares for each category
CPU_SHARES_IMPORTANT=1024
CPU_SHARES_MODERATE=512
CPU_SHARES_LOW=256
NEXT_TELEMETRY_DISABLED=1
NEXT_TELEMETRY_DISABLED=1
# custom domains stuff
# local DNS server for custom domain verification, by default it's dnsmasq.
# reachable by containers on 172.30.0.2(:53), outside of docker with 0.0.0.0:5353
DOMAINS_DNS_SERVER=172.30.0.2

View File

@ -10,8 +10,9 @@ _Was anything unclear during your work on this PR? Anything we should definitely
## Checklist
**Are your changes backwards compatible? Please answer below:**
**Are your changes backward compatible? Please answer below:**
_For example, a change is not backward compatible if you removed a GraphQL field or dropped a database column._
**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:**

View File

@ -14,7 +14,10 @@ jobs:
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'
github.event.pull_request.head.ref != 'extend-awards/patch' &&
github.event.pull_request.user.login != 'huumn' &&
github.event.pull_request.user.login != 'ekzyis' &&
github.event.pull_request.user.login != 'Soxasora'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -22,14 +25,36 @@ jobs:
with:
python-version: '3.13'
- run: pip install requests
- name: Check if branch exists
id: check_branch
run: |
git fetch origin extend-awards/patch || echo "Branch does not exist"
if git show-ref --verify --quiet refs/remotes/origin/extend-awards/patch; then
echo "exists=true" >> $GITHUB_ENV
else
echo "exists=false" >> $GITHUB_ENV
fi
- name: Checkout to existing branch
if: env.exists == 'true'
run: |
git checkout extend-awards/patch
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
- run: python extend-awards.py
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_CONTEXT: ${{ toJson(github) }}
- name: Commit changes and push to existing branch
if: env.exists == 'true'
run: |
git commit -am "Extending awards.csv" || exit 0
git push origin extend-awards/patch
- uses: peter-evans/create-pull-request@v7
if: env.exists == 'false'
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.
body: One or more PR's were merged that solve an issue(s) and awards.csv should be extended. Remember to delete the branch after merging.
delete-branch: true

11
.gitignore vendored
View File

@ -53,6 +53,7 @@ docker-compose.*.yml
*.sql
!/prisma/migrations/*/*.sql
!/docker/db/seed.sql
!/docker/db/wallet-seed.sql
# nostr wallet connect
scripts/nwc-keys.json
@ -65,4 +66,12 @@ docker/lnbits/data
# nostr link extract
scripts/nostr-link-extract.config.json
scripts/nostr-links.db
scripts/nostr-links.db
scripts/twitter-link-extract.config.json
scripts/twitter-links.db
# pay-awards
scripts/pay-awards.config.json
# dnsmasq
docker/dnsmasq/dnsmasq.d/*

View File

@ -87,6 +87,9 @@ COMMANDS
psql open psql on db
prisma run prisma commands
domains:
domains custom domains dev management
dev:
pr fetch and checkout a pr
lint run linters
@ -131,7 +134,36 @@ services:
You can read more about [docker compose override files](https://docs.docker.com/compose/multiple-compose-files/merge/).
#### Enabling semantic search
To enable semantic search that uses text embeddings, run `./scripts/nlp-setup`.
Before running `./scripts/nlp-setup`, ensure the following are true:
- search is enabled in `COMPOSE_PROFILES`:
```.env
COMPOSE_PROFILES=...,search,...
```
- The default opensearch index (default name=`item`) is created and done indexing. This should happen the first time you run `./sndev start`, but it may take a few minutes for indexing to complete.
After `nlp-setup` is done, restart your containers to enable semantic search:
```
> ./sndev restart
```
#### Local DNS via dnsmasq
To enable dnsmasq:
- domains should be enabled in `COMPOSE_PROFILES`:
```.env
COMPOSE_PROFILES=...,domains,...
```
To add/remove DNS records you can now use `./sndev domains dns`. More on this [here](#add-or-remove-dns-records-in-local).
<br>
@ -431,6 +463,25 @@ To enable Web Push locally, you will need to set the `VAPID_*` env vars. `VAPID_
<br>
## Custom domains
### Add or remove DNS records in local
A worker dedicated to verifying custom domains, checks, among other things, if a domain has the correct DNS records and values. This would normally require a real domain and access to its DNS configuration. Therefore we use dnsmasq to have local DNS, make sure you have [enabled it](#local-dns-via-dnsmasq).
To add a DNS record the syntax is the following:
`./sndev domains dns add|remove cname|txt <name/domain> <value>`
For TXT records, you can also use `""` quoted strings on `value`.
To list all DNS records present in the dnsmasq config: `./sndev domains dns list`
#### Access a local custom domain added via dnsmasq
sndev will use the dnsmasq DNS server by default, but chances are that you might want to access the domain via your browser.
For every edit on dnsmasq, it will give you the option to either edit the `/etc/hosts` file or use the dnsmasq DNS server which can be reached on `127.0.0.1:5353`. You can avoid getting asked to edit the `/etc/hosts` file by adding the `--no-hosts` parameter.
# Internals
<br>
@ -468,7 +519,7 @@ Open a [discussion](http://github.com/stackernews/stacker.news/discussions) or [
# Responsible disclosure
If you found a vulnerability, we would greatly appreciate it if you contact us via [security@stacker.news](mailto:security@stacker.news) or open a [security advisory](https://github.com/stackernews/stacker.news/security/advisories/new). Our PGP key can be found [here](https://stacker.news/pgp.txt) (EBAF 75DA 7279 CB48).
If you found a vulnerability, we would greatly appreciate it if you contact us via [security@stacker.news](mailto:security@stacker.news) or open a [security advisory](https://github.com/stackernews/stacker.news/security/advisories/new). Our PGP key can be found [here](https://stacker.news/pgp.txt) (FEE1 E768 E0B3 81F5).
<br>

View File

@ -103,7 +103,7 @@ stateDiagram-v2
| 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 |
| receive | | | | | x | x | x | | x |
| buy fee credits | | | x | | x | | | x | |
| invite gift | x | | | | | | x | x | |
@ -205,7 +205,7 @@ The ONLY exception to this are for the `users` table where we store a stacker's
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).
### This is a big deal
1. If you read from the database and intend to use that data to write to the database, and it's possible that a concurrent transaction could change the data you've read (it usually is), you need to be prepared to handle that.
1. If you read from the database and intend to use that data to write to the database, and it's possible that a concurrent transaction could change the data you've read (it usually is), you need to be prepared to handle that (see [_phantom reads_](https://www.postgresql.org/docs/16/transaction-iso.html)).
2. This applies to **ALL**, and I really mean **ALL**, read data regardless of how you read the data within the `read committed` transaction:
- independent statements
- `WITH` queries (CTEs) in the same statement

View File

@ -62,7 +62,7 @@ export async function onPaid ({ invoice, actId }, { tx }) {
// denormalize downzaps
await tx.$executeRaw`
WITH territory AS (
SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName"
FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId"
WHERE i.id = ${itemAct.itemId}::INTEGER

View File

@ -227,7 +227,7 @@ async function performP2PAction (actionType, args, incomingContext) {
await assertBelowMaxPendingInvoices(incomingContext)
const description = await paidActions[actionType].describe(args, incomingContext)
const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, {
const { invoice, wrappedInvoice, protocol, maxFee } = await createWrappedInvoice(userId, {
msats: cost,
feePercent: sybilFeePercent,
description,
@ -239,7 +239,7 @@ async function performP2PAction (actionType, args, incomingContext) {
invoiceArgs: {
bolt11: invoice,
wrappedBolt11: wrappedInvoice,
wallet,
protocol,
maxFee
}
}
@ -269,7 +269,7 @@ async function performDirectAction (actionType, args, incomingContext) {
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
for await (const { invoice, logger, wallet } of createUserInvoice(userId, {
for await (const { invoice, logger, protocol } of createUserInvoice(userId, {
msats: cost,
description,
expiry: INVOICE_EXPIRE_SECS
@ -293,7 +293,7 @@ async function performDirectAction (actionType, args, incomingContext) {
bolt11: invoice,
msats: cost,
hash,
walletId: wallet.id,
protocolId: protocol.id,
receiverId: userId
}
}),
@ -346,22 +346,26 @@ export async function retryPaidAction (actionType, args, incomingContext) {
invoiceId: failedInvoice.id
},
include: {
wallet: true
protocol: {
include: {
wallet: true
}
}
}
})
if (invoiceForward) {
// this is a wrapped invoice, we need to retry it with receiver fallbacks
try {
const { userId } = invoiceForward.wallet
const { userId } = invoiceForward.protocol.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, {
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, protocol, 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 }
invoiceArgs = { bolt11, wrappedBolt11, protocol, maxFee }
} catch (err) {
console.log('failed to retry wrapped invoice, falling back to SN:', err)
}
@ -429,7 +433,7 @@ async function createSNInvoice (actionType, args, context) {
async function createDbInvoice (actionType, args, context) {
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs
const { bolt11, wrappedBolt11, preimage, protocol, maxFee } = invoiceArgs
const db = tx ?? models
@ -468,9 +472,9 @@ async function createDbInvoice (actionType, args, context) {
invoice: {
create: invoiceData
},
wallet: {
protocol: {
connect: {
id: wallet.id
id: protocol.id
}
}
}

View File

@ -1,8 +1,9 @@
import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { ANON_ITEM_SPAM_INTERVAL, ANON_FEE_MULTIPLIER, 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 { msatsToSats, satsToMsats } from '@/lib/format'
import { GqlInputError } from '@/lib/error'
import { throwOnExpiredUploads } from '@/api/resolvers/upload'
export const anonable = true
@ -38,12 +39,12 @@ export async function getBaseCost ({ models, bio, parentId, subName }) {
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
const baseCost = await getBaseCost({ models, bio, parentId, subName })
// cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + upload fees + boost
// cost = baseCost * 10^num_items_in_10m * 10 (ANON_FEE_MULTIPLIER constant) 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
* ${me ? 1 : ANON_FEE_MULTIPLIER}::INTEGER
+ (SELECT "nUnpaid" * "uploadFeesMsats"
FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
+ ${satsToMsats(boost)}::INTEGER as cost`
@ -61,15 +62,7 @@ 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.`)
}
await throwOnExpiredUploads(uploadIds, { tx })
let invoiceData = {}
if (invoiceId) {

View File

@ -1,5 +1,5 @@
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { uploadFees } from '../resolvers/upload'
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { notifyItemMention, notifyMention } from '@/lib/webPush'
import { satsToMsats } from '@/lib/format'
@ -17,7 +17,7 @@ export async function getCost ({ id, boost = 0, uploadIds, bio }, { me, models }
// or more boost
const old = await models.item.findUnique({ where: { id: parseInt(id) } })
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me })
const cost = BigInt(totalFeesMsats) + satsToMsats(boost - old.boost)
const cost = totalFeesMsats + satsToMsats(boost - old.boost)
if (cost > 0 && old.invoiceActionState && old.invoiceActionState !== 'PAID') {
throw new Error('creation invoice not paid')
@ -60,6 +60,7 @@ export async function perform (args, context) {
const itemMentions = await getItemMentions(args, context)
const itemUploads = uploadIds.map(id => ({ uploadId: id }))
await throwOnExpiredUploads(uploadIds, { tx })
await tx.upload.updateMany({
where: { id: { in: uploadIds } },
data: { paid: true }
@ -163,7 +164,8 @@ export async function nonCriticalSideEffects ({ invoice, id }, { models }) {
where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } }
itemReferrers: { include: { refereeItem: true } },
user: true
}
})
// compare timestamps to only notify if mention or item referral was just created to avoid duplicates on edits

View File

@ -1,5 +1,5 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { toPositiveBigInt, numWithUnits, msatsToSats, satsToMsats } from '@/lib/format'
import { PAID_ACTION_PAYMENT_METHODS, PROXY_RECEIVE_FEE_PERCENT } from '@/lib/constants'
import { toPositiveBigInt, numWithUnits, msatsToSats } from '@/lib/format'
import { notifyDeposit } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server'
@ -16,22 +16,20 @@ export async function getCost ({ 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
// don't fallback to direct if proxy is enabled to always hide stacker's node pubkey
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && me?.proxyReceive) 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
return PROXY_RECEIVE_FEE_PERCENT
}
export async function perform ({
@ -58,24 +56,6 @@ export async function describe ({ description }, { me, cost, paymentMethod, sybi
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`

View File

@ -2,6 +2,7 @@ import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/consta
import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory'
import { initialTrust } from './lib/territory'
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
export const anonable = false
@ -11,8 +12,9 @@ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ billingType }) {
return satsToMsats(TERRITORY_PERIOD_COST(billingType))
export async function getCost ({ billingType, uploadIds }, { models, me }) {
const { totalFees } = await uploadFees(uploadIds, { models, me })
return satsToMsats(BigInt(TERRITORY_PERIOD_COST(billingType)) + totalFees)
}
export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
@ -21,6 +23,19 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
const billedLastAt = new Date()
const billPaidUntil = nextBilling(billedLastAt, billingType)
await throwOnExpiredUploads(data.uploadIds, { tx })
if (data.uploadIds.length > 0) {
await tx.upload.updateMany({
where: {
id: { in: data.uploadIds }
},
data: {
paid: true
}
})
}
delete data.uploadIds
const sub = await tx.sub.create({
data: {
...data,

View File

@ -36,8 +36,15 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
data.userId = me.id
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 } } })
try {
// XXX this will throw if this transfer has already happened
// TODO: upsert this
await tx.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: me.id } })
// this will throw if the prior user has already unsubscribed
await tx.subSubscription.delete({ where: { userId_subName: { userId: sub.userId, subName: name } } })
} catch (e) {
console.error(e)
}
}
await tx.subAct.create({
@ -78,9 +85,16 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
}
})
await tx.userSubTrust.createMany({
data: initialTrust({ name: updatedSub.name, userId: updatedSub.userId })
})
const trust = initialTrust({ name: updatedSub.name, userId: updatedSub.userId })
for (const t of trust) {
await tx.userSubTrust.upsert({
where: {
userId_subName: { userId: t.userId, subName: t.subName }
},
update: t,
create: t
})
}
return updatedSub
}

View File

@ -2,6 +2,7 @@ import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/consta
import { satsToMsats } from '@/lib/format'
import { proratedBillingCost } from '@/lib/territory'
import { datePivot } from '@/lib/time'
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
export const anonable = false
@ -11,18 +12,16 @@ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ oldName, billingType }, { models }) {
export async function getCost ({ oldName, billingType, uploadIds }, { models, me }) {
const oldSub = await models.sub.findUnique({
where: {
name: oldName
}
})
const cost = proratedBillingCost(oldSub, billingType)
if (!cost) {
return 0n
}
const { totalFees } = await uploadFees(uploadIds, { models, me })
const cost = BigInt(proratedBillingCost(oldSub, billingType)) + totalFees
return satsToMsats(cost)
}
@ -63,6 +62,19 @@ export async function perform ({ oldName, invoiceId, ...data }, { me, cost, tx }
})
}
await throwOnExpiredUploads(data.uploadIds, { tx })
if (data.uploadIds.length > 0) {
await tx.upload.updateMany({
where: {
id: { in: data.uploadIds }
},
data: {
paid: true
}
})
}
delete data.uploadIds
return await tx.sub.update({
data,
where: {

View File

@ -39,11 +39,11 @@ export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models,
return null
}
const wallets = await getInvoiceableWallets(item.userId, { models })
const protocols = 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 &&
if (protocols.length > 0 &&
item.itemForwards.length === 0 &&
sats >= item.user.receiveCreditsBelowSats) {
return item.userId
@ -151,7 +151,7 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
await tx.$queryRaw`
WITH territory AS (
SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName"
FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId"
WHERE i.id = ${itemAct.itemId}::INTEGER

View File

@ -6,9 +6,9 @@ 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 }) {
export default async function performPayingAction ({ bolt11, maxFee, protocolId }, { me, models, lnd }) {
try {
console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, walletId)
console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, protocolId)
if (!me) {
throw new Error('You must be logged in to perform this action')
@ -34,8 +34,8 @@ export default async function performPayingAction ({ bolt11, maxFee, walletId },
msatsPaying: toPositiveBigInt(decoded.mtokens),
msatsFeePaying: satsToMsats(maxFee),
userId: me.id,
walletId,
autoWithdraw: !!walletId
protocolId,
autoWithdraw: !!protocolId
}
})
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })

View File

@ -1,7 +1,8 @@
import user from './user'
import message from './message'
import item from './item'
import wallet from './wallet'
import walletV1 from './wallet'
import walletV2 from '@/wallets/server/resolvers'
import lnurl from './lnurl'
import notifications from './notifications'
import invite from './invite'
@ -19,7 +20,6 @@ import chainFee from './chainFee'
import { GraphQLScalarType, Kind } from 'graphql'
import { createIntScalar } from 'graphql-scalar'
import paidAction from './paidAction'
import vault from './vault'
const date = new GraphQLScalarType({
name: 'Date',
@ -54,6 +54,6 @@ const limit = createIntScalar({
maximum: 1000
})
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
export default [user, item, message, walletV1, walletV2, lnurl, notifications, invite, sub,
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction]

View File

@ -15,7 +15,6 @@ import {
FULL_COMMENTS_THRESHOLD
} from '@/lib/constants'
import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts'
import uu from 'url-unshort'
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, validateSchema } from '@/lib/validate'
import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item'
@ -26,11 +25,15 @@ import assertApiKeyNotPermitted from './apiKey'
import performPaidAction from '../paidAction'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { verifyHmac } from './wallet'
import { parse } from 'tldts'
import { shuffleArray } from '@/lib/rand'
function commentsOrderByClause (me, models, sort) {
const sharedSortsArray = []
sharedSortsArray.push('("Item"."pinId" IS NOT NULL) DESC')
sharedSortsArray.push('("Item"."deletedAt" IS NULL) DESC')
// outlawed items should be at the bottom
sharedSortsArray.push(`NOT ("Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD} OR "Item".outlawed) DESC`)
const sharedSorts = sharedSortsArray.join(', ')
if (sort === 'recent') {
@ -509,7 +512,7 @@ export default {
${whereClause(
'"parentId" IS NULL',
'"Item"."deletedAt" IS NULL',
'"Item"."status" = \'ACTIVE\'',
activeOrMine(me),
'created_at <= $1',
'"pinId" IS NULL',
subClause(sub, 4)
@ -592,7 +595,13 @@ export default {
const response = await fetch(ensureProtocol(url), { redirect: 'follow' })
const html = await response.text()
const doc = domino.createWindow(html).document
const metadata = getMetadata(doc, url, { title: metadataRuleSets.title, publicationDate: publicationDateRuleSet })
const titleRuleSet = {
rules: [
['h1 > yt-formatted-string.ytd-watch-metadata', el => el.getAttribute('title')],
...metadataRuleSets.title.rules
]
}
const metadata = getMetadata(doc, url, { title: titleRuleSet, publicationDate: publicationDateRuleSet })
const dateHint = ` (${metadata.publicationDate?.getFullYear()})`
const moreThanOneYearAgo = metadata.publicationDate && metadata.publicationDate < datePivot(new Date(), { years: -1 })
@ -613,7 +622,6 @@ export default {
const urlObj = new URL(ensureProtocol(url))
let { hostname, pathname } = urlObj
// remove subdomain from hostname
const parseResult = parse(urlObj.hostname)
if (parseResult?.subdomain?.length > 0) {
hostname = hostname.replace(`${parseResult.subdomain}.`, '')
@ -639,6 +647,9 @@ export default {
} else if (urlObj.hostname === 'yewtu.be') {
const matches = url.match(/(https?:\/\/)?yewtu\.be.*(v=|embed\/)(?<id>[_0-9a-z-]+)/i)
similar = `^(http(s)?:\\/\\/)?yewtu\\.be\\/(watch\\?v\\=|embed\\/)${matches?.groups?.id}&?`
} else {
// only allow ending of mismatching search params
similar += '(?:\\?.*)?$'
}
return await itemQueryWithMeta({
@ -647,7 +658,7 @@ export default {
query: `
${SELECT}
FROM "Item"
WHERE url ~* $1
WHERE url ~* $1 AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID')
ORDER BY created_at DESC
LIMIT 3`
}, similar)
@ -694,7 +705,11 @@ export default {
status: 'ACTIVE',
deletedAt: null,
outlawed: false,
parentId: null
parentId: null,
OR: [
{ invoiceActionState: 'PAID' },
{ invoiceActionState: null }
]
}
if (id) {
where.id = { not: Number(id) }
@ -724,6 +739,24 @@ export default {
homeMaxBoost: homeAgg._max.boost || 0,
subMaxBoost: subAgg?._max.boost || 0
}
},
newComments: async (parent, { rootId, after }, { models, me }) => {
const comments = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
-- comments can be nested, so we need to get all comments that are descendants of the root
${whereClause(
'"Item".path <@ (SELECT path FROM "Item" WHERE id = $1 AND "Item"."lastCommentAt" > $2)',
activeOrMine(me),
'"Item"."created_at" > $2'
)}
ORDER BY "Item"."created_at" ASC`
}, Number(rootId), after)
return { comments }
}
},
@ -831,8 +864,16 @@ export default {
const data = { itemId: Number(id), userId: me.id }
const old = await models.threadSubscription.findUnique({ where: { userId_itemId: data } })
if (old) {
await models.threadSubscription.delete({ where: { userId_itemId: data } })
} else await models.threadSubscription.create({ data })
await models.$executeRaw`
DELETE FROM "ThreadSubscription" ts
USING "Item" i
WHERE ts."userId" = ${me.id}
AND i.path <@ (SELECT path FROM "Item" WHERE id = ${Number(id)})
AND ts."itemId" = i.id
`
} else {
await models.threadSubscription.create({ data })
}
return { id }
},
deleteItem: async (parent, { id }, { me, models }) => {
@ -1148,7 +1189,8 @@ export default {
poll.meVoted = false
}
poll.options = options
poll.randPollOptions = item?.randPollOptions
poll.options = poll.randPollOptions ? shuffleArray(options) : options
poll.count = options.reduce((t, o) => t + o.count, 0)
return poll
@ -1449,7 +1491,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
throw new GqlInputError('item can no longer be edited')
}
if (item.url && !isJob(item)) {
if (item.url && !isJob({ subName, ...item })) {
item.url = ensureProtocol(item.url)
item.url = removeTracking(item.url)
}
@ -1464,7 +1506,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
item = { subName, ...item }
item.forwardUsers = await getForwardUsers(models, forward)
}
item.uploadIds = uploadIdsFromText(item.text, { models })
item.uploadIds = uploadIdsFromText(item.text)
// never change author of item
item.userId = old.userId
@ -1483,7 +1525,7 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd
item.userId = me ? Number(me.id) : USER_ID.anon
item.forwardUsers = await getForwardUsers(models, forward)
item.uploadIds = uploadIdsFromText(item.text, { models })
item.uploadIds = uploadIdsFromText(item.text)
if (item.url && !isJob(item)) {
item.url = ensureProtocol(item.url)

View File

@ -2,7 +2,7 @@ import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor'
import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item'
import { getInvoice, getWithdrawl } from './wallet'
import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
import { replyToSubscription } from '@/lib/webPush'
import { sendPushSubscriptionReply } from '@/lib/webPush'
import { getSub } from './sub'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
@ -316,13 +316,36 @@ export default {
if (meFull.noteCowboyHat) {
queries.push(
`(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'Streak' AS type
`(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'CowboyHat' AS type
FROM "Streak"
WHERE "userId" = $1
AND updated_at < $2
AND type = 'COWBOY_HAT'
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
for (const type of ['HORSE', 'GUN']) {
const gqlType = type.charAt(0) + type.slice(1).toLowerCase()
queries.push(
`(SELECT id::text, "startedAt" AS "sortTime", 0 as "earnedSats", 'New${gqlType}' AS type
FROM "Streak"
WHERE "userId" = $1
AND updated_at < $2
AND type = '${type}'::"StreakType"
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
queries.push(
`(SELECT id::text AS id, "endedAt" AS "sortTime", 0 as "earnedSats", 'Lost${gqlType}' AS type
FROM "Streak"
WHERE "userId" = $1
AND updated_at < $2
AND "endedAt" IS NOT NULL
AND type = '${type}'::"StreakType"
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
}
}
queries.push(
@ -416,7 +439,7 @@ export default {
console.log(`[webPush] created subscription for user ${me.id}: endpoint=${endpoint}`)
}
await replyToSubscription(dbPushSubscription.id, { title: 'Stacker News notifications are now active' })
await sendPushSubscriptionReply(dbPushSubscription)
return dbPushSubscription
},
@ -461,8 +484,13 @@ export default {
},
TerritoryTransfer: {
sub: async (n, args, { models, me }) => {
const transfer = await models.territoryTransfer.findUnique({ where: { id: Number(n.id) }, include: { sub: true } })
return transfer.sub
const [sub] = await models.$queryRaw`
SELECT "Sub".*
FROM "TerritoryTransfer"
JOIN "Sub" ON "Sub"."name" = "TerritoryTransfer"."subName"
WHERE "TerritoryTransfer"."id" = ${Number(n.id)}`
return sub
}
},
JobChanged: {
@ -500,23 +528,14 @@ export default {
}
}
},
Streak: {
CowboyHat: {
days: async (n, args, { models }) => {
const res = await models.$queryRaw`
SELECT "endedAt" - "startedAt" AS days
SELECT "endedAt"::date - "startedAt"::date AS days
FROM "Streak"
WHERE id = ${Number(n.id)} AND "endedAt" IS NOT NULL
`
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,6 +1,5 @@
import { amountSchema, validateSchema } from '@/lib/validate'
import { getAd, getItem } from './item'
import { topUsers } from './user'
import performPaidAction from '../paidAction'
import { GqlInputError } from '@/lib/error'
@ -152,13 +151,6 @@ export default {
}
},
Rewards: {
leaderboard: async (parent, args, { models, ...context }) => {
// get to and from using postgres because it's easier to do there
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: 500 }, { models, ...context })
},
total: async (parent, args, { models }) => {
if (!parent.total) {
return 0

View File

@ -1,6 +1,7 @@
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { whenToFrom } from '@/lib/time'
import { getItem, itemQueryWithMeta, SELECT } from './item'
import { parse } from 'tldts'
function queryParts (q) {
const regex = /"([^"]*)"/gm
@ -253,24 +254,17 @@ export default {
// if search contains a url term, modify the query text
if (url) {
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 ?? ''}` }
})
let uri = url.slice(4)
termQueries.push({
match_bool_prefix: { url: { query: uri, operator: 'and', boost: 1000 } }
})
const parsed = parse(uri)
if (parsed?.subdomain?.length > 0) {
uri = uri.replace(`${parsed.subdomain}.`, '')
}
termQueries.push({
wildcard: { url: { value: `*${uri}*` } }
})
}
// if nym, items must contain nym
@ -289,25 +283,23 @@ export default {
// if quoted phrases, items must contain entire phrase
for (const quote of quotes) {
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,
fields: ['title.exact', 'text.exact'],
type: 'phrase'
}
})
termQueries.push({
multi_match: {
query: quote,
fields: ['title.exact^10', 'text.exact'],
type: 'phrase',
fields: ['title', 'text']
boost: 1000
}
})
}
// functions for boosting search rank by recency or popularity
switch (sort) {
case 'comments':
functions.push({
@ -395,6 +387,24 @@ export default {
fields: ['title^10', 'text'],
boost: 1000
}
},
// match on exact fields higher
{
multi_match: {
query,
type: 'best_fields',
fields: ['title.exact^10', 'text.exact'],
boost: 100
}
},
// exact phrase matches higher
{
multi_match: {
query,
fields: ['title.exact^10', 'text.exact'],
type: 'phrase',
boost: 10000
}
}
]
@ -406,6 +416,7 @@ export default {
if (process.env.OPENSEARCH_MODEL_ID) {
osQuery = {
hybrid: {
pagination_depth: 50,
queries: [
{
bool: {
@ -457,7 +468,9 @@ export default {
highlight: {
fields: {
title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
text: { number_of_fragments: 5, order: 'score', pre_tags: ['***'], post_tags: ['***'] }
'title.exact': { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
text: { number_of_fragments: 5, order: 'score', pre_tags: ['***'], post_tags: ['***'] },
'text.exact': { number_of_fragments: 5, order: 'score', pre_tags: ['***'], post_tags: ['***'] }
}
}
}
@ -492,8 +505,14 @@ export default {
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
item.searchText = (e.highlight?.text && e.highlight.text.join(' ... ')) || undefined
// prefer the fuzzier highlight for title
item.searchTitle = e.highlight?.title?.[0] || e.highlight?.['title.exact']?.[0] || item.title
// prefer the exact highlight for text
const searchTextHighlight = e.highlight?.['text.exact'] || e.highlight?.text || []
item.searchText = searchTextHighlight?.join(' ... ')
return item
})

View File

@ -5,6 +5,7 @@ import { viewGroup } from './growth'
import { notifyTerritoryTransfer } from '@/lib/webPush'
import performPaidAction from '../paidAction'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { uploadIdsFromText } from './upload'
export async function getSub (parent, { name }, { models, me }) {
if (!name) return null
@ -35,6 +36,27 @@ export async function getSub (parent, { name }, { models, me }) {
export default {
Query: {
sub: getSub,
subSuggestions: async (parent, { q, limit = 5 }, { models }) => {
let subs = []
if (q) {
subs = await models.$queryRaw`
SELECT name
FROM "Sub"
WHERE status = 'ACTIVE'
AND SIMILARITY(name, ${q}) > 0.1
ORDER BY SIMILARITY(name, ${q}) DESC
LIMIT ${limit}`
} else {
subs = await models.$queryRaw`
SELECT name
FROM "Sub"
WHERE status = 'ACTIVE'
ORDER BY name ASC
LIMIT ${limit}`
}
return subs
},
subs: async (parent, args, { models, me }) => {
if (me) {
const currentUser = await models.user.findUnique({ where: { id: me.id } })
@ -106,7 +128,7 @@ export default {
subs
}
},
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models }) => {
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models, me }) => {
if (!name) {
throw new GqlInputError('must supply user name')
}
@ -129,26 +151,56 @@ export default {
}
const subs = await models.$queryRawUnsafe(`
SELECT "Sub".*,
"Sub".created_at as "createdAt",
COALESCE(floor(sum(msats_revenue)/1000), 0) as revenue,
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked,
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments
FROM ${viewGroup(range, 'sub_stats')}
JOIN "Sub" on "Sub".name = u.sub_name
WHERE "Sub"."userId" = $3
AND "Sub".status = 'ACTIVE'
GROUP BY "Sub".name
ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
OFFSET $4
LIMIT $5`, ...range, user.id, decodedCursor.offset, limit)
SELECT "Sub".*,
"Sub".created_at as "createdAt",
COALESCE(floor(sum(msats_revenue)/1000), 0) as revenue,
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked,
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments,
ss."userId" IS NOT NULL as "meSubscription",
ms."userId" IS NOT NULL as "meMuteSub"
FROM ${viewGroup(range, 'sub_stats')}
JOIN "Sub" on "Sub".name = u.sub_name
LEFT JOIN "SubSubscription" ss ON ss."subName" = "Sub".name AND ss."userId" IS NOT DISTINCT FROM $4
LEFT JOIN "MuteSub" ms ON ms."subName" = "Sub".name AND ms."userId" IS NOT DISTINCT FROM $4
WHERE "Sub"."userId" = $3
AND "Sub".status = 'ACTIVE'
GROUP BY "Sub".name, ss."userId", ms."userId"
ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
OFFSET $5
LIMIT $6
`, ...range, user.id, me?.id, decodedCursor.offset, limit)
return {
cursor: subs.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
subs
}
},
mySubscribedSubs: async (parent, { cursor }, { models, me }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const decodedCursor = decodeCursor(cursor)
const subs = await models.$queryRaw`
SELECT "Sub".*,
"MuteSub"."userId" IS NOT NULL as "meMuteSub",
TRUE as "meSubscription"
FROM "SubSubscription"
JOIN "Sub" ON "SubSubscription"."subName" = "Sub".name
LEFT JOIN "MuteSub" ON "MuteSub"."subName" = "Sub".name AND "MuteSub"."userId" = ${me.id}
WHERE "SubSubscription"."userId" = ${me.id}
AND "Sub".status <> 'STOPPED'
ORDER BY "Sub".name ASC
OFFSET ${decodedCursor.offset}
LIMIT ${LIMIT}
`
return {
cursor: subs.length === LIMIT ? nextCursorEncoded(decodedCursor, LIMIT) : null,
subs
}
}
},
Mutation: {
@ -159,6 +211,8 @@ export default {
await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } })
data.uploadIds = uploadIdsFromText(data.desc)
if (data.oldName) {
return await updateSub(parent, data, { me, models, lnd })
} else {

View File

@ -6,7 +6,17 @@ import { msatsToSats } from '@/lib/format'
export default {
Query: {
uploadFees: async (parent, { s3Keys }, { models, me }) => {
return uploadFees(s3Keys, { models, me })
const fees = await uploadFees(s3Keys, { models, me })
// GraphQL doesn't support bigint
return {
totalFees: Number(fees.totalFees),
totalFeesMsats: Number(fees.totalFeesMsats),
uploadFees: Number(fees.uploadFees),
uploadFeesMsats: Number(fees.uploadFeesMsats),
nUnpaid: Number(fees.nUnpaid),
bytesUnpaid: Number(fees.bytesUnpaid),
bytes24h: Number(fees.bytes24h)
}
}
},
Mutation: {
@ -54,17 +64,36 @@ export default {
}
}
export function uploadIdsFromText (text, { models }) {
export function uploadIdsFromText (text) {
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 }
const [{
bytes24h,
bytesUnpaid,
nUnpaid,
uploadFeesMsats
}] = await models.$queryRaw`SELECT * FROM upload_fees(${me?.id ?? USER_ID.anon}::INTEGER, ${s3Keys}::INTEGER[])`
const uploadFees = BigInt(msatsToSats(uploadFeesMsats))
const totalFeesMsats = BigInt(nUnpaid) * uploadFeesMsats
const totalFees = BigInt(msatsToSats(totalFeesMsats))
return { bytes24h, bytesUnpaid, nUnpaid, uploadFees, uploadFeesMsats, totalFees, totalFeesMsats }
}
export async function throwOnExpiredUploads (uploadIds, { tx }) {
if (uploadIds.length === 0) return
const existingUploads = await tx.upload.findMany({
where: { id: { in: uploadIds } },
select: { id: true }
})
const existingIds = new Set(existingUploads.map(upload => upload.id))
const deletedIds = uploadIds.filter(id => !existingIds.has(id))
if (deletedIds.length > 0) {
throw new Error(`upload(s) ${deletedIds.join(', ')} are expired, consider reuploading.`)
}
}

View File

@ -11,6 +11,7 @@ import assertApiKeyNotPermitted from './apiKey'
import { hashEmail } from '@/lib/crypto'
import { isMuted } from '@/lib/user'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { processCrop } from '@/worker/imgproxy'
const contributors = new Set()
@ -727,6 +728,18 @@ export default {
return true
},
cropPhoto: async (parent, { photoId, cropData }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const croppedUrl = await processCrop({ photoId: Number(photoId), cropData })
if (!croppedUrl) {
throw new GqlInputError('can\'t crop photo')
}
return croppedUrl
},
setPhoto: async (parent, { photoId }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
@ -898,6 +911,22 @@ export default {
await models.user.update({ where: { id: me.id }, data: { hideWelcomeBanner: true } })
return true
},
hideWalletRecvPrompt: async (parent, data, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.user.update({ where: { id: me.id }, data: { hideWalletRecvPrompt: true } })
return true
},
setDiagnostics: async (parent, { diagnostics }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.user.update({ where: { id: me.id }, data: { diagnostics } })
return diagnostics
}
},
@ -1071,6 +1100,9 @@ export default {
return false
}
return !!user.tipRandomMin && !!user.tipRandomMax
},
hideWalletRecvPrompt: async (user, args, { models }) => {
return user.hideWalletRecvPrompt || user.hasRecvWallet
}
},
@ -1082,19 +1114,17 @@ export default {
return user.streak
},
gunStreak: async (user, args, { models }) => {
hasSendWallet: async (user, args, { models }) => {
if (user.hideCowboyHat) {
return null
return false
}
return user.gunStreak
return user.hasSendWallet
},
horseStreak: async (user, args, { models }) => {
hasRecvWallet: async (user, args, { models }) => {
if (user.hideCowboyHat) {
return null
return false
}
return user.horseStreak
return user.hasRecvWallet
},
maxStreak: async (user, args, { models }) => {
if (user.hideCowboyHat) {
@ -1102,8 +1132,9 @@ export default {
}
const [{ max }] = await models.$queryRaw`
SELECT MAX(COALESCE("endedAt", (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt")
FROM "Streak" WHERE "userId" = ${user.id}`
SELECT MAX(COALESCE("endedAt"::date, (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt"::date)
FROM "Streak" WHERE "userId" = ${user.id}
AND type = 'COWBOY_HAT'`
return max
},
isContributor: async (user, args, { me }) => {

View File

@ -1,75 +0,0 @@
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
}
}
}

View File

@ -5,90 +5,23 @@ import {
import crypto, { timingSafeEqual } from 'crypto'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item'
import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format'
import { msatsToSats, msatsToSatsDecimal } from '@/lib/format'
import {
USER_ID, INVOICE_RETENTION_DAYS,
PAID_ACTION_PAYMENT_METHODS,
WALLET_CREATE_INVOICE_TIMEOUT_MS,
WALLET_RETRY_AFTER_MS,
WALLET_RETRY_BEFORE_MS,
WALLET_MAX_RETRIES
} from '@/lib/constants'
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import { validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
import { bolt11Tags } from '@/lib/bolt11'
import { finalizeHodlInvoice } from '@/worker/wallet'
import walletDefs from '@/wallets/server'
import { generateResolverName, generateTypeDefName } from '@/wallets/graphql'
import { lnAddrOptions } from '@/lib/lnurl'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { getNodeSockets, getOurPubkey } from '../lnd'
import validateWallet from '@/wallets/validate'
import { canReceive, getWalletByType } from '@/wallets/common'
import { getNodeSockets } from '../lnd'
import performPaidAction from '../paidAction'
import performPayingAction from '../payingAction'
import { timeoutSignal, withTimeout } from '@/lib/time'
function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:')
for (const walletDef of walletDefs) {
const resolverName = generateResolverName(walletDef.walletField)
console.log(resolverName)
resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
console.log('resolving', resolverName, { settings, validateLightning, vaultEntries, ...data })
let existingVaultEntries
if (typeof vaultEntries === 'undefined' && data.id) {
// this mutation was sent from an unsynced client
// to pass validation, we need to add the existing vault entries for validation
// in case the client is removing the receiving config
existingVaultEntries = await models.vaultEntry.findMany({
where: {
walletId: Number(data.id)
}
})
}
const validData = await validateWallet(walletDef,
{ ...data, ...settings, vaultEntries: vaultEntries ?? existingVaultEntries },
{ serverSide: true })
if (validData) {
data && Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
settings && Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
}
// wallet in shape of db row
const wallet = {
field: walletDef.walletField,
type: walletDef.walletType,
userId: me?.id
}
const logger = walletLogger({ wallet, models })
return await upsertWallet({
wallet,
walletDef,
testCreateInvoice:
walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data })
? (data) => withTimeout(
walletDef.testCreateInvoice(data, {
logger,
signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS)
}),
WALLET_CREATE_INVOICE_TIMEOUT_MS)
: null
}, {
settings,
data,
vaultEntries
}, { logger, me, models })
}
}
console.groupEnd()
return resolvers
}
export async function getInvoice (parent, { id }, { me, models, lnd }) {
const inv = await models.invoice.findUnique({
@ -154,54 +87,6 @@ export function verifyHmac (hash, hmac) {
const resolvers = {
Query: {
invoice: getInvoice,
wallet: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
return await models.wallet.findUnique({
where: {
userId: me.id,
id: Number(id)
},
include: {
vaultEntries: true
}
})
},
walletByType: async (parent, { type }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const wallet = await models.wallet.findFirst({
where: {
userId: me.id,
type
},
include: {
vaultEntries: true
}
})
return wallet
},
wallets: async (parent, args, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
return await models.wallet.findMany({
include: {
vaultEntries: true
},
where: {
userId: me.id
},
orderBy: {
priority: 'asc'
}
})
},
withdrawl: getWithdrawl,
direct: async (parent, { id }, { me, models }) => {
if (!me) {
@ -407,67 +292,6 @@ const resolvers = {
facts: history
}
},
walletLogs: async (parent, { type, from, to, cursor }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
// we cursoring with the wallet logs on the client
// if we have from, don't use cursor
// regardless, store the state of the cursor for the next call
const decodedCursor = cursor ? decodeCursor(cursor) : { offset: 0, time: to ?? new Date() }
let logs = []
let nextCursor
if (from) {
logs = await models.walletLog.findMany({
where: {
userId: me.id,
wallet: type ?? undefined,
createdAt: {
gt: from ? new Date(Number(from)) : undefined,
lte: to ? new Date(Number(to)) : undefined
}
},
include: {
invoice: true,
withdrawal: true
},
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }
]
})
nextCursor = nextCursorEncoded(decodedCursor, logs.length)
} else {
logs = await models.walletLog.findMany({
where: {
userId: me.id,
wallet: type ?? undefined,
createdAt: {
lte: decodedCursor.time
}
},
include: {
invoice: true,
withdrawal: true
},
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }
],
take: LIMIT,
skip: decodedCursor.offset
})
nextCursor = logs.length === LIMIT ? nextCursorEncoded(decodedCursor, logs.length) : null
}
return {
cursor: nextCursor,
entries: logs
}
},
failedInvoices: async (parent, args, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
@ -481,38 +305,20 @@ const resolvers = {
AND "cancelledAt" < now() - ${`${WALLET_RETRY_AFTER_MS} milliseconds`}::interval
AND "cancelledAt" > now() - ${`${WALLET_RETRY_BEFORE_MS} milliseconds`}::interval
AND "paymentAttempt" < ${WALLET_MAX_RETRIES}
AND (
"actionType" = 'ITEM_CREATE' OR
"actionType" = 'ZAP' OR
"actionType" = 'DOWN_ZAP' OR
"actionType" = 'POLL_VOTE' OR
"actionType" = 'BOOST'
)
ORDER BY id DESC`
}
},
Wallet: {
wallet: async (wallet) => {
return {
...wallet.wallet,
__resolveType: generateTypeDefName(wallet.type)
}
}
},
WalletDetails: {
__resolveType: wallet => wallet.__resolveType
},
InvoiceOrDirect: {
__resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType
},
Mutation: {
createInvoice: async (parent, { amount }, { me, models, lnd, headers }) => {
await validateSchema(amountSchema, { amount })
await assertGofacYourself({ models, headers })
const { invoice, paymentMethod } = await performPaidAction('RECEIVE', {
msats: satsToMsats(amount)
}, { models, lnd, me })
return {
...invoice,
__resolveType:
paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT ? 'Direct' : 'Invoice'
}
},
createWithdrawl: createWithdrawal,
sendToLnAddr,
cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, boss }) => {
@ -573,43 +379,6 @@ const resolvers = {
return true
},
setWalletPriority: async (parent, { id, priority }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.wallet.update({ where: { userId: me.id, id: Number(id) }, data: { priority } })
return true
},
removeWallet: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const wallet = await models.wallet.findUnique({ where: { userId: me.id, id: Number(id) } })
if (!wallet) {
throw new GqlInputError('wallet not found')
}
const logger = walletLogger({ wallet, models })
await models.wallet.delete({ where: { userId: me.id, id: Number(id) } })
if (canReceive({ def: getWalletByType(wallet.type), config: wallet.wallet })) {
logger.info('details for receiving deleted')
}
return true
},
deleteWalletLogs: async (parent, { wallet }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.walletLog.deleteMany({ where: { userId: me.id, wallet } })
return true
},
buyCredits: async (parent, { credits }, { me, models, lnd }) => {
return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd })
}
@ -753,223 +522,12 @@ const resolvers = {
return item
},
sats: fact => msatsToSatsDecimal(fact.msats)
},
WalletLogEntry: {
context: async ({ level, context, invoice, withdrawal }, args, { models }) => {
const isError = ['error', 'warn'].includes(level.toLowerCase())
if (withdrawal) {
return {
...await logContextFromBolt11(withdrawal.bolt11),
...(withdrawal.preimage ? { preimage: withdrawal.preimage } : {}),
...(isError ? { max_fee: formatMsats(withdrawal.msatsFeePaying) } : {})
}
}
// XXX never return invoice as context because it might leak sensitive sender details
// if (invoice) { ... }
return context
}
}
}
export default injectResolvers(resolvers)
export default resolvers
const logContextFromBolt11 = async (bolt11) => {
const decoded = await parsePaymentRequest({ request: bolt11 })
return {
bolt11,
amount: formatMsats(decoded.mtokens),
payment_hash: decoded.id,
created_at: decoded.created_at,
expires_at: decoded.expires_at,
description: decoded.description
}
}
export const walletLogger = ({ wallet, models }) => {
// no-op logger if wallet is not provided
if (!wallet) {
return {
ok: () => {},
info: () => {},
error: () => {},
warn: () => {}
}
}
// server implementation of wallet logger interface on client
const log = (level) => async (message, ctx = {}) => {
try {
let { invoiceId, withdrawalId, ...context } = ctx
if (context.bolt11) {
// automatically populate context from bolt11 to avoid duplicating this code
context = {
...context,
...await logContextFromBolt11(context.bolt11)
}
}
await models.walletLog.create({
data: {
userId: wallet.userId,
wallet: wallet.type,
level,
message,
context,
invoiceId,
withdrawalId
}
})
} catch (err) {
console.error('error creating wallet log:', err)
}
}
return {
ok: (message, context) => log('SUCCESS')(message, context),
info: (message, context) => log('INFO')(message, context),
error: (message, context) => log('ERROR')(message, context),
warn: (message, context) => log('WARN')(message, context)
}
}
async function upsertWallet (
{ wallet, walletDef, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) {
if (!me) {
throw new GqlAuthenticationError()
}
assertApiKeyNotPermitted({ me })
if (testCreateInvoice) {
try {
await testCreateInvoice(data)
} catch (err) {
const message = 'failed to create test invoice: ' + (err.message || err.toString?.())
logger.error(message)
throw new GqlInputError(message)
}
}
const { id, enabled, priority, ...recvConfig } = data
const txs = []
if (id) {
const oldVaultEntries = await models.vaultEntry.findMany({ where: { userId: me.id, walletId: Number(id) } })
// createMany is the set difference of the new - old
// deleteMany is the set difference of the old - new
// updateMany is the intersection of the old and new
const difference = (a = [], b = [], key = 'key') => a.filter(x => !b.find(y => y[key] === x[key]))
const intersectionMerge = (a = [], b = [], key = 'key') => a.filter(x => b.find(y => y[key] === x[key]))
.map(x => ({ [key]: x[key], ...b.find(y => y[key] === x[key]) }))
txs.push(
models.wallet.update({
where: { id: Number(id), userId: me.id },
data: {
enabled,
priority,
// client only wallets have no receive config and thus don't have their own table
...(Object.keys(recvConfig).length > 0
? {
[wallet.field]: {
upsert: {
create: recvConfig,
update: recvConfig
}
}
}
: {}),
...(vaultEntries
? {
vaultEntries: {
deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({
userId: me.id, key
})),
create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({
key, iv, value, userId: me.id
})),
update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({
where: { userId_key: { userId: me.id, key } },
data: { value, iv }
}))
}
}
: {})
},
include: {
vaultEntries: true
}
})
)
} else {
txs.push(
models.wallet.create({
include: {
vaultEntries: true
},
data: {
enabled,
priority,
userId: me.id,
type: wallet.type,
// client only wallets have no receive config and thus don't have their own table
...(Object.keys(recvConfig).length > 0 ? { [wallet.field]: { create: recvConfig } } : {}),
...(vaultEntries
? {
vaultEntries: {
createMany: {
data: vaultEntries?.map(({ key, iv, value }) => ({ key, iv, value, userId: me.id }))
}
}
}
: {})
}
})
)
}
if (settings) {
txs.push(
models.user.update({
where: { id: me.id },
data: settings
})
)
}
if (canReceive({ def: walletDef, config: recvConfig })) {
txs.push(
models.walletLog.createMany({
data: {
userId: me.id,
wallet: wallet.type,
level: 'SUCCESS',
message: id ? 'details for receiving updated' : 'details for receiving saved'
}
}),
models.walletLog.create({
data: {
userId: me.id,
wallet: wallet.type,
level: enabled ? 'SUCCESS' : 'INFO',
message: enabled ? 'receiving enabled' : 'receiving disabled'
}
})
)
}
const [upsertedWallet] = await models.$transaction(txs)
return upsertedWallet
}
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, wallet, logger }) {
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, protocol, logger }) {
assertApiKeyNotPermitted({ me })
await validateSchema(withdrawlSchema, { invoice, maxFee })
await assertGofacYourself({ models, headers })
@ -1019,10 +577,10 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
throw new GqlInputError('SN cannot pay an invoice that SN is proxying')
}
return await performPayingAction({ bolt11: invoice, maxFee, walletId: wallet?.id }, { me, models, lnd })
return await performPayingAction({ bolt11: invoice, maxFee, protocolId: protocol?.id }, { me, models, lnd })
}
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
{ me, models, lnd, headers }) {
if (!me) {
throw new GqlAuthenticationError()
@ -1040,11 +598,9 @@ export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers })
}
export async function fetchLnAddrInvoice (
async function fetchLnAddrInvoice (
{ addr, amount, maxFee, comment, ...payer },
{
me, models, lnd, autoWithdraw = false
}) {
{ me, models, lnd }) {
const options = await lnAddrOptions(addr)
await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
@ -1081,14 +637,6 @@ export async function fetchLnAddrInvoice (
// decode invoice
try {
const decoded = await parsePaymentRequest({ request: res.pr })
const ourPubkey = await getOurPubkey({ lnd })
if (autoWithdraw && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') {
// unset lnaddr so we don't trigger another withdrawal with same destination
await models.wallet.deleteMany({
where: { userId: me.id, type: 'LIGHTNING_ADDRESS' }
})
throw new Error('automated withdrawals to other stackers are not allowed')
}
if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {
throw new Error('invoice has incorrect amount')
}

View File

@ -1,17 +0,0 @@
import { WebClient, LogLevel } from '@slack/web-api'
const slackClient = global.slackClient || (() => {
if (!process.env.SLACK_BOT_TOKEN && !process.env.SLACK_CHANNEL_ID) {
console.warn('SLACK_* env vars not set, skipping slack setup')
return null
}
console.log('initing slack client')
const client = new WebClient(process.env.SLACK_BOT_TOKEN, {
logLevel: LogLevel.INFO
})
return client
})()
if (process.env.NODE_ENV === 'development') global.slackClient = slackClient
export default slackClient

View File

@ -15,7 +15,7 @@ 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'
import { MULTI_AUTH_ANON, MULTI_AUTH_POINTER } from '@/lib/auth'
export default async function getSSRApolloClient ({ req, res, me = null }) {
const session = req && await getServerSession(req, res, getAuthOptions(req))
@ -156,7 +156,7 @@ export function getGetServerSideProps (
// 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) {
if (req.cookies[MULTI_AUTH_POINTER] === MULTI_AUTH_ANON) {
me = null
}

View File

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

View File

@ -11,6 +11,7 @@ export default gql`
auctionPosition(sub: String, id: ID, boost: Int): Int!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int!
newComments(rootId: ID, after: Date): Comments!
}
type BoostPositions {
@ -57,7 +58,7 @@ export default gql`
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!
randPollOptions: Boolean, hash: String, hmac: String): ItemPaidAction!
updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction!
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
@ -81,6 +82,7 @@ export default gql`
meInvoiceActionState: InvoiceActionState
count: Int!
options: [PollOption!]!
randPollOptions: Boolean
}
type Items {
@ -147,6 +149,7 @@ export default gql`
ncomments: Int!
nDirectComments: Int!
comments(sort: String, cursor: String): Comments!
injected: Boolean!
path: String
position: Int
prior: Int

View File

@ -75,13 +75,6 @@ export default gql`
tipComments: Int!
}
type Streak {
id: ID!
sortTime: Date!
days: Int
type: String!
}
type Earn {
id: ID!
earnedSats: Int!
@ -156,11 +149,37 @@ export default gql`
sortTime: Date!
}
type CowboyHat {
id: ID!
sortTime: Date!
days: Int
}
type NewHorse {
id: ID!
sortTime: Date!
}
type LostHorse {
id: ID!
sortTime: Date!
}
type NewGun {
id: ID!
sortTime: Date!
}
type LostGun {
id: ID!
sortTime: Date!
}
union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
| Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
| FollowActivity | ForwardedVotification | Revenue | SubStatus
| TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification
| ReferralReward
| ReferralReward | CowboyHat | NewHorse | LostHorse | NewGun | LostGun
type Notifications {
lastChecked: Date

View File

@ -18,7 +18,6 @@ export default gql`
total: Int!
time: Date!
sources: [NameValue!]!
leaderboard: UsersNullable
ad: Item
}

View File

@ -7,6 +7,8 @@ export default gql`
subs: [Sub!]!
topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
mySubscribedSubs(cursor: String): Subs
subSuggestions(q: String!, limit: Limit): [Sub!]!
}
type Subs {

View File

@ -29,21 +29,34 @@ export default gql`
users: [User!]!
}
input CropData {
x: Float!
y: Float!
width: Float!
height: Float!
originalWidth: Int!
originalHeight: Int!
scale: Float!
}
extend type Mutation {
setName(name: String!): String
setSettings(settings: SettingsInput!): User
cropPhoto(photoId: ID!, cropData: CropData): String!
setPhoto(photoId: ID!): Int!
upsertBio(text: String!): ItemPaidAction!
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
unlinkAuth(authType: String!): AuthMethods!
linkUnverifiedEmail(email: String!): Boolean
hideWelcomeBanner: Boolean
hideWalletRecvPrompt: Boolean
subscribeUserPosts(id: ID): User
subscribeUserComments(id: ID): User
toggleMute(id: ID): User
generateApiKey(id: ID!): String
deleteApiKey(id: ID!): User
disableFreebies: Boolean
setDiagnostics(diagnostics: Boolean!): Boolean
}
type User {
@ -74,7 +87,6 @@ export default gql`
input SettingsInput {
autoDropBolt11s: Boolean!
diagnostics: Boolean!
noReferralLinks: Boolean!
fiatCurrency: String!
satsFilter: Int!
@ -112,10 +124,6 @@ export default gql`
zapUndos: Int
wildWestMode: Boolean!
withdrawMaxFeeDefault: Int!
proxyReceive: Boolean
directReceive: Boolean
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
}
type AuthMethods {
@ -141,16 +149,18 @@ export default gql`
"""
lastCheckedJobs: String
hideWelcomeBanner: Boolean!
hideWalletRecvPrompt: Boolean!
tipPopover: Boolean!
upvotePopover: Boolean!
hasInvites: Boolean!
apiKeyEnabled: Boolean!
showPassphrase: Boolean!
diagnostics: Boolean!
"""
mirrors SettingsInput
"""
autoDropBolt11s: Boolean!
diagnostics: Boolean!
noReferralLinks: Boolean!
fiatCurrency: String!
satsFilter: Int!
@ -191,14 +201,9 @@ export default gql`
wildWestMode: Boolean!
withdrawMaxFeeDefault: Int!
autoWithdrawThreshold: Int
autoWithdrawMaxFeePercent: Float
autoWithdrawMaxFeeTotal: Int
vaultKeyHash: String
vaultKeyHashUpdatedAt: Date
walletsUpdatedAt: Date
proxyReceive: Boolean
directReceive: Boolean
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
}
type UserOptional {
@ -211,6 +216,9 @@ export default gql`
streak: Int
gunStreak: Int
horseStreak: Int
hasSendWallet: Boolean
hasRecvWallet: Boolean
hideWalletRecvPrompt: Boolean
maxStreak: Int
isContributor: Boolean
githubId: String

View File

@ -1,29 +0,0 @@
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,66 +1,8 @@
import { gql } from 'graphql-tag'
import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName } from '@/wallets/graphql'
import { isServerField } from '@/wallets/common'
import walletDefs from '@/wallets/server'
function injectTypeDefs (typeDefs) {
const injected = [rawTypeDefs(), mutationTypeDefs()]
return `${typeDefs}\n\n${injected.join('\n\n')}\n`
}
const shared = 'walletId: ID, templateName: ID, enabled: Boolean!'
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 `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 = `
const typeDefs = gql`
extend type Query {
invoice(id: ID!): Invoice!
withdrawl(id: ID!): Withdrawl!
@ -68,23 +10,151 @@ const typeDefs = `
numBolt11s: Int!
connectAddress: String!
walletHistory(cursor: String, inc: String): History
wallets(includeReceivers: Boolean, includeSenders: Boolean, onlyEnabled: Boolean, prioritySort: String): [Wallet!]!
wallet(id: ID!): Wallet
walletByType(type: String!): Wallet
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
wallets: [WalletOrTemplate!]!
wallet(id: ID, name: String): WalletOrTemplate
walletSettings: WalletSettings!
walletLogs(protocolId: Int, cursor: String, debug: Boolean): WalletLogs!
failedInvoices: [Invoice!]!
}
extend type Mutation {
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, 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!
# upserts
upsertWalletSendLNbits(
${shared},
url: String!,
apiKey: VaultEntryInput!
): WalletSendLNbits!
upsertWalletRecvLNbits(
${shared},
url: String!,
apiKey: String!
): WalletRecvLNbits!
upsertWalletSendPhoenixd(
${shared},
url: String!,
apiKey: VaultEntryInput!
): WalletSendPhoenixd!
upsertWalletRecvPhoenixd(
${shared},
url: String!,
apiKey: String!
): WalletRecvPhoenixd!
upsertWalletSendBlink(
${shared},
currency: VaultEntryInput!,
apiKey: VaultEntryInput!
): WalletSendBlink!
upsertWalletRecvBlink(
${shared},
currency: String!,
apiKey: String!
): WalletRecvBlink!
upsertWalletRecvLightningAddress(
${shared},
address: String!
): WalletRecvLightningAddress!
upsertWalletSendNWC(
${shared},
url: VaultEntryInput!
): WalletSendNWC!
upsertWalletRecvNWC(
${shared},
url: String!
): WalletRecvNWC!
upsertWalletRecvCLNRest(
${shared},
socket: String!,
rune: String!,
cert: String
): WalletRecvCLNRest!
upsertWalletRecvLNDGRPC(
${shared},
socket: String!,
macaroon: String!,
cert: String
): WalletRecvLNDGRPC!
upsertWalletSendLNC(
${shared},
pairingPhrase: VaultEntryInput!,
localKey: VaultEntryInput!,
remoteKey: VaultEntryInput!,
serverHost: VaultEntryInput!
): WalletSendLNC!
upsertWalletSendWebLN(
${shared}
): WalletSendWebLN!
# tests
testWalletRecvNWC(
url: String!
): Boolean!
testWalletRecvLightningAddress(
address: String!
): Boolean!
testWalletRecvCLNRest(
socket: String!,
rune: String!,
cert: String
): Boolean!
testWalletRecvLNDGRPC(
socket: String!,
macaroon: String!,
cert: String
): Boolean!
testWalletRecvPhoenixd(
url: String!
apiKey: String!
): Boolean!
testWalletRecvLNbits(
url: String!
apiKey: String!
): Boolean!
testWalletRecvBlink(
currency: String!
apiKey: String!
): Boolean!
# delete
removeWallet(id: ID!): Boolean
removeWalletProtocol(id: ID!): Boolean
# crypto
updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean
updateKeyHash(keyHash: String!): Boolean
resetWallets(newKeyHash: String!): Boolean
disablePassphraseExport: Boolean
# settings
setWalletSettings(settings: WalletSettingsInput!): Boolean
setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean
# logs
addWalletLog(protocolId: Int, level: String!, message: String!, timestamp: Date!, invoiceId: Int): Boolean
deleteWalletLogs(protocolId: Int, debug: Boolean): Boolean
}
type BuyCreditsResult {
@ -95,15 +165,155 @@ const typeDefs = `
id: ID!
}
union WalletOrTemplate = Wallet | WalletTemplate
enum WalletStatus {
OK
WARNING
ERROR
DISABLED
}
type Wallet {
id: ID!
createdAt: Date!
updatedAt: Date!
type: String!
enabled: Boolean!
name: String!
priority: Int!
wallet: WalletDetails!
vaultEntries: [VaultEntry!]!
template: WalletTemplate!
protocols: [WalletProtocol!]!
send: WalletStatus!
receive: WalletStatus!
}
type WalletTemplate {
name: ID!
protocols: [WalletProtocolTemplate!]!
send: WalletStatus!
receive: WalletStatus!
}
type WalletProtocol {
id: ID!
name: String!
send: Boolean!
enabled: Boolean!
config: WalletProtocolConfig!
status: WalletStatus!
}
type WalletProtocolTemplate {
id: ID!
name: String!
send: Boolean!
}
union WalletProtocolConfig =
| WalletSendNWC
| WalletSendLNbits
| WalletSendPhoenixd
| WalletSendBlink
| WalletSendWebLN
| WalletSendLNC
| WalletRecvNWC
| WalletRecvLNbits
| WalletRecvPhoenixd
| WalletRecvBlink
| WalletRecvLightningAddress
| WalletRecvCLNRest
| WalletRecvLNDGRPC
type WalletSettings {
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
autoWithdrawThreshold: Int
autoWithdrawMaxFeePercent: Float
autoWithdrawMaxFeeTotal: Int
proxyReceive: Boolean!
}
input WalletSettingsInput {
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
autoWithdrawThreshold: Int!
autoWithdrawMaxFeePercent: Float!
autoWithdrawMaxFeeTotal: Int!
proxyReceive: Boolean!
}
type WalletSendNWC {
id: ID!
url: VaultEntry!
}
type WalletSendLNbits {
id: ID!
url: String!
apiKey: VaultEntry!
}
type WalletSendPhoenixd {
id: ID!
url: String!
apiKey: VaultEntry!
}
type WalletSendBlink {
id: ID!
currency: VaultEntry!
apiKey: VaultEntry!
}
type WalletSendWebLN {
id: ID!
}
type WalletSendLNC {
id: ID!
pairingPhrase: VaultEntry!
localKey: VaultEntry!
remoteKey: VaultEntry!
serverHost: VaultEntry!
}
type WalletRecvNWC {
id: ID!
url: String!
}
type WalletRecvLNbits {
id: ID!
url: String!
apiKey: String!
}
type WalletRecvPhoenixd {
id: ID!
url: String!
apiKey: String!
}
type WalletRecvBlink {
id: ID!
currency: String!
apiKey: String!
}
type WalletRecvLightningAddress {
id: ID!
address: String!
}
type WalletRecvCLNRest {
id: ID!
socket: String!
rune: String!
cert: String
}
type WalletRecvLNDGRPC {
id: ID!
socket: String!
macaroon: String!
cert: String
}
input AutowithdrawSettings {
@ -112,6 +322,22 @@ const typeDefs = `
autoWithdrawMaxFeeTotal: Int!
}
input WalletEncryptionUpdate {
id: ID!
protocols: [WalletEncryptionUpdateProtocol!]!
}
input WalletEncryptionUpdateProtocol {
name: String!
send: Boolean!
config: JSONObject!
}
input WalletPriorityUpdate {
id: ID!
priority: Int!
}
type Invoice implements InvoiceOrDirect {
id: ID!
createdAt: Date!
@ -186,7 +412,7 @@ const typeDefs = `
cursor: String
}
type WalletLog {
type WalletLogs {
entries: [WalletLogEntry!]!
cursor: String
}
@ -194,11 +420,25 @@ const typeDefs = `
type WalletLogEntry {
id: ID!
createdAt: Date!
wallet: ID!
wallet: Wallet
protocol: WalletProtocol
level: String!
message: String!
context: JSONObject
}
`
export default gql`${injectTypeDefs(typeDefs)}`
type VaultEntry {
id: ID!
iv: String!
value: String!
createdAt: Date!
updatedAt: Date!
}
input VaultEntryInput {
iv: String!
value: String!
keyHash: String!
}
`
export default typeDefs

View File

@ -177,31 +177,84 @@ 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,???,???
dtonon,issue,#1928,#1924,good-first-issue,,,,2k,tips@dtonon.com,2025-04-16
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,???
itsrealfake,issue,#1930,#1167,good-first-issue,,,,2k,smallimagination100035@getalby.com,2025-04-02
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,???,???
Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,2025-04-02
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,me@benthecarman.com,2025-04-16
ed-kung,pr,#2012,#2004,easy,,,,100k,simplestacker@getalby.com,2025-04-02
ed-kung,issue,#2012,#2004,easy,,,,10k,simplestacker@getalby.com,2025-04-02
ed-kung,pr,#1993,#1982,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
rideandslide,issue,#1993,#1982,good-first-issue,,,,2k,koiora@getalby.com,2025-04-02
ed-kung,pr,#1972,#1254,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
SatsAllDay,issue,#1972,#1254,good-first-issue,,,,2k,weareallsatoshi@getalby.com,2025-04-02
ed-kung,pr,#1962,#1343,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
ed-kung,pr,#1962,#1217,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
ed-kung,pr,#1962,#866,easy,,,,100k,simplestacker@getalby.com,2025-04-02
felipebueno,issue,#1962,#866,easy,,,,10k,felipebueno@blink.sv,2025-04-02
cointastical,issue,#1962,#1217,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
Scroogey-SN,pr,#1975,#1964,good-first-issue,,,,20k,Scroogey@coinos.io,2025-04-02
rideandslide,issue,#1986,#1985,good-first-issue,,,,2k,koiora@getalby.com,2025-04-02
kristapsk,issue,#1976,#841,good-first-issue,,,,2k,kristapsk@stacker.news,2025-04-16
ed-kung,pr,#2070,#2061,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-16
ed-kung,issue,#2070,#2061,good-first-issue,,,,2k,simplestacker@getalby.com,2025-04-16
ed-kung,pr,#2070,#2058,easy,,,,100k,simplestacker@getalby.com,2025-04-16
ed-kung,pr,#2070,#2047,medium-hard,,,,500k,simplestacker@getalby.com,2025-04-16
SouthKoreaLN,pr,#2068,#2064,good-first-issue,,,,20k,south_korea_ln@stacker.news,2025-04-16
kepford,issue,#2068,#2064,good-first-issue,,,,2k,penalwink141@minibits.cash,2025-04-16
SouthKoreaLN,pr,#2069,#1990,good-first-issue,,,,20k,south_korea_ln@stacker.news,2025-04-16
cointastical,issue,#2071,#1475,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
brymut,pr,#2082,#2051,easy,low,3,,35k,brymut@stacker.news,2025-04-16
abhiShandy,pr,#2083,#1270,good-first-issue,,,,20k,abhishandy@stacker.news,2025-04-16
cointastical,issue,#2083,#1270,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
brymut,pr,#2093,#1991,easy,,,,100k,brymut@stacker.news,2025-04-16
brymut,pr,#2100,#2090,easy,,3,,70k,brymut@stacker.news,2025-04-24
abhiShandy,pr,#2109,#1221,good-first-issue,,,,20k,abhishandy@stacker.news,2025-04-24
Gudnessuche,issue,#2109,#1221,good-first-issue,,,,2k,everythingsatoshi@getalby.com,2025-04-24
brymut,pr,#2153,#2087,easy,,,,100k,brymut@stacker.news,2025-05-13
brymut,pr,#2152,#2142,good-first-issue,,1,,18k,brymut@stacker.news,2025-05-13
m0wer,pr,#2124,#992,medium,,1,,225k,klk@stacker.news,2025-05-13
ed-kung,issue,#2072,#2043,easy,,,,10k,simplestacker@getalby.com,2025-05-15
ed-kung,helpfulness,#2072,#2043,easy,,,,10k,simplestacker@getalby.com,2025-05-15
SouthKoreaLN,pr,#2072,#2043,easy,,,,100k,south_korea_ln@stacker.news,2025-05-13
m0wer,pr,#2135,#1391,easy,,1,more difficult than planned,150k,klk@stacker.news,2025-05-13
sutt,pr,#2162,#2161,good-first-issue,,,,20k,bounty_hunter@stacker.news,2025-05-15
sutt,issue,#2162,#2161,good-first-issue,,,,2k,bounty_hunter@stacker.news,2025-05-15
brymut,pr,#2171,#2164,easy,,,,100k,brymut@stacker.news,2025-05-21
SouthKoreaLN,issue,#2171,#2164,easy,,,,10k,south_korea_ln@stacker.news,2025-05-21
brymut,pr,#2175,#2173,good-first-issue,,,,20k,brymut@stacker.news,2025-05-21
sutt,pr,#2185,#2183,easy,high,,,200k,bounty_hunter@stacker.news,2025-06-19
sutt,issue,#2185,#2183,easy,high,,,20k,bounty_hunter@stacker.news,2025-06-06
axelvyrn,advisory,#2205,GHSA-x2xp-x867-4jfc,,,,,100k,holonite@speed.app,2025-06-06
brymut,pr,#2184,#2165,easy,,,,100k,brymut@stacker.news,2025-06-06
sutt,pr,#2190,#2187,easy,,,,100k,bounty_hunter@stacker.news,2025-06-06
sutt,issue,#2190,#2187,easy,,,,10k,bounty_hunter@stacker.news,2025-06-06
sutt,pr,#2192,#2188,medium,,,,250k,bounty_hunter@stacker.news,2025-06-19
sutt,issue,#2192,#2188,medium,,,,25k,bounty_hunter@stacker.news,2025-06-19
abhiShandy,pr,#2195,#2181,good-first-issue,,1,,18k,abhishandy@stacker.news,2025-06-13
brymut,pr,#2191,#1409,medium,,2,,200k,brymut@stacker.news,2025-06-13
SatsAllDay,issue,#2191,#1409,medium,,,,20k,weareallsatoshi@getalby.com,2025-06-13
ed-kung,pr,#2217,#2039,medium,,,,250k,simplestacker@getalby.com,2025-06-18
ed-kung,issue,#2217,#2039,medium,,,,25k,simplestacker@getalby.com,2025-06-18
axelvyrn,pr,#2220,#2198,good-first-issue,,5,,10k,holonite@speed.app,2025-06-18
axelvyrn,issue,#2220,#2198,good-first-issue,,,,1k,holonite@speed.app,2025-06-18
brymut,pr,#2221,#2204,good-first-issue,,,,20k,brymut@stacker.news,2025-06-18
brymut,pr,#2235,#2233,good-first-issue,,,,20k,brymut@stacker.news,2025-06-18
brymut,pr,#2250,#2106,good-first-issue,,,,20k,brymut@stacker.news,2025-07-12
SouthKoreaLN,issue,#2267,#2164,easy,,,,10k,south_korea_ln@stacker.news,2025-07-12
pory-gone,pr,#2316,#2277,good-first-issue,,,,20k,pory@porygone.xyz,2025-08-01
brymut,pr,#2326,,good-first-issue,,,,20k,brymut@stacker.news,2025-07-31
brymut,pr,#2332,#2276,easy,,,,100k,brymut@stacker.news,2025-07-31
ed-kung,pr,#2373,#2371,good-first-issue,,,,20k,simplestacker@getalby.com,2025-07-31
ed-kung,issue,#2373,#2371,good-first-issue,,,,2k,simplestacker@getalby.com,2025-07-31
pory-gone,pr,#2381,#2370,good-first-issue,,,,20k,pory@porygone.xyz,???
pory-gone,pr,#2413,#2361,easy,,,,100k,pory@porygone.xyz,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
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 ??? tips@dtonon.com ??? 2025-04-16
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 ??? 2025-04-02
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 ??? 2025-04-02
193 benthecarman issue #1953 #1950 good-first-issue 2k ??? me@benthecarman.com ??? 2025-04-16
194 ed-kung pr #2012 #2004 easy 100k simplestacker@getalby.com ??? 2025-04-02
195 ed-kung issue #2012 #2004 easy 10k simplestacker@getalby.com ??? 2025-04-02
196 ed-kung pr #1993 #1982 good-first-issue 20k simplestacker@getalby.com ??? 2025-04-02
197 rideandslide issue #1993 #1982 good-first-issue 2k ??? koiora@getalby.com ??? 2025-04-02
198 ed-kung pr #1972 #1254 good-first-issue 20k simplestacker@getalby.com ??? 2025-04-02
199 SatsAllDay issue #1972 #1254 good-first-issue 2k weareallsatoshi@getalby.com ??? 2025-04-02
200 ed-kung pr #1962 #1343 good-first-issue 20k simplestacker@getalby.com ??? 2025-04-02
201 ed-kung pr #1962 #1217 good-first-issue 20k simplestacker@getalby.com ??? 2025-04-02
202 ed-kung pr #1962 #866 easy 100k simplestacker@getalby.com ??? 2025-04-02
203 felipebueno issue #1962 #866 easy 10k felipebueno@blink.sv ??? 2025-04-02
204 cointastical issue #1962 #1217 good-first-issue 2k cointastical@stacker.news Cointastical@getAlby.com ??? 2025-04-16
205 Scroogey-SN pr #1975 #1964 good-first-issue 20k Scroogey@coinos.io ??? 2025-04-02
206 rideandslide issue #1986 #1985 good-first-issue 2k ??? koiora@getalby.com ??? 2025-04-02
207 kristapsk issue #1976 #841 good-first-issue 2k ??? kristapsk@stacker.news ??? 2025-04-16
208 ed-kung pr #2070 #2061 good-first-issue 20k simplestacker@getalby.com 2025-04-16
209 ed-kung issue #2070 #2061 good-first-issue 2k simplestacker@getalby.com 2025-04-16
210 ed-kung pr #2070 #2058 easy 100k simplestacker@getalby.com 2025-04-16
211 ed-kung pr #2070 #2047 medium-hard 500k simplestacker@getalby.com 2025-04-16
212 SouthKoreaLN pr #2068 #2064 good-first-issue 20k south_korea_ln@stacker.news 2025-04-16
213 kepford issue #2068 #2064 good-first-issue 2k penalwink141@minibits.cash 2025-04-16
214 SouthKoreaLN pr #2069 #1990 good-first-issue 20k south_korea_ln@stacker.news 2025-04-16
215 cointastical issue #2071 #1475 good-first-issue 2k Cointastical@getAlby.com 2025-04-16
216 brymut pr #2082 #2051 easy low 3 35k brymut@stacker.news 2025-04-16
217 abhiShandy pr #2083 #1270 good-first-issue 20k abhishandy@stacker.news 2025-04-16
218 cointastical issue #2083 #1270 good-first-issue 2k Cointastical@getAlby.com 2025-04-16
219 brymut pr #2093 #1991 easy 100k brymut@stacker.news 2025-04-16
220 brymut pr #2100 #2090 easy 3 70k brymut@stacker.news 2025-04-24
221 abhiShandy pr #2109 #1221 good-first-issue 20k abhishandy@stacker.news 2025-04-24
222 Gudnessuche issue #2109 #1221 good-first-issue 2k everythingsatoshi@getalby.com 2025-04-24
223 brymut pr #2153 #2087 easy 100k brymut@stacker.news 2025-05-13
224 brymut pr #2152 #2142 good-first-issue 1 18k brymut@stacker.news 2025-05-13
225 m0wer pr #2124 #992 medium 1 225k klk@stacker.news 2025-05-13
226 ed-kung issue #2072 #2043 easy 10k simplestacker@getalby.com 2025-05-15
227 ed-kung helpfulness #2072 #2043 easy 10k simplestacker@getalby.com 2025-05-15
228 SouthKoreaLN pr #2072 #2043 easy 100k south_korea_ln@stacker.news 2025-05-13
229 m0wer pr #2135 #1391 easy 1 more difficult than planned 150k klk@stacker.news 2025-05-13
230 sutt pr #2162 #2161 good-first-issue 20k bounty_hunter@stacker.news 2025-05-15
231 sutt issue #2162 #2161 good-first-issue 2k bounty_hunter@stacker.news 2025-05-15
232 brymut pr #2171 #2164 easy 100k brymut@stacker.news 2025-05-21
233 SouthKoreaLN issue #2171 #2164 easy 10k south_korea_ln@stacker.news 2025-05-21
234 brymut pr #2175 #2173 good-first-issue 20k brymut@stacker.news 2025-05-21
235 sutt pr #2185 #2183 easy high 200k bounty_hunter@stacker.news 2025-06-19
236 sutt issue #2185 #2183 easy high 20k bounty_hunter@stacker.news 2025-06-06
237 axelvyrn advisory #2205 GHSA-x2xp-x867-4jfc 100k holonite@speed.app 2025-06-06
238 brymut pr #2184 #2165 easy 100k brymut@stacker.news 2025-06-06
239 sutt pr #2190 #2187 easy 100k bounty_hunter@stacker.news 2025-06-06
240 sutt issue #2190 #2187 easy 10k bounty_hunter@stacker.news 2025-06-06
241 sutt pr #2192 #2188 medium 250k bounty_hunter@stacker.news 2025-06-19
242 sutt issue #2192 #2188 medium 25k bounty_hunter@stacker.news 2025-06-19
243 abhiShandy pr #2195 #2181 good-first-issue 1 18k abhishandy@stacker.news 2025-06-13
244 brymut pr #2191 #1409 medium 2 200k brymut@stacker.news 2025-06-13
245 SatsAllDay issue #2191 #1409 medium 20k weareallsatoshi@getalby.com 2025-06-13
246 ed-kung pr #2217 #2039 medium 250k simplestacker@getalby.com 2025-06-18
247 ed-kung issue #2217 #2039 medium 25k simplestacker@getalby.com 2025-06-18
248 axelvyrn pr #2220 #2198 good-first-issue 5 10k holonite@speed.app 2025-06-18
249 axelvyrn issue #2220 #2198 good-first-issue 1k holonite@speed.app 2025-06-18
250 brymut pr #2221 #2204 good-first-issue 20k brymut@stacker.news 2025-06-18
251 brymut pr #2235 #2233 good-first-issue 20k brymut@stacker.news 2025-06-18
252 brymut pr #2250 #2106 good-first-issue 20k brymut@stacker.news 2025-07-12
253 SouthKoreaLN issue #2267 #2164 easy 10k south_korea_ln@stacker.news 2025-07-12
254 pory-gone pr #2316 #2277 good-first-issue 20k pory@porygone.xyz 2025-08-01
255 brymut pr #2326 good-first-issue 20k brymut@stacker.news 2025-07-31
256 brymut pr #2332 #2276 easy 100k brymut@stacker.news 2025-07-31
257 ed-kung pr #2373 #2371 good-first-issue 20k simplestacker@getalby.com 2025-07-31
258 ed-kung issue #2373 #2371 good-first-issue 2k simplestacker@getalby.com 2025-07-31
259 pory-gone pr #2381 #2370 good-first-issue 20k pory@porygone.xyz ???
260 pory-gone pr #2413 #2361 easy 100k pory@porygone.xyz ???

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2",
"express": "^4.20.0",
"puppeteer": "^20.8.2"
},
"type": "module"

View File

@ -46,7 +46,7 @@ export default function AccordianItem ({ header, body, className, headerColor =
<Accordion defaultActiveKey={activeKey} activeKey={activeKey} onSelect={handleOnSelect}>
<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>
<div key={activeKey}>{body}</div>
</Accordion.Collapse>
</Accordion>
)

View File

@ -1,165 +1,44 @@
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_ID } from '@/lib/constants'
import { USER } from '@/fragments/users'
import { useQuery } from '@apollo/client'
import { UserListRow } from '@/components/user-list'
import useCookie from '@/components/use-cookie'
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 const nextAccount = 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
return status === 302
}
export default function SwitchAccountList () {
const { accounts, multiAuthErrors } = useAccounts()
const router = useRouter()
const accounts = useAccounts()
const [pointerCookie] = useCookie(MULTI_AUTH_POINTER)
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} />
<AccountListRow
account={{ id: USER_ID.anon, name: 'anon' }}
selected={pointerCookie === MULTI_AUTH_ANON}
showHat={false}
/>
{
accounts.map((account) => <AccountListRow key={account.id} account={account} showHat={false} />)
accounts.map((account) =>
<AccountListRow
key={account.id}
account={account}
selected={Number(pointerCookie) === account.id}
showHat={false}
/>)
}
</div>
<Link
@ -175,3 +54,45 @@ export default function SwitchAccountList () {
</>
)
}
const AccountListRow = ({ account, selected, ...props }) => {
const router = useRouter()
const [, setPointerCookie] = useCookie(MULTI_AUTH_POINTER)
// 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 })
const anon = account.id === USER_ID.anon
setPointerCookie(anon ? MULTI_AUTH_ANON : account.id, options)
// 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'
selected={selected}
{...props}
onNymClick={onClick}
/>
</div>
)
}
export const useAccounts = () => {
const [listCookie] = useCookie(MULTI_AUTH_LIST)
return listCookie ? JSON.parse(b64Decode(listCookie)) : []
}

View File

@ -2,7 +2,7 @@ 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, SSR } from '@/lib/constants'
import { BOOST_MIN, BOOST_MAX, BOOST_MULT, MAX_FORWARDS, SSR } from '@/lib/constants'
import { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
import Info from './info'
import { abbrNum, numWithUnits } from '@/lib/format'
@ -37,6 +37,7 @@ export function BoostHelp () {
<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>The maximum boost is {numWithUnits(BOOST_MAX, { 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>
@ -197,7 +198,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
for (let i = 0; i < MAX_FORWARDS; i++) {
['nym', 'pct'].forEach(key => {
const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`)
if (value) {
if (value !== undefined && value !== null) {
formik?.setFieldValue(`forward[${i}].${key}`, value)
}
})
@ -268,7 +269,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
emptyItem={EMPTY_FORWARD}
hint={<span className='text-muted'>Forward sats to up to 5 other stackers. Any remaining sats go to you.</span>}
>
{({ index, placeholder }) => {
{({ index, AppendColumn }) => {
return (
<div key={index} className='d-flex flex-row'>
<InputUserSuggest
@ -285,6 +286,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
max={100}
append={<InputGroup.Text className='text-monospace'>%</InputGroup.Text>}
groupClassName={`${styles.percent} mb-0`}
AppendColumn={AppendColumn}
/>
</div>
)

View File

@ -0,0 +1,275 @@
import { useCallback, createContext, useContext, useState, useEffect } from 'react'
import Particles from 'react-particles'
import { loadFireworksPreset } from 'tsparticles-preset-fireworks'
import styles from './fireworks.module.css'
import {
rgbToHsl,
setRangeValue,
stringToRgb
} from 'tsparticles-engine'
import useDarkMode from '@/components/dark-mode'
export const FireworksContext = createContext({
strike: () => {}
})
export const FireworksConsumer = FireworksContext.Consumer
export function useFireworks () {
const { strike } = useContext(FireworksContext)
return strike
}
export function FireworksProvider ({ children }) {
const [cont, setCont] = useState()
const [context, setContext] = useState({ strike: () => {} })
const [darkMode] = useDarkMode()
useEffect(() => {
setContext({
strike: () => {
const should = window.localStorage.getItem('lnAnimate') || 'yes'
if (should !== 'yes') return false
cont?.addEmitter(
{
direction: 'top',
life: {
count: 1,
duration: 0.1,
delay: 0.1
},
rate: {
delay: 0,
quantity: 1
},
size: {
width: 10,
height: 0
},
position: {
y: 100,
x: 50
}
})
return true
}
})
}, [cont])
const particlesLoaded = useCallback((container) => {
setCont(container)
}, [])
const particlesInit = useCallback(async engine => {
// you can initiate the tsParticles instance (engine) here, adding custom shapes or presets
// this loads the tsparticles package bundle, it's the easiest method for getting everything ready
// starting from v2 you can add only the features you need reducing the bundle size
await loadFireworksPreset(engine)
}, [])
return (
<FireworksContext.Provider value={context}>
<Particles
className={styles.fireworks}
init={particlesInit}
loaded={particlesLoaded}
options={darkMode ? darkOptions : lightOptions}
/>
{children}
</FireworksContext.Provider>
)
}
const fixRange = (value, min, max) => {
const diffSMax = value.max > max ? value.max - max : 0
let res = setRangeValue(value)
if (diffSMax) {
res = setRangeValue(value.min - diffSMax, max)
}
const diffSMin = value.min < min ? value.min : 0
if (diffSMin) {
res = setRangeValue(0, value.max + diffSMin)
}
return res
}
const fireworksOptions = ['#ff595e', '#ffca3a', '#8ac926', '#1982c4', '#6a4c93']
.map((color) => {
const rgb = stringToRgb(color)
if (!rgb) {
return undefined
}
const hsl = rgbToHsl(rgb)
const sRange = fixRange({ min: hsl.s - 30, max: hsl.s + 30 }, 0, 100)
const lRange = fixRange({ min: hsl.l - 30, max: hsl.l + 30 }, 0, 100)
return {
color: {
value: {
h: hsl.h,
s: sRange,
l: lRange
}
},
stroke: {
width: 0
},
number: {
value: 0
},
opacity: {
value: {
min: 0.1,
max: 1
},
animation: {
enable: true,
speed: 0.7,
sync: false,
startValue: 'max',
destroy: 'min'
}
},
shape: {
type: 'circle'
},
size: {
value: { min: 1, max: 2 },
animation: {
enable: true,
speed: 5,
count: 1,
sync: false,
startValue: 'min',
destroy: 'none'
}
},
life: {
count: 1,
duration: {
value: {
min: 1,
max: 2
}
}
},
move: {
decay: { min: 0.075, max: 0.1 },
enable: true,
gravity: {
enable: true,
inverse: false,
acceleration: 5
},
speed: { min: 5, max: 15 },
direction: 'none',
outMode: {
top: 'destroy',
default: 'bounce'
}
}
}
})
.filter((t) => t !== undefined)
const particlesOptions = (theme) => ({
number: {
value: 0
},
destroy: {
mode: 'split',
bounds: {
top: { min: 5, max: 40 }
},
split: {
sizeOffset: false,
count: 1,
factor: {
value: 0.333333
},
rate: {
value: { min: 75, max: 150 }
},
particles: fireworksOptions
}
},
life: {
count: 1
},
shape: {
type: 'line'
},
size: {
value: {
min: 0.1,
max: 50
},
animation: {
enable: true,
sync: true,
speed: 90,
startValue: 'max',
destroy: 'min'
}
},
rotate: {
path: true
},
stroke: {
color: {
value: theme === 'dark' ? '#fff' : '#aaa'
},
width: 1
},
move: {
enable: true,
gravity: {
acceleration: 15,
enable: true,
inverse: true,
maxSpeed: 100
},
speed: {
min: 10,
max: 20
},
outModes: {
default: 'split',
top: 'none'
},
trail: {
fillColor: theme === 'dark' ? '#000' : '#f5f5f7',
enable: true,
length: 10
}
}
})
const darkOptions = {
fullScreen: { enable: true, zIndex: -1 },
detectRetina: true,
background: {
color: '#000',
opacity: 0
},
fpsLimit: 120,
emitters: [],
particles: particlesOptions('dark')
}
const lightOptions = {
fullScreen: { enable: true, zIndex: -1 },
detectRetina: true,
background: {
color: '#fff',
opacity: 0
},
fpsLimit: 120,
emitters: [],
particles: particlesOptions('light')
}

View File

@ -0,0 +1,8 @@
.fireworks {
z-index: 0;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}

View File

@ -0,0 +1,72 @@
import { useCallback, useEffect, useState } from 'react'
import { useMe } from '@/components/me'
import { randInRange } from '@/lib/rand'
import { LightningProvider, useLightning } from './lightning'
// import { FireworksProvider, useFireworks } from './fireworks'
// import { SnowProvider, useSnow } from './snow'
const [SelectedAnimationProvider, useSelectedAnimation] = [
LightningProvider, useLightning
// FireworksProvider, useFireworks
// SnowProvider, useSnow // TODO: the snow animation doesn't seem to work anymore
]
export function AnimationProvider ({ children }) {
return (
<SelectedAnimationProvider>
<AnimationHooks>
{children}
</AnimationHooks>
</SelectedAnimationProvider>
)
}
export function useAnimation () {
const animate = useSelectedAnimation()
return useCallback(() => {
const should = window.localStorage.getItem('lnAnimate') || 'yes'
if (should !== 'yes') return false
animate()
return true
}, [animate])
}
export function useAnimationEnabled () {
const [enabled, setEnabled] = useState(undefined)
useEffect(() => {
const enabled = window.localStorage.getItem('lnAnimate') || 'yes'
setEnabled(enabled === 'yes')
}, [])
const toggleEnabled = useCallback(() => {
setEnabled(enabled => {
const newEnabled = !enabled
window.localStorage.setItem('lnAnimate', newEnabled ? 'yes' : 'no')
return newEnabled
})
}, [])
return [enabled, toggleEnabled]
}
function AnimationHooks ({ children }) {
const { me } = useMe()
const animate = useAnimation()
useEffect(() => {
if (me || window.localStorage.getItem('striked') || window.localStorage.getItem('lnAnimated')) return
const timeout = setTimeout(() => {
const animated = animate()
if (animated) {
window.localStorage.setItem('lnAnimated', 'yep')
}
}, randInRange(3000, 10000))
return () => clearTimeout(timeout)
}, [me?.id, animate])
return children
}

View File

@ -13,16 +13,11 @@ export class LightningProvider extends React.Component {
* @returns boolean indicating whether the strike actually happened, based on user preferences
*/
strike = () => {
const should = window.localStorage.getItem('lnAnimate') || 'yes'
if (should === 'yes') {
this.setState(state => {
return {
bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />]
}
})
return true
}
return false
this.setState(state => {
return {
bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />]
}
})
}
unstrike = (index) => {

View File

@ -11,22 +11,17 @@ export const SnowProvider = ({ children }) => {
const [flakes, setFlakes] = useState(Array(1024))
const snow = useCallback(() => {
const should = window.localStorage.getItem('lnAnimate') || 'yes'
if (should === 'yes') {
// amount of flakes to add
const n = Math.floor(randInRange(5, 30))
const newFlakes = [...flakes]
let i
for (i = startIndex; i < (startIndex + n); ++i) {
const key = startIndex + i
newFlakes[i % MAX_FLAKES] = <Snow key={key} />
}
setStartIndex(i % MAX_FLAKES)
setFlakes(newFlakes)
return true
// amount of flakes to add
const n = Math.floor(randInRange(5, 30))
const newFlakes = [...flakes]
let i
for (i = startIndex; i < (startIndex + n); ++i) {
const key = startIndex + i
newFlakes[i % MAX_FLAKES] = <Snow key={key} />
}
return false
}, [setFlakes, startIndex])
setStartIndex(i % MAX_FLAKES)
setFlakes(newFlakes)
}, [flakes, startIndex])
return (
<SnowContext.Provider value={snow}>

View File

@ -1,76 +0,0 @@
import { InputGroup } from 'react-bootstrap'
import { Input } from './form'
import { useMe } from './me'
import { useEffect, useState } from 'react'
import { isNumber } from '@/lib/format'
import Link from 'next/link'
function autoWithdrawThreshold ({ me }) {
return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000
}
export function autowithdrawInitial ({ me }) {
return {
autoWithdrawThreshold: autoWithdrawThreshold({ me }),
autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1,
autoWithdrawMaxFeeTotal: isNumber(me?.privates?.autoWithdrawMaxFeeTotal) ? me?.privates?.autoWithdrawMaxFeeTotal : 1
}
}
export function AutowithdrawSettings () {
const { me } = useMe()
const threshold = autoWithdrawThreshold({ me })
const [sendThreshold, setSendThreshold] = useState(Math.max(Math.floor(threshold / 10), 1))
useEffect(() => {
setSendThreshold(Math.max(Math.floor(threshold / 10), 1))
}, [autoWithdrawThreshold])
return (
<>
<div className='my-4 border border-3 rounded'>
<div className='p-3'>
<h3 className='text-center text-muted'>desired balance</h3>
<h6 className='text-center pb-3'>applies globally to all autowithdraw methods</h6>
<Input
label='desired balance'
name='autoWithdrawThreshold'
onChange={(formik, e) => {
const value = e.target.value
setSendThreshold(Math.max(Math.floor(value / 10), 1))
}}
hint={isNumber(sendThreshold) ? `will attempt auto-withdraw when your balance exceeds ${sendThreshold * 11} sats` : undefined}
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 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

@ -6,12 +6,18 @@ import EditImage from '@/svgs/image-edit-fill.svg'
import Moon from '@/svgs/moon-fill.svg'
import { useShowModal } from './modal'
import { FileUpload } from './file-upload'
import { gql, useMutation } from '@apollo/client'
export default function Avatar ({ onSuccess }) {
const [cropPhoto] = useMutation(gql`
mutation cropPhoto($photoId: ID!, $cropData: CropData) {
cropPhoto(photoId: $photoId, cropData: $cropData)
}
`)
const [uploading, setUploading] = useState()
const showModal = useShowModal()
const Body = ({ onClose, file, upload }) => {
const Body = ({ onClose, file, onSave }) => {
const [scale, setScale] = useState(1)
const ref = useRef()
@ -34,13 +40,21 @@ export default function Avatar ({ onSuccess }) {
/>
</BootstrapForm.Group>
<Button
onClick={() => {
ref.current.getImageScaledToCanvas().toBlob(blob => {
if (blob) {
upload(blob)
onClose()
onClick={async () => {
const rect = ref.current.getCroppingRect()
const img = new window.Image()
img.onload = async () => {
const cropData = {
...rect,
originalWidth: img.width,
originalHeight: img.height,
scale
}
}, 'image/jpeg')
// upload original to S3 along with crop data
await onSave(cropData)
}
img.src = URL.createObjectURL(file)
onClose()
}}
>save
</Button>
@ -48,6 +62,45 @@ export default function Avatar ({ onSuccess }) {
)
}
const startCrop = async (file, upload) => {
return new Promise((resolve, reject) =>
showModal(onClose => (
<Body
onClose={() => {
onClose()
resolve()
}}
file={file}
onSave={async (cropData) => {
setUploading(true)
try {
// upload original to S3
const photoId = await upload(file)
// crop it
const { data } = await cropPhoto({ variables: { photoId, cropData } })
const res = await fetch(data.cropPhoto)
const blob = await res.blob()
// create a file from the blob
const croppedImage = new File([blob], 'avatar.jpg', { type: 'image/jpeg' })
// upload the imgproxy cropped image
const croppedPhotoId = await upload(croppedImage)
onSuccess?.(croppedPhotoId)
setUploading(false)
} catch (e) {
console.error(e)
setUploading(false)
reject(e)
}
}}
/>
))
)
}
return (
<FileUpload
allow='image/*'
@ -56,26 +109,7 @@ export default function Avatar ({ onSuccess }) {
console.log(e)
setUploading(false)
}}
onSelect={(file, upload) => {
return new Promise((resolve, reject) =>
showModal(onClose => (
<Body
onClose={() => {
onClose()
resolve()
}}
file={file}
upload={async (blob) => {
await upload(blob)
resolve(blob)
}}
/>
)))
}}
onSuccess={({ id }) => {
onSuccess?.(id)
setUploading(false)
}}
onSelect={startCrop}
onUpload={() => {
setUploading(true)
}}

View File

@ -1,29 +1,14 @@
import { Fragment } from 'react'
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 { numWithUnits } from '@/lib/format'
import { USER_ID } from '@/lib/constants'
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) {
@ -34,14 +19,43 @@ export default function Badges ({ user, badge, className = 'ms-1', badgeClassNam
)
}
const badges = []
const streak = user.optional.streak
if (streak !== null) {
badges.push({
icon: CowboyHatIcon,
overlayText: streak
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
: 'new'
})
}
if (user.optional.hasRecvWallet) {
badges.push({
icon: HorseIcon,
overlayText: 'can receive sats'
})
}
if (user.optional.hasSendWallet) {
badges.push({
icon: GunIcon,
sizeDelta: 2,
overlayText: 'can send sats'
})
}
if (badges.length === 0) return null
return (
<span className={className}>
{BADGES.map(({ icon, streakName, sizeDelta }, i) => (
{badges.map(({ icon, overlayText, sizeDelta }, i) => (
<SNBadge
key={streakName}
key={i}
user={user}
badge={badge}
streakName={streakName}
overlayText={overlayText}
badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)}
IconForBadge={icon}
height={height}
@ -53,20 +67,19 @@ export default function Badges ({ user, badge, className = 'ms-1', badgeClassNam
)
}
function SNBadge ({ user, badge, streakName, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
const streak = user.optional[streakName]
if (streak === null) {
return null
function SNBadge ({ user, badge, overlayText, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
let Wrapper = Fragment
if (overlayText) {
Wrapper = ({ children }) => (
<BadgeTooltip overlayText={overlayText}>{children}</BadgeTooltip>
)
}
return (
<BadgeTooltip
overlayText={streak
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
: 'new'}
>
<Wrapper>
<span><IconForBadge className={badgeClassName} height={height + sizeDelta} width={width + sizeDelta} /></span>
</BadgeTooltip>
</Wrapper>
)
}

View File

@ -5,8 +5,6 @@ import { useMe } from '@/components/me'
import { useMutation } from '@apollo/client'
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
import { useToast } from '@/components/toast'
import Link from 'next/link'
import AccordianItem from '@/components/accordian-item'
export function WelcomeBanner ({ Banner }) {
const { me } = useMe()
@ -101,22 +99,6 @@ export function MadnessBanner ({ handleClose }) {
)
}
export function WalletSecurityBanner ({ isActive }) {
return (
<Alert className={styles.banner} key='info' variant='warning'>
<Alert.Heading>
Gunslingin' Safety Tips
</Alert.Heading>
<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 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>
)
}
export function AuthBanner () {
return (
<Alert className={`${styles.banner} mt-0`} key='info' variant='danger'>
@ -124,24 +106,3 @@ 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

@ -53,12 +53,19 @@ function useArrowKeys ({ moveLeft, moveRight }) {
}, [onKeyDown])
}
export default function Carousel ({ close, mediaArr, src, originalSrc, setOptions }) {
function Carousel ({ close, mediaArr, src, 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])
useEffect(() => {
if (index === -1) return
setOptions({
overflow: <CarouselOverflow {...mediaArr[index][1]} />
})
}, [index, mediaArr, setOptions])
const moveLeft = useCallback(() => {
setIndex(i => Math.max(0, i - 1))
}, [setIndex])
@ -114,15 +121,15 @@ export function CarouselProvider ({ children }) {
fullScreen: true,
overflow: <CarouselOverflow {...media.current.get(src)} />
})
}, [showModal, media.current])
}, [showModal])
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>

View File

@ -96,7 +96,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
}
export default function Comment ({
item, children, replyOpen, includeParent, topLevel,
item, children, replyOpen, includeParent, topLevel, rootLastCommentAt,
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry
}) {
const [edit, setEdit] = useState()
@ -114,6 +114,17 @@ export default function Comment ({
const { cache } = useApolloClient()
const unsetOutline = () => {
if (!ref.current) return
const hasOutline = ref.current.classList.contains('outline-new-comment') || ref.current.classList.contains('outline-new-injected-comment')
const hasOutlineUnset = ref.current.classList.contains('outline-new-comment-unset')
// don't try to unset the outline if the comment is not outlined or we already unset the outline
if (hasOutline && !hasOutlineUnset) {
ref.current.classList.add('outline-new-comment-unset')
}
}
useEffect(() => {
const comment = cache.readFragment({
id: `Item:${router.query.commentId}`,
@ -140,12 +151,29 @@ export default function Comment ({
}, [item.id, cache, router.query.commentId])
useEffect(() => {
if (router.query.commentsViewedAt &&
me?.id !== item.user?.id &&
new Date(item.createdAt).getTime() > router.query.commentsViewedAt) {
if (me?.id === item.user?.id) return
const itemCreatedAt = new Date(item.createdAt).getTime()
// it's a new comment if it was created after the last comment was viewed
// or, in the case of live comments, after the last comment was created
const isNewComment = (router.query.commentsViewedAt && itemCreatedAt > router.query.commentsViewedAt) ||
(rootLastCommentAt && itemCreatedAt > new Date(rootLastCommentAt).getTime())
if (!isNewComment) return
if (item.injected) {
// newly injected comments (item.injected) have to use a different class to outline every new comment
ref.current.classList.add('outline-new-injected-comment')
// wait for the injection animation to end before removing its class
ref.current.addEventListener('animationend', () => {
ref.current.classList.remove(styles.injectedComment)
}, { once: true })
// animate the live comment injection
ref.current.classList.add(styles.injectedComment)
} else {
ref.current.classList.add('outline-new-comment')
}
}, [item.id])
}, [item.id, rootLastCommentAt])
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
@ -159,17 +187,19 @@ export default function Comment ({
return (
<div
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
onMouseEnter={unsetOutline}
onTouchStart={unsetOutline}
>
<div className={`${itemStyles.item} ${styles.item}`}>
{item.outlawed && !me?.privates?.wildWestMode
? <Skull className={styles.dontLike} width={24} height={24} />
: 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'} />}
: pin
? <Pin width={22} height={22} className={styles.pin} />
: item.mine
? <Boost item={item} className={styles.upvote} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: <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'
@ -182,6 +212,7 @@ export default function Comment ({
>reply from someone you muted
</span>)
: <ItemInfo
full={topLevel}
item={item}
commentsText='replies'
commentTextSingular='reply'
@ -249,7 +280,7 @@ export default function Comment ({
</div>
{collapse !== 'yep' && (
bottomedOut
? <div className={styles.children}><div className={classNames(styles.comment, 'mt-3')}><ReplyOnAnotherPage item={item} /></div></div>
? <div className={styles.children}><div className={classNames(styles.comment, 'mt-3 pb-2')}><ViewMoreReplies item={item} threadContext /></div></div>
: (
<div className={styles.children}>
{item.outlawed && !me?.privates?.wildWestMode
@ -264,9 +295,13 @@ export default function Comment ({
? (
<>
{item.comments.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
<Comment depth={depth + 1} key={item.id} item={item} rootLastCommentAt={rootLastCommentAt} />
))}
{item.comments.comments.length < item.nDirectComments && <ViewAllReplies id={item.id} nhas={item.ncomments} />}
{item.comments.comments.length < item.nDirectComments && (
<div className={`d-block ${styles.comment} pb-2 ps-3`}>
<ViewMoreReplies item={item} />
</div>
)}
</>
)
: null}
@ -279,29 +314,24 @@ 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 }) {
export function ViewMoreReplies ({ item, threadContext = false }) {
const root = useRoot()
const rootId = commentSubTreeRootId(item, root)
const id = threadContext ? commentSubTreeRootId(item, root) : item.id
let text = 'reply on another page'
if (item.ncomments > 0) {
text = `view all ${item.ncomments} replies`
}
// if threadContext is true, we travel to some comments before the current comment, focusing on the comment itself
// otherwise, we directly navigate to the comment
const href = `/items/${id}` + (threadContext ? `?commentId=${item.id}` : '')
const text = threadContext && item.ncomments === 0
? 'reply on another page'
: `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'>
<Link
href={href}
as={`/items/${id}`}
className='fw-bold d-flex align-items-center gap-2 text-muted'
>
{text}
</Link>
)

View File

@ -135,4 +135,36 @@
.comment:has(.comment) + .comment{
padding-top: .5rem;
}
}
.newCommentDot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--bs-primary);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
background-color: #80d3ff;
opacity: 0.7;
}
50% {
background-color: #007cbe;
opacity: 1;
}
100% {
background-color: #80d3ff;
opacity: 0.7;
}
}
.injectedComment {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}

View File

@ -8,6 +8,7 @@ import { defaultCommentSort } from '@/lib/item'
import { useRouter } from 'next/router'
import MoreFooter from './more-footer'
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
import useLiveComments from './use-live-comments'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter()
@ -64,10 +65,13 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
export default function Comments ({
parentId, pinned, bio, parentCreatedAt,
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, ...props
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, lastCommentAt, item, ...props
}) {
const router = useRouter()
// fetch new comments that arrived after the lastCommentAt, and update the item.comments field in cache
useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort)
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
return (
@ -90,11 +94,11 @@ export default function Comments ({
: null}
{pins.map(item => (
<Fragment key={item.id}>
<Comment depth={1} item={item} {...props} pin />
<Comment depth={1} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} pin />
</Fragment>
))}
{comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} {...props} />
<Comment depth={1} key={item.id} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} />
))}
{ncomments > FULL_COMMENTS_THRESHOLD &&
<MoreFooter

View File

@ -125,19 +125,21 @@ const Embed = memo(function Embed ({ src, provider, id, meta, className, topLeve
// 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>
<>
<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>
</>
)
}

View File

@ -3,7 +3,6 @@ import { StaticLayout } from './layout'
import styles from '@/styles/error.module.css'
import Image from 'react-bootstrap/Image'
import copy from 'clipboard-copy'
import { LoggerContext } from './logger'
import Button from 'react-bootstrap/Button'
import { useToast } from './toast'
import { decodeMinifiedStackTrace } from '@/lib/stacktrace'
@ -36,8 +35,6 @@ class ErrorBoundary extends Component {
// You can use your own error logging service here
console.log({ error, errorInfo })
this.setState({ errorInfo })
const logger = this.context
logger?.error(this.getErrorDetails())
}
render () {
@ -47,7 +44,7 @@ class ErrorBoundary extends Component {
const errorDetails = this.getErrorDetails()
return (
<StaticLayout footer={false}>
<Image width='500' height='375' className='rounded-1 shadow-sm' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.gif`} fluid />
<Image width='500' height='375' className='rounded-1 shadow-sm' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.webp`} fluid />
<h1 className={styles.status} style={{ fontSize: '48px' }}>something went wrong</h1>
{this.state.error && <CopyErrorButton errorDetails={errorDetails} />}
</StaticLayout>
@ -59,8 +56,6 @@ class ErrorBoundary extends Component {
}
}
ErrorBoundary.contextType = LoggerContext
export default ErrorBoundary
// This button is a functional component so we can use `useToast` hook, which

View File

@ -12,10 +12,10 @@ import No from '@/svgs/no.svg'
import Bolt from '@/svgs/bolt.svg'
import Amboss from '@/svgs/amboss.svg'
import Mempool from '@/svgs/bimi.svg'
import { useEffect, useState } from 'react'
import Rewards from './footer-rewards'
import useDarkMode from './dark-mode'
import ActionTooltip from './action-tooltip'
import { useAnimationEnabled } from '@/components/animation'
const RssPopover = (
<Popover>
@ -53,33 +53,43 @@ const RssPopover = (
const SocialsPopover = (
<Popover>
<Popover.Body style={{ fontWeight: 500, fontSize: '.9rem' }}>
<a
href='https://njump.me/npub1jfujw6llhq7wuvu5detycdsq5v5yqf56sgrdq8wlgrryx2a2p09svwm0gx' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
nostr
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://twitter.com/stacker_news' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
twitter
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://www.youtube.com/@stackernews' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
youtube
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://www.fountain.fm/show/Mg1AWuvkeZSFhsJZ3BW2' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
pod
</a>
<div className='d-flex justify-content-center'>
<a
href='https://njump.me/npub1jfujw6llhq7wuvu5detycdsq5v5yqf56sgrdq8wlgrryx2a2p09svwm0gx' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
nostr
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://twitter.com/stacker_news' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
twitter
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://www.youtube.com/@stackernews' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
youtube
</a>
</div>
<div className='d-flex justify-content-center'>
<a
href='https://www.fountain.fm/show/Mg1AWuvkeZSFhsJZ3BW2' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
pod
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://www.plebpoet.com/zines.html' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
zines
</a>
</div>
</Popover.Body>
</Popover>
)
@ -135,24 +145,10 @@ const LegalPopover = (
export default function Footer ({ links = true }) {
const [darkMode, darkModeToggle] = useDarkMode()
const [lightning, setLightning] = useState(undefined)
useEffect(() => {
setLightning(window.localStorage.getItem('lnAnimate') || 'yes')
}, [])
const toggleLightning = () => {
if (lightning === 'yes') {
window.localStorage.setItem('lnAnimate', 'no')
setLightning('no')
} else {
window.localStorage.setItem('lnAnimate', 'yes')
setLightning('yes')
}
}
const [animationEnabled, toggleAnimation] = useAnimationEnabled()
const DarkModeIcon = darkMode ? Sun : Moon
const LnIcon = lightning === 'yes' ? No : Bolt
const LnIcon = animationEnabled ? No : Bolt
const version = process.env.NEXT_PUBLIC_COMMIT_HASH
@ -165,8 +161,8 @@ export default function Footer ({ links = true }) {
<ActionTooltip notForm overlayText={`${darkMode ? 'disable' : 'enable'} dark mode`}>
<DarkModeIcon onClick={darkModeToggle} width={20} height={20} className='fill-grey theme' suppressHydrationWarning />
</ActionTooltip>
<ActionTooltip notForm overlayText={`${lightning === 'yes' ? 'disable' : 'enable'} lightning animations`}>
<LnIcon onClick={toggleLightning} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
<ActionTooltip notForm overlayText={`${animationEnabled ? 'disable' : 'enable'} lightning animations`}>
<LnIcon onClick={toggleAnimation} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
</ActionTooltip>
</div>
<div className='mb-0' style={{ fontWeight: 500 }}>

View File

@ -16,6 +16,7 @@ import AddIcon from '@/svgs/add-fill.svg'
import CloseIcon from '@/svgs/close-line.svg'
import { gql, useLazyQuery } from '@apollo/client'
import { USER_SUGGESTIONS } from '@/fragments/users'
import { SUB_SUGGESTIONS } from '@/fragments/subs'
import TextareaAutosize from 'react-textarea-autosize'
import { useToast } from './toast'
import { numWithUnits } from '@/lib/format'
@ -33,12 +34,9 @@ import Info from './info'
import { useMe } from './me'
import classNames from 'classnames'
import Clipboard from '@/svgs/clipboard-line.svg'
import QrIcon from '@/svgs/qr-code-line.svg'
import QrScanIcon from '@/svgs/qr-scan-line.svg'
import { useShowModal } from './modal'
import { QRCodeSVG } from 'qrcode.react'
import dynamic from 'next/dynamic'
import { qrImageSettings } from './qr'
import { useIsClient } from './use-client'
import PageLoading from './page-loading'
@ -77,7 +75,7 @@ export function SubmitButton ({
)
}
function CopyButton ({ value, icon, ...props }) {
export function CopyButton ({ value, icon, ...props }) {
const toaster = useToast()
const [copied, setCopied] = useState(false)
@ -139,6 +137,174 @@ function setNativeValue (textarea, value) {
textarea.dispatchEvent(new Event('input', { bubbles: true, value }))
}
function useEntityAutocomplete ({
prefix,
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent
}) {
const [entityData, setEntityData] = useState()
const handleSelect = useCallback((name) => {
if (entityData?.start === undefined || entityData?.end === undefined) return
const { start, end } = entityData
setEntityData(undefined)
const first = `${meta?.value.substring(0, start)}${prefix}${name}`
const second = meta?.value.substring(end)
const updatedValue = `${first}${second}`
helpers.setValue(updatedValue)
setSelectionRange({ start: first.length, end: first.length })
innerRef.current.focus()
}, [entityData, meta?.value, helpers, prefix, setSelectionRange, innerRef])
const handleTextChange = useCallback((e) => {
const { value, selectionStart } = e.target
if (!value || selectionStart === undefined) {
setEntityData(undefined)
return false
}
let priorSpace = -1
for (let i = selectionStart - 1; i >= 0; i--) {
if (/[^\w@~]/.test(value[i])) {
priorSpace = i
break
}
}
let nextSpace = value.length
for (let i = selectionStart; i <= value.length; i++) {
if (/[^\w]/.test(value[i])) {
nextSpace = i
break
}
}
const currentSegment = value.substring(priorSpace + 1, nextSpace)
const regexPattern = new RegExp(`^\\${prefix}\\w*$`)
if (regexPattern.test(currentSegment)) {
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
setEntityData({
query: currentSegment,
start: priorSpace + 1,
end: nextSpace,
style: {
position: 'absolute',
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
left: `${left}px`
}
})
return true
}
setEntityData(undefined)
return false
}, [prefix])
// Return a function that takes a render prop instead of directly returning the component
return {
entityData,
handleSelect,
handleTextChange,
renderSuggest: (renderProps) => {
if (!entityData) return null
return (
<SuggestComponent
query={entityData?.query}
onSelect={handleSelect}
dropdownStyle={entityData?.style}
>
{renderProps}
</SuggestComponent>
)
}
}
}
export function useDualAutocomplete ({ meta, helpers, innerRef, setSelectionRange }) {
const userAutocomplete = useEntityAutocomplete({
prefix: '@',
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: UserSuggest
})
const territoryAutocomplete = useEntityAutocomplete({
prefix: '~',
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: TerritorySuggest
})
const handleTextChange = useCallback((e) => {
// Try to match user mentions first, then territories
if (!userAutocomplete.handleTextChange(e)) {
territoryAutocomplete.handleTextChange(e)
}
}, [userAutocomplete, territoryAutocomplete])
const handleKeyDown = useCallback((e, userOnKeyDown, territoryOnKeyDown) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
if (!metaOrCtrl) {
if (userAutocomplete.entityData) {
return userOnKeyDown(e)
} else if (territoryAutocomplete.entityData) {
return territoryOnKeyDown(e)
}
}
return false // Didn't handle the event
}, [userAutocomplete.entityData, territoryAutocomplete.entityData])
const handleBlur = useCallback((resetUserSuggestions, resetTerritorySuggestions) => {
setTimeout(resetUserSuggestions, 500)
setTimeout(resetTerritorySuggestions, 500)
}, [])
return {
userAutocomplete,
territoryAutocomplete,
handleTextChange,
handleKeyDown,
handleBlur
}
}
export function DualAutocompleteWrapper ({
userAutocomplete,
territoryAutocomplete,
children
}) {
return (
<UserSuggest
query={userAutocomplete.entityData?.query}
onSelect={userAutocomplete.handleSelect}
dropdownStyle={userAutocomplete.entityData?.style}
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions: resetUserSuggestions }) => (
<TerritorySuggest
query={territoryAutocomplete.entityData?.query}
onSelect={territoryAutocomplete.handleSelect}
dropdownStyle={territoryAutocomplete.entityData?.style}
>{({ onKeyDown: territorySuggestOnKeyDown, resetSuggestions: resetTerritorySuggestions }) =>
children({
userSuggestOnKeyDown,
territorySuggestOnKeyDown,
resetUserSuggestions,
resetTerritorySuggestions
})}
</TerritorySuggest>
)}
</UserSuggest>
)
}
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
const [tab, setTab] = useState('write')
const [, meta, helpers] = useField(props)
@ -151,10 +317,8 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
const [updateUploadFees] = useLazyQuery(gql`
query uploadFees($s3Keys: [Int]!) {
uploadFees(s3Keys: $s3Keys) {
totalFees
nUnpaid
uploadFees
bytes24h
}
}`, {
fetchPolicy: 'no-cache',
@ -163,13 +327,15 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
console.error(err)
},
onCompleted: ({ uploadFees }) => {
const { uploadFees: feePerUpload, nUnpaid } = uploadFees
const totalFees = feePerUpload * nUnpaid
merge({
uploadFees: {
term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`,
term: `+ ${numWithUnits(feePerUpload, { abbreviate: false })} x ${nUnpaid}`,
label: 'upload fee',
op: '+',
modifier: cost => cost + uploadFees.totalFees,
omit: !uploadFees.totalFees
modifier: cost => cost + totalFees,
omit: !totalFees
}
})
}
@ -198,18 +364,12 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
}
}, [innerRef, selectionRange.start, selectionRange.end])
const [mention, setMention] = useState()
const insertMention = useCallback((name) => {
if (mention?.start === undefined || mention?.end === undefined) return
const { start, end } = mention
setMention(undefined)
const first = `${meta?.value.substring(0, start)}@${name}`
const second = meta?.value.substring(end)
const updatedValue = `${first}${second}`
helpers.setValue(updatedValue)
setSelectionRange({ start: first.length, end: first.length })
innerRef.current.focus()
}, [mention, meta?.value, helpers?.setValue])
const { userAutocomplete, territoryAutocomplete, handleTextChange, handleKeyDown, handleBlur } = useDualAutocomplete({
meta,
helpers,
innerRef,
setSelectionRange
})
const uploadFeesUpdate = useDebounceCallback(
(text) => {
@ -219,86 +379,9 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
const onChangeInner = useCallback((formik, e) => {
if (onChange) onChange(formik, e)
// check for mention editing
const { value, selectionStart } = e.target
uploadFeesUpdate(value)
if (!value || selectionStart === undefined) {
setMention(undefined)
return
}
let priorSpace = -1
for (let i = selectionStart - 1; i >= 0; i--) {
if (/[^\w@]/.test(value[i])) {
priorSpace = i
break
}
}
let nextSpace = value.length
for (let i = selectionStart; i <= value.length; i++) {
if (/[^\w]/.test(value[i])) {
nextSpace = i
break
}
}
const currentSegment = value.substring(priorSpace + 1, nextSpace)
// set the query to the current character segment and note where it appears
if (/^@\w*$/.test(currentSegment)) {
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
setMention({
query: currentSegment,
start: priorSpace + 1,
end: nextSpace,
style: {
position: 'absolute',
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
left: `${left}px`
}
})
} else {
setMention(undefined)
}
}, [onChange, setMention, uploadFeesUpdate])
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
return (e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
if (metaOrCtrl) {
if (e.key === 'k') {
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
e.preventDefault()
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'b') {
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
e.preventDefault()
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'i') {
// some browsers might use CTRL+I to do something else so prevent that behavior too
e.preventDefault()
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'u') {
// some browsers might use CTRL+U to do something else so prevent that behavior too
e.preventDefault()
imageUploadRef.current?.click()
}
if (e.key === 'Tab' && e.altKey) {
e.preventDefault()
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
}
if (!metaOrCtrl) {
userSuggestOnKeyDown(e)
}
if (onKeyDown) onKeyDown(e)
}
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown])
uploadFeesUpdate(e.target.value)
handleTextChange(e)
}, [onChange, uploadFeesUpdate, handleTextChange])
const onPaste = useCallback((event) => {
const items = event.clipboardData.items
@ -342,6 +425,44 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
setDragStyle(null)
}, [setDragStyle])
const onKeyDownInner = useCallback((userSuggestOnKeyDown, territorySuggestOnKeyDown) => {
return (e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
// Handle markdown shortcuts first
if (metaOrCtrl) {
if (e.key === 'k') {
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
e.preventDefault()
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'b') {
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
e.preventDefault()
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'i') {
// some browsers might use CTRL+I to do something else so prevent that behavior too
e.preventDefault()
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'u') {
// some browsers might use CTRL+U to do something else so prevent that behavior too
e.preventDefault()
imageUploadRef.current?.click()
}
if (e.key === 'Tab' && e.altKey) {
e.preventDefault()
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
} else {
handleKeyDown(e, userSuggestOnKeyDown, territorySuggestOnKeyDown)
}
if (onKeyDown) onKeyDown(e)
}
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown, handleKeyDown, imageUploadRef])
return (
<FormGroup label={label} className={groupClassName}>
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
@ -408,24 +529,25 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
</span>
</Nav>
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
<UserSuggest
query={mention?.query}
onSelect={insertMention}
dropdownStyle={mention?.style}
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions }) => (
<InputInner
innerRef={innerRef}
{...props}
onChange={onChangeInner}
onKeyDown={onKeyDownInner(userSuggestOnKeyDown)}
onBlur={() => setTimeout(resetSuggestions, 500)}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onPaste={onPaste}
className={dragStyle === 'over' ? styles.dragOver : ''}
/>)}
</UserSuggest>
<DualAutocompleteWrapper
userAutocomplete={userAutocomplete}
territoryAutocomplete={territoryAutocomplete}
>
{({ userSuggestOnKeyDown, territorySuggestOnKeyDown, resetUserSuggestions, resetTerritorySuggestions }) => (
<InputInner
innerRef={innerRef}
{...props}
onChange={onChangeInner}
onKeyDown={onKeyDownInner(userSuggestOnKeyDown, territorySuggestOnKeyDown)}
onBlur={() => handleBlur(resetUserSuggestions, resetTerritorySuggestions)}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onPaste={onPaste}
className={dragStyle === 'over' ? styles.dragOver : ''}
/>
)}
</DualAutocompleteWrapper>
</div>
{tab !== 'write' &&
<div className='form-group'>
@ -487,7 +609,7 @@ function FormGroup ({ className, label, children }) {
function InputInner ({
prepend, append, hint, warn, showValid, onChange, onBlur, overrideValue, appendValue,
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, hideError,
...props
AppendColumn, ...props
}) {
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
const formik = noForm ? null : useFormikContext()
@ -565,38 +687,43 @@ function InputInner ({
return (
<>
<InputGroup hasValidation className={inputGroupClassName}>
{prepend}
<BootstrapForm.Control
ref={innerRef}
{...field}
{...props}
onKeyDown={onKeyDownInner}
onChange={onChangeInner}
onBlur={onBlurInner}
isInvalid={!hideError && invalid} // if hideError is true, handle error showing separately
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
/>
{(isClient && clear && field.value && !props.readOnly) &&
<Button
variant={null}
onClick={(e) => {
helpers.setValue('')
if (storageKey) {
window.localStorage.removeItem(storageKey)
}
if (onChange) {
onChange(formik, { target: { value: '' } })
}
}}
className={`${styles.clearButton} ${styles.appendButton} ${invalid ? styles.isInvalid : ''}`}
><CloseIcon className='fill-grey' height={20} width={20} />
</Button>}
{append}
<BootstrapForm.Control.Feedback type='invalid'>
{meta.touched && meta.error}
</BootstrapForm.Control.Feedback>
</InputGroup>
<Row>
<Col>
<InputGroup hasValidation className={inputGroupClassName}>
{prepend}
<BootstrapForm.Control
ref={innerRef}
{...field}
{...props}
onKeyDown={onKeyDownInner}
onChange={onChangeInner}
onBlur={onBlurInner}
isInvalid={!hideError && invalid} // if hideError is true, handle error showing separately
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
/>
{(isClient && clear && field.value && !props.readOnly) &&
<Button
variant={null}
onClick={(e) => {
helpers.setValue('')
if (storageKey) {
window.localStorage.removeItem(storageKey)
}
if (onChange) {
onChange(formik, { target: { value: '' } })
}
}}
className={`${styles.clearButton} ${styles.appendButton} ${invalid ? styles.isInvalid : ''}`}
><CloseIcon className='fill-grey' height={20} width={20} />
</Button>}
{append}
<BootstrapForm.Control.Feedback type='invalid'>
{meta.touched && meta.error}
</BootstrapForm.Control.Feedback>
</InputGroup>
</Col>
{AppendColumn && <AppendColumn className={meta.touched && meta.error ? 'invisible' : ''} />}
</Row>
{hint && (
<BootstrapForm.Text>
{hint}
@ -617,34 +744,34 @@ function InputInner ({
}
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
export function UserSuggest ({
query, onSelect, dropdownStyle, children,
transformUser = user => user, selectWithTab = true, filterUsers = () => true
export function BaseSuggest ({
query, onSelect, dropdownStyle,
transformItem = item => item, selectWithTab = true, filterItems = () => true,
getSuggestionsQuery, queryName, itemsField,
children
}) {
const [getSuggestions] = useLazyQuery(USER_SUGGESTIONS, {
const [getSuggestions] = useLazyQuery(getSuggestionsQuery, {
onCompleted: data => {
query !== undefined && setSuggestions({
array: data.userSuggestions
.filter((...args) => filterUsers(query, ...args))
.map(transformUser),
array: data[itemsField]
.filter((...args) => filterItems(query, ...args))
.map(transformItem),
index: 0
})
}
})
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), [])
useEffect(() => {
if (query !== undefined) {
// remove both the leading @ and any @domain after nym
const q = query?.replace(/^[@ ]+|[ ]+$/g, '').replace(/@[^\s]*$/, '')
// remove the leading character and any trailing spaces
const q = query?.replace(/^[@ ~]+|[ ]+$/g, '').replace(/@[^\s]*$/, '').replace(/~[^\s]*$/, '')
getSuggestions({ variables: { q, limit: 5 } })
} else {
resetSuggestions()
}
}, [query, resetSuggestions, getSuggestions])
const onKeyDown = useCallback(e => {
switch (e.code) {
case 'ArrowUp':
@ -689,7 +816,6 @@ export function UserSuggest ({
break
}
}, [onSelect, resetSuggestions, suggestions])
return (
<>
{children?.({ onKeyDown, resetSuggestions })}
@ -712,17 +838,17 @@ export function UserSuggest ({
)
}
export function InputUserSuggest ({
label, groupClassName, transformUser, filterUsers,
selectWithTab, onChange, transformQuery, ...props
function BaseInputSuggest ({
label, groupClassName, transformItem, filterItems,
selectWithTab, onChange, transformQuery, SuggestComponent, prefixRegex, ...props
}) {
const [ovalue, setOValue] = useState()
const [query, setQuery] = useState()
return (
<FormGroup label={label} className={groupClassName}>
<UserSuggest
transformUser={transformUser}
filterUsers={filterUsers}
<SuggestComponent
transformItem={transformItem}
filterItems={filterItems}
selectWithTab={selectWithTab}
onSelect={(v) => {
// HACK ... ovalue does not trigger onChange
@ -737,19 +863,85 @@ export function InputUserSuggest ({
autoComplete='off'
onChange={(formik, e) => {
onChange && onChange(formik, e)
if (e.target.value === ovalue) {
// we don't need to set the ovalue or query if the value is the same
return
}
setOValue(e.target.value)
setQuery(e.target.value.replace(/^[@ ]+|[ ]+$/g, ''))
setQuery(e.target.value.replace(prefixRegex, ''))
}}
overrideValue={ovalue}
onKeyDown={onKeyDown}
onBlur={() => setTimeout(resetSuggestions, 500)}
/>
)}
</UserSuggest>
</SuggestComponent>
</FormGroup>
)
}
export function InputUserSuggest ({
transformUser, filterUsers, ...props
}) {
return (
<BaseInputSuggest
transformItem={transformUser}
filterItems={filterUsers}
SuggestComponent={UserSuggest}
prefixRegex={/^[@ ]+|[ ]+$/g}
{...props}
/>
)
}
export function InputTerritorySuggest ({
transformSub, filterSubs, ...props
}) {
return (
<BaseInputSuggest
transformItem={transformSub}
filterItems={filterSubs}
SuggestComponent={TerritorySuggest}
prefixRegex={/^[~ ]+|[ ]+$/g}
{...props}
/>
)
}
function UserSuggest ({
transformUser = user => user, filterUsers = () => true,
children, ...props
}) {
return (
<BaseSuggest
transformItem={transformUser}
filterItems={filterUsers}
getSuggestionsQuery={USER_SUGGESTIONS}
itemsField='userSuggestions'
{...props}
>
{children}
</BaseSuggest>
)
}
function TerritorySuggest ({
transformSub = sub => sub, filterSubs = () => true,
children, ...props
}) {
return (
<BaseSuggest
transformItem={transformSub}
filterItems={filterSubs}
getSuggestionsQuery={SUB_SUGGESTIONS}
itemsField='subSuggestions'
{...props}
>
{children}
</BaseSuggest>
)
}
export function Input ({ label, groupClassName, under, ...props }) {
return (
<FormGroup label={label} className={groupClassName}>
@ -765,31 +957,38 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re
<FieldArray name={name} hasValidation>
{({ form, ...fieldArrayHelpers }) => {
const options = form.values[name]
return (
<>
{options?.map((_, i) => (
<div key={i}>
<Row className='mb-2'>
<Col>
{children
? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined })
: <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} />}
</Col>
<Col className='d-flex ps-0' xs='auto'>
{options.length - 1 === i && options.length !== max
? <AddIcon className='fill-grey align-self-center justify-self-center pointer' onClick={() => fieldArrayHelpers.push(emptyItem)} />
// filler div for col alignment across rows
: <div style={{ width: '24px', height: '24px' }} />}
</Col>
{options.length - 1 === i &&
<>
{hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>}
{form.touched[name] && typeof form.errors[name] === 'string' &&
<div className='invalid-feedback d-block'>{form.errors[name]}</div>}
</>}
</Row>
</div>
))}
{options?.map((_, i) => {
const AppendColumn = ({ className }) => (
<Col className={`d-flex ps-0 ${className}`} xs='auto'>
{options.length - 1 === i && options.length !== max
// onMouseDown is used to prevent the blur event on text inputs from overriding the click event
? <AddIcon className='fill-grey align-self-center justify-self-center pointer' onMouseDown={() => fieldArrayHelpers.push(emptyItem)} />
// filler div for col alignment across rows
: <div style={{ width: '24px', height: '24px' }} />}
</Col>
)
return (
<div key={i}>
<Row className='mb-2'>
<Col>
{children
? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined, AppendColumn })
: <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} AppendColumn={AppendColumn} />}
</Col>
{options.length - 1 === i &&
<>
{hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>}
{form.touched[name] && typeof form.errors[name] === 'string' &&
<div className='invalid-feedback d-block'>{form.errors[name]}</div>}
</>}
</Row>
</div>
)
})}
</>
)
}}
@ -878,15 +1077,14 @@ export function Form ({
})
}, [storageKeyPrefix])
const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => {
const variables = { amount, ...values }
const onSubmitInner = useCallback(async (values, ...args) => {
if (requireSession && !me) {
throw new SessionRequiredError()
}
try {
if (onSubmit) {
await onSubmit(variables, ...args)
await onSubmit(values, ...args)
}
} catch (err) {
console.log(err.message, err)
@ -1144,33 +1342,6 @@ function PasswordHider ({ onClick, showPass }) {
)
}
function QrPassword ({ value }) {
const showModal = useShowModal()
const toaster = useToast()
const showQr = useCallback(() => {
showModal(close => (
<div>
<p className='line-height-md text-muted'>Import this passphrase into another device by navigating to device sync settings and scanning this QR code</p>
<div className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }}>
<QRCodeSVG className='h-auto mw-100' value={value} size={300} imageSettings={qrImageSettings} />
</div>
</div>
))
}, [toaster, value, showModal])
return (
<>
<InputGroup.Text
style={{ cursor: 'pointer' }}
onClick={showQr}
>
<QrIcon height={16} width={16} />
</InputGroup.Text>
</>
)
}
function PasswordScanner ({ onScan, text }) {
const showModal = useShowModal()
const toaster = useToast()
@ -1191,8 +1362,10 @@ function PasswordScanner ({ onScan, text }) {
<Scanner
formats={['qr_code']}
onScan={([{ rawValue: result }]) => {
onScan(result)
onClose()
if (result) {
onScan(result)
onClose()
}
}}
styles={{
video: {
@ -1207,6 +1380,7 @@ function PasswordScanner ({ onScan, text }) {
}
onClose()
}}
components={{ audio: false }}
/>
)}
</div>
@ -1233,12 +1407,12 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, value: ini
{copy && (
<CopyButton icon value={field?.value} />
)}
{qr && (readOnly
? <QrPassword value={field?.value} />
: <PasswordScanner
text="Where'd you learn to square dance?"
onScan={v => helpers.setValue(v)}
/>)}
{qr && (
<PasswordScanner
text="Where'd you learn to square dance?"
onScan={v => helpers.setValue(v)}
/>
)}
{append}
</>
)

View File

@ -109,4 +109,4 @@
padding-top: 1px;
background-color: var(--bs-body-bg);
z-index: 1000;
}
}

View File

@ -13,12 +13,12 @@ export default function CCInfo (props) {
<ul>
<li>if the zap is small and you don't have a direct channel to SN, the routing fee may exceed SN's 3% max fee</li>
<li>check your <Link href='/wallets/logs'>wallet logs</Link> for clues</li>
<li>if you have questions about the errors in your wallet logs, mention the error in the <Link href='/daily'>saloon</Link></li>
<li>if you have questions about the errors in your wallet logs, mention the error in the <Link href='/api/daily'>saloon</Link></li>
</ul>
</li>
<li>some zaps might be smaller than your configured receiving dust limit
<ul>
<li>you can configure your dust limit in your <Link href='/settings'>settings</Link></li>
<li>you can configure your dust limit in your <Link href='/wallets/settings'>wallet settings</Link></li>
</ul>
</li>
</ul>

View File

@ -6,9 +6,9 @@ import { CompactLongCountdown } from './countdown'
import PayerData from './payer-data'
import Bolt11Info from './bolt11-info'
import { useQuery } from '@apollo/client'
import { INVOICE } from '@/fragments/wallet'
import { INVOICE } from '@/fragments/invoice'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/errors'
import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/client/errors'
import ItemJob from './item-job'
import Item from './item'
import { CommentFlat } from './comment'

View File

@ -6,19 +6,22 @@ import { useMe } from './me'
import UpBolt from '@/svgs/bolt.svg'
import { amountSchema, boostSchema } from '@/lib/validate'
import { useToast } from './toast'
import { useLightning } from './lightning'
import { nextTip, defaultTipIncludingRandom } from './upvote'
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { usePaidMutation } from './use-paid-mutation'
import { ACT_MUTATION } from '@/fragments/paidAction'
import { meAnonSats } from '@/lib/apollo'
import { BoostItemInput } from './adv-post-form'
import { useSendWallets } from '@/wallets/index'
import { useHasSendWallet } from '@/wallets/client/hooks'
import { useAnimation } from '@/components/animation'
const defaultTips = [100, 1000, 10_000, 100_000]
const Tips = ({ setOValue }) => {
const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
const customTips = getCustomTips()
const defaultNoCustom = defaultTips.filter(d => !customTips.includes(d))
const tips = [...customTips, ...defaultNoCustom].slice(0, 7).sort((a, b) => a - b)
return tips.map((num, i) =>
<Button
size='sm'
@ -37,11 +40,7 @@ const Tips = ({ setOValue }) => {
const getCustomTips = () => JSON.parse(window.localStorage.getItem('custom-tips')) || []
const addCustomTip = (amount) => {
if (defaultTips.includes(amount)) return
let customTips = Array.from(new Set([amount, ...getCustomTips()]))
if (customTips.length > 3) {
customTips = customTips.slice(0, 3)
}
const customTips = Array.from(new Set([amount, ...getCustomTips()])).slice(0, 7)
window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
}
@ -89,7 +88,7 @@ function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'B
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
const inputRef = useRef(null)
const { me } = useMe()
const wallets = useSendWallets()
const hasSendWallet = useHasSendWallet()
const [oValue, setOValue] = useState()
useEffect(() => {
@ -97,7 +96,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
}, [onClose, item.id])
const actor = useAct()
const strike = useLightning()
const animate = useAnimation()
const onSubmit = useCallback(async ({ amount }) => {
if (abortSignal && zapUndoTrigger({ me, amount })) {
@ -112,12 +111,12 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
}
const onPaid = () => {
strike()
animate()
onClose?.()
if (!me) setItemMeAnonSats({ id: item.id, amount })
}
const closeImmediately = wallets.length > 0 || me?.privates?.sats > Number(amount)
const closeImmediately = hasSendWallet || me?.privates?.sats > Number(amount)
if (closeImmediately) {
onPaid()
}
@ -127,7 +126,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
id: item.id,
sats: Number(amount),
act,
hasSendWallet: wallets.length > 0
hasSendWallet
},
optimisticResponse: me
? {
@ -144,7 +143,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
})
if (error) throw error
addCustomTip(Number(amount))
}, [me, actor, wallets.length, act, item.id, onClose, abortSignal, strike])
}, [me, actor, hasSendWallet, act, item.id, onClose, abortSignal, animate])
return act === 'BOOST'
? <BoostForm step={step} onSubmit={onSubmit} item={item} inputRef={inputRef} act={act}>{children}</BoostForm>
@ -264,13 +263,13 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
// because the mutation name we use varies,
// we need to extract the result/invoice from the response
const getPaidActionResult = data => Object.values(data)[0]
const wallets = useSendWallets()
const hasSendWallet = useHasSendWallet()
const [act] = usePaidMutation(query, {
waitFor: inv =>
// if we have attached wallets, we might be paying a wrapped invoice in which case we need to make sure
// we don't prematurely consider the payment as successful (important for receiver fallbacks)
wallets.length > 0
hasSendWallet
? inv?.actionState === 'PAID'
: inv?.satsReceived > 0,
...options,
@ -299,9 +298,9 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
}
export function useZap () {
const wallets = useSendWallets()
const hasSendWallet = useHasSendWallet()
const act = useAct()
const strike = useLightning()
const animate = useAnimation()
const toaster = useToast()
return useCallback(async ({ item, me, abortSignal }) => {
@ -310,14 +309,14 @@ export function useZap () {
// add current sats to next tip since idempotent zaps use desired total zap not difference
const sats = nextTip(meSats, { ...me?.privates })
const variables = { id: item.id, sats, act: 'TIP', hasSendWallet: wallets.length > 0 }
const variables = { id: item.id, sats, act: 'TIP', hasSendWallet }
const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } }
try {
await abortSignal.pause({ me, amount: sats })
strike()
animate()
// batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request
const { error } = await act({ variables, optimisticResponse, context: { batch: wallets.length > 0 || me?.privates?.sats > sats } })
const { error } = await act({ variables, optimisticResponse, context: { batch: hasSendWallet || me?.privates?.sats > sats } })
if (error) throw error
} catch (error) {
if (error instanceof ActCanceledError) {
@ -328,7 +327,7 @@ export function useZap () {
// but right now this toast is noisy for optimistic zaps
console.error(error)
}
}, [act, toaster, strike, wallets.length])
}, [act, toaster, animate, hasSendWallet])
}
export class ActCanceledError extends Error {

View File

@ -191,6 +191,8 @@ export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props
comments={item.comments.comments}
commentsCursor={item.comments.cursor}
fetchMoreComments={fetchMoreComments}
lastCommentAt={item.lastCommentAt}
item={item}
/>
</div>}
</CarouselProvider>

View File

@ -89,11 +89,14 @@ export default function ItemInfo ({
const myPost = (me && root && Number(me.id) === Number(root.user.id))
const rootReply = item.path.split('.').length === 2
const canPin = (isPost && mySub) || (myPost && rootReply)
const isPinnedPost = isPost && item.position && (pinnable || !item.subName)
const isPinnedSubReply = !isPost && item.position && !item.subName
const isAd = !item.parentId && Number(item.user?.id) === USER_ID.ad
const meSats = (me ? item.meSats : item.meAnonSats) || 0
return (
<div className={className || `${styles.other}`}>
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
{!isPinnedPost && !(isPinnedSubReply && !full) && !isAd &&
<>
<span title={itemTitle(item)}>
{numWithUnits(item.sats)}
@ -107,7 +110,7 @@ export default function ItemInfo ({
</>}
<Link
href={`/items/${item.id}`} onClick={(e) => {
const viewedAt = commentsViewedAt(item)
const viewedAt = commentsViewedAt(item.id)
if (viewedAt) {
e.preventDefault()
router.push(
@ -282,7 +285,7 @@ function InfoDropdownItem ({ item }) {
)
}
function PaymentInfo ({ item, disableRetry, setDisableRetry }) {
export function PaymentInfo ({ item, disableRetry, setDisableRetry }) {
const { me } = useMe()
const toaster = useToast()
const retryCreateItem = useRetryCreateItem({ id: item.id })

View File

@ -13,8 +13,9 @@ import { MEDIA_URL } from '@/lib/constants'
import { abbrNum } from '@/lib/format'
import { Badge } from 'react-bootstrap'
import SubPopover from './sub-popover'
import { PaymentInfo } from './item-info'
export default function ItemJob ({ item, toc, rank, children }) {
export default function ItemJob ({ item, toc, rank, children, disableRetry, setDisableRetry }) {
const isEmail = string().email().isValidSync(item.url)
return (
@ -78,6 +79,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
<Link href={`/items/${item.id}/edit`} className='text-reset fw-bold'>
edit
</Link>
<PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
</>)}
</div>
</div>

View File

@ -29,7 +29,7 @@ import { useShowModal } from './modal'
import { BoostHelp } from './adv-post-form'
function onItemClick (e, router, item) {
const viewedAt = commentsViewedAt(item)
const viewedAt = commentsViewedAt(item.id)
if (viewedAt) {
e.preventDefault()
if (e.ctrlKey || e.metaKey) {

View File

@ -45,18 +45,18 @@ function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
)
}
function LightningExplainer ({ text, children }) {
function LightningExplainer ({ text, children, backButton, md = 12, lg = 6 }) {
const router = useRouter()
return (
<Container>
<div className={styles.login}>
<div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>
{backButton && <div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>}
<h3 className='w-100 pb-2'>
{text || 'Login'} with Lightning
</h3>
<div className='fw-bold text-muted pb-4'>This is the most private way to use Stacker News. Just open your Lightning wallet and scan the QR code.</div>
<Row className='w-100 text-muted'>
<Col className='ps-0 mb-4' md>
<Col className='ps-0 mb-4' md={md} lg={lg}>
<AccordianItem
header={`Which wallets can I use to ${(text || 'Login').toLowerCase()}?`}
body={
@ -92,7 +92,7 @@ function LightningExplainer ({ text, children }) {
}
/>
</Col>
<Col md className='mx-auto' style={{ maxWidth: '300px' }}>
<Col md={md} lg={lg} className='mx-auto' style={{ maxWidth: '300px' }}>
{children}
</Col>
</Row>
@ -101,9 +101,9 @@ function LightningExplainer ({ text, children }) {
)
}
export function LightningAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
export function LightningAuthWithExplainer ({ text, callbackUrl, multiAuth, backButton = true, md = 12, lg = 6 }) {
return (
<LightningExplainer text={text}>
<LightningExplainer text={text} backButton={backButton} md={md} lg={lg}>
<LightningAuth callbackUrl={callbackUrl} multiAuth={multiAuth} />
</LightningExplainer>
)

View File

@ -35,3 +35,12 @@
.linkBoxParent img {
pointer-events: auto !important;
}
.linkBoxParent h1 a,
.linkBoxParent h2 a,
.linkBoxParent h3 a,
.linkBoxParent h4 a,
.linkBoxParent h5 a,
.linkBoxParent h6 a {
pointer-events: none !important;
}

View File

@ -1,62 +0,0 @@
import { timeSince } from '@/lib/time'
import styles from '@/styles/log.module.css'
import { Fragment, useState } from 'react'
export default function LogMessage ({ showWallet, wallet, level, message, context, ts }) {
const [show, setShow] = useState(false)
let className
switch (level.toLowerCase()) {
case 'ok':
case 'success':
level = 'ok'
className = 'text-success'; break
case 'error':
className = 'text-danger'; break
case 'warn':
className = 'text-warning'; break
default:
className = 'text-info'
}
const filtered = context
? Object.keys(context)
.filter(key => !['send', 'recv', 'status'].includes(key))
.reduce((obj, key) => {
obj[key] = context[key]
return obj
}, {})
: {}
const hasContext = context && Object.keys(filtered).length > 0
const handleClick = () => {
if (hasContext) { setShow(show => !show) }
}
const style = hasContext ? { cursor: 'pointer' } : { cursor: 'inherit' }
const indicator = hasContext ? (show ? '-' : '+') : <></>
return (
<>
<tr className={styles.tableRow} onClick={handleClick} style={style}>
<td className={styles.timestamp}>{timeSince(new Date(ts))}</td>
{showWallet ? <td className={styles.wallet}>[{wallet}]</td> : <td className='mx-1' />}
<td className={`${styles.level} ${className}`}>{level}</td>
<td>{message}</td>
<td>{indicator}</td>
</tr>
{show && hasContext && Object.entries(filtered)
.map(([key, value], i) => {
const last = i === Object.keys(filtered).length - 1
return (
<tr className={styles.line} key={i}>
<td />
<td className={last ? 'pb-2 pe-1' : 'pe-1'} colSpan='2'>{key}</td>
<td className={last ? 'text-break pb-2' : 'text-break'}>{value}</td>
</tr>
)
})}
</>
)
}

View File

@ -1,119 +0,0 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useMe } from './me'
import fancyNames from '@/lib/fancy-names.json'
const generateFancyName = () => {
// 100 adjectives * 100 nouns * 10000 = 100M possible names
const pickRandom = (array) => array[Math.floor(Math.random() * array.length)]
const adj = pickRandom(fancyNames.adjectives)
const noun = pickRandom(fancyNames.nouns)
const id = Math.floor(Math.random() * fancyNames.maxSuffix)
return `${adj}-${noun}-${id}`
}
export function detectOS () {
if (!window.navigator) return ''
const userAgent = window.navigator.userAgent
const platform = window.navigator.userAgentData?.platform || window.navigator.platform
const macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K']
const windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE']
const iosPlatforms = ['iPhone', 'iPad', 'iPod']
let os = null
if (macosPlatforms.indexOf(platform) !== -1) {
os = 'Mac OS'
} else if (iosPlatforms.indexOf(platform) !== -1) {
os = 'iOS'
} else if (windowsPlatforms.indexOf(platform) !== -1) {
os = 'Windows'
} else if (/Android/.test(userAgent)) {
os = 'Android'
} else if (/Linux/.test(platform)) {
os = 'Linux'
}
return os
}
export const LoggerContext = createContext()
export const LoggerProvider = ({ children }) => {
return (
<ServiceWorkerLoggerProvider>
{children}
</ServiceWorkerLoggerProvider>
)
}
const ServiceWorkerLoggerContext = createContext()
function ServiceWorkerLoggerProvider ({ children }) {
const { me } = useMe()
const [name, setName] = useState()
const [os, setOS] = useState()
useEffect(() => {
let name = window.localStorage.getItem('fancy-name')
if (!name) {
name = generateFancyName()
window.localStorage.setItem('fancy-name', name)
}
setName(name)
setOS(detectOS())
}, [])
const log = useCallback(level => {
return async (message, context) => {
if (!me || !me.privates?.diagnostics) return
const env = {
userAgent: window.navigator.userAgent,
// os may not be initialized yet
os: os || detectOS()
}
const body = {
level,
env,
// name may be undefined if it wasn't stored in local storage yet
// we fallback to local storage since on page reloads, the name may wasn't fetched from local storage yet
name: name || window.localStorage.getItem('fancy-name'),
message,
context
}
await fetch('/api/log', {
method: 'post',
headers: {
'Content-type': 'application/json'
},
body: JSON.stringify(body)
}).catch(console.error)
}
}, [me?.privates?.diagnostics, name, os])
const logger = useMemo(() => ({
info: log('info'),
warn: log('warn'),
error: log('error'),
name
}), [log, name])
useEffect(() => {
// for communication between app and service worker
const channel = new MessageChannel()
navigator?.serviceWorker?.controller?.postMessage({ action: 'MESSAGE_PORT' }, [channel.port2])
channel.port1.onmessage = (event) => {
const { message, level, context } = Object.assign({ level: 'info' }, event.data)
logger[level](message, context)
}
}, [logger])
return (
<ServiceWorkerLoggerContext.Provider value={logger}>
{children}
</ServiceWorkerLoggerContext.Provider>
)
}
export function useServiceWorkerLogger () {
return useContext(ServiceWorkerLoggerContext)
}

View File

@ -12,6 +12,7 @@ import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import { datePivot } from '@/lib/time'
import * as cookie from 'cookie'
import { cookieOptions } from '@/lib/auth'
import Link from 'next/link'
export function EmailLoginForm ({ text, callbackUrl, multiAuth }) {
const disabled = multiAuth
@ -52,12 +53,26 @@ const authErrorMessages = {
default: 'Auth failed. Try again or choose a different method.'
}
export function authErrorMessage (error) {
return error && (authErrorMessages[error] ?? authErrorMessages.default)
export function authErrorMessage (error, signin) {
if (!error) return null
const message = error && (authErrorMessages[error] ?? authErrorMessages.default)
// workaround for signin/signup awareness due to missing support from next-auth
if (signin) {
return (
<>
{message}
<br />
If you are new to Stacker News, please <Link className='fw-bold' href='/signup'>sign up</Link> first.
</>
)
}
return message
}
export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer, signin }) {
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error))
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error, signin))
const router = useRouter()
// signup/signin awareness cookie

View File

@ -4,6 +4,12 @@ import BackArrow from '@/svgs/arrow-left-line.svg'
import { useRouter } from 'next/router'
import ActionDropdown from './action-dropdown'
export class ModalClosedError extends Error {
constructor () {
super('modal closed')
}
}
export const ShowModalContext = createContext(() => null)
export function ShowModalProvider ({ children }) {

View File

@ -7,24 +7,24 @@ import { useCallback, useEffect, useState } from 'react'
import Price from '../price'
import SubSelect from '../sub-select'
import { USER_ID } from '../../lib/constants'
import Head from 'next/head'
import NoteIcon from '../../svgs/notification-4-fill.svg'
import { useMe } from '../me'
import { abbrNum } from '../../lib/format'
import { useServiceWorker } from '../serviceworker'
import { signOut } from 'next-auth/react'
import Badges from '../badge'
import { randInRange } from '../../lib/rand'
import { useLightning } from '../lightning'
import LightningIcon from '../../svgs/bolt.svg'
import SearchIcon from '../../svgs/search-line.svg'
import classNames from 'classnames'
import SnIcon from '@/svgs/sn.svg'
import { useHasNewNotes } from '../use-has-new-notes'
import { useWallets } from '@/wallets/index'
import SwitchAccountList, { useAccounts } from '@/components/account'
// import { useWallets } from '@/wallets/client/hooks'
import { useWalletIndicator } from '@/wallets/client/hooks'
import SwitchAccountList, { nextAccount, useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal'
import { numWithUnits } from '@/lib/format'
import Head from 'next/head'
export function Brand ({ className }) {
return (
<Link href='/' passHref legacyBehavior>
@ -164,8 +164,34 @@ export function NavWalletSummary ({ className }) {
)
}
export const Indicator = ({ superscript }) => {
if (superscript) {
return (
<span className='d-inline-block p-1'>
<span
className='position-absolute p-1 bg-secondary'
style={{ top: '5px', right: '0px', height: '5px', width: '5px' }}
>
<span className='invisible'>{' '}</span>
</span>
</span>
)
}
return (
<div className='p-1 d-inline-block bg-secondary ms-1'>
<span className='invisible'>{' '}</span>
</div>
)
}
export function MeDropdown ({ me, dropNavKey }) {
if (!me) return null
const profileIndicator = !me.bioId
const walletIndicator = useWalletIndicator()
const indicator = profileIndicator || walletIndicator
return (
<div className=''>
<Dropdown className={styles.dropdown} align='end'>
@ -173,12 +199,7 @@ export function MeDropdown ({ me, dropNavKey }) {
<div className='d-flex align-items-center'>
<Nav.Link eventKey={me.name} as='span' className='p-0 position-relative'>
{`@${me.name}`}
{!me.bioId &&
<span className='d-inline-block p-1'>
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px', height: '5px', width: '5px' }}>
<span className='invisible'>{' '}</span>
</span>
</span>}
{indicator && <Indicator superscript />}
</Nav.Link>
<Badges user={me} />
</div>
@ -187,17 +208,17 @@ export function MeDropdown ({ me, dropNavKey }) {
<Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}>
profile
{me && !me.bioId &&
<div className='p-1 d-inline-block bg-secondary ms-1'>
<span className='invisible'>{' '}</span>
</div>}
{profileIndicator && <Indicator />}
</Dropdown.Item>
</Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
</Link>
<Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
<Dropdown.Item eventKey='wallets'>
wallets
{walletIndicator && <Indicator />}
</Dropdown.Item>
</Link>
<Link href='/credits' passHref legacyBehavior>
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>
@ -272,8 +293,7 @@ export default function LoginButton () {
function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const { removeLocalWallets } = useWallets()
const { nextAccount } = useAccounts()
// const { removeLocalWallets } = useWallets()
const router = useRouter()
return (
@ -304,8 +324,6 @@ function LogoutObstacle ({ onClose }) {
await togglePushSubscription().catch(console.error)
}
removeLocalWallets()
await signOut({ callbackUrl: '/' })
}}
>
@ -340,7 +358,7 @@ export function LogoutDropdownItem ({ handleClose }) {
function SwitchAccountButton ({ handleClose }) {
const showModal = useShowModal()
const { accounts } = useAccounts()
const accounts = useAccounts()
if (accounts.length === 0) return null
@ -378,18 +396,6 @@ export function LoginButtons ({ handleClose }) {
}
export function AnonDropdown ({ path }) {
const strike = useLightning()
useEffect(() => {
if (!window.localStorage.getItem('striked')) {
const to = setTimeout(() => {
strike()
window.localStorage.setItem('striked', 'yep')
}, randInRange(3000, 10000))
return () => clearTimeout(to)
}
}, [])
return (
<div className='position-relative'>
<Dropdown className={styles.dropdown} align='end' autoClose>

View File

@ -2,11 +2,12 @@ import { useState } from 'react'
import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap'
import { MEDIA_URL } from '@/lib/constants'
import Link from 'next/link'
import { LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
import { Indicator, LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
import AnonIcon from '@/svgs/spy-fill.svg'
import styles from './footer.module.css'
import canvasStyles from './offcanvas.module.css'
import classNames from 'classnames'
import { useWalletIndicator } from '@/wallets/client/hooks'
export default function OffCanvas ({ me, dropNavKey }) {
const [show, setShow] = useState(false)
@ -25,6 +26,9 @@ export default function OffCanvas ({ me, dropNavKey }) {
)
: <span className='text-muted pointer'><AnonIcon onClick={onClick} width='22' height='22' /></span>
const profileIndicator = me && !me.bioId
const walletIndicator = useWalletIndicator()
return (
<>
<MeImage onClick={handleShow} />
@ -50,17 +54,17 @@ export default function OffCanvas ({ me, dropNavKey }) {
<Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}>
profile
{me && !me.bioId &&
<div className='p-1 d-inline-block bg-secondary ms-1'>
<span className='invisible'>{' '}</span>
</div>}
{profileIndicator && <Indicator />}
</Dropdown.Item>
</Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
</Link>
<Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallets'>wallets</Dropdown.Item>
<Dropdown.Item eventKey='wallets'>
wallets
{walletIndicator && <Indicator />}
</Dropdown.Item>
</Link>
<Link href='/credits' passHref legacyBehavior>
<Dropdown.Item eventKey='credits'>credits</Dropdown.Item>

View File

@ -58,7 +58,9 @@ function Notification ({ n, fresh }) {
(type === 'InvoicePaid' && (n.invoice.nostr ? <NostrZap n={n} /> : <InvoicePaid n={n} />)) ||
(type === 'WithdrawlPaid' && <WithdrawlPaid n={n} />) ||
(type === 'Referral' && <Referral n={n} />) ||
(type === 'Streak' && <Streak n={n} />) ||
(type === 'CowboyHat' && <CowboyHat n={n} />) ||
(['NewHorse', 'LostHorse'].includes(type) && <Horse n={n} />) ||
(['NewGun', 'LostGun'].includes(type) && <Gun n={n} />) ||
(type === 'Votification' && <Votification n={n} />) ||
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
(type === 'Mention' && <Mention n={n} />) ||
@ -112,12 +114,14 @@ function NoteHeader ({ color, children, big }) {
function NoteItem ({ item, ...props }) {
return (
<div>
{item.title
? <Item item={item} itemClassName='pt-0' {...props} />
: (
<RootProvider root={item.root}>
<Comment item={item} noReply includeParent clickToContext {...props} />
</RootProvider>)}
{item.isJob
? <ItemJob item={item} {...props} />
: item.title
? <Item item={item} itemClassName='pt-0' {...props} />
: (
<RootProvider root={item.root}>
<Comment item={item} noReply includeParent clickToContext {...props} />
</RootProvider>)}
</div>
)
}
@ -163,7 +167,7 @@ const defaultOnClick = n => {
if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` }
if (type === 'Referral') return { href: '/referrals/month' }
if (type === 'ReferralReward') return { href: '/referrals/month' }
if (type === 'Streak') return {}
if (['CowboyHat', 'NewHorse', 'LostHorse', 'NewGun', 'LostGun'].includes(type)) return {}
if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
if (!n.item) return {}
@ -172,30 +176,64 @@ const defaultOnClick = n => {
return itemLink(n.item)
}
function Streak ({ n }) {
function blurb (n) {
const type = n.type ?? 'COWBOY_HAT'
const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length)
if (n.days) {
return `After ${numWithUnits(n.days, {
abbreviate: false,
unitSingular: 'day',
unitPlural: 'days'
})}, ` + LOST_BLURBS[type][index]
}
function blurb (n) {
const type = n.__typename === 'CowboyHat'
? 'COWBOY_HAT'
: (n.__typename.includes('Horse') ? 'HORSE' : 'GUN')
const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length)
const lost = n.days || n.__typename.includes('Lost')
return lost ? LOST_BLURBS[type][index] : FOUND_BLURBS[type][index]
}
return FOUND_BLURBS[type][index]
function CowboyHat ({ n }) {
const Icon = n.days ? BaldIcon : CowboyHatIcon
let body = ''
if (n.days) {
body = `After ${numWithUnits(n.days, {
abbreviate: false,
unitSingular: 'day',
unitPlural: 'days'
})}, `
}
const Icon = n.days
? n.type === 'GUN' ? HolsterIcon : n.type === 'HORSE' ? SaddleIcon : BaldIcon
: n.type === 'GUN' ? GunIcon : n.type === 'HORSE' ? HorseIcon : CowboyHatIcon
body += `you ${n.days ? 'lost your' : 'found a'} cowboy hat`
return (
<div className='d-flex'>
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
<div className='ms-1 p-1'>
<span className='fw-bold'>you {n.days ? 'lost your' : 'found a'} {n.type.toLowerCase().replace('_', ' ')}</span>
<span className='fw-bold'>{body}</span>
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
</div>
</div>
)
}
function Horse ({ n }) {
const found = n.__typename.includes('New')
const Icon = found ? HorseIcon : SaddleIcon
return (
<div className='d-flex'>
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
<div className='ms-1 p-1'>
<span className='fw-bold'>you {found ? 'found a' : 'lost your'} horse</span>
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
</div>
</div>
)
}
function Gun ({ n }) {
const found = n.__typename.includes('New')
const Icon = found ? GunIcon : HolsterIcon
return (
<div className='d-flex'>
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
<div className='ms-1 p-1'>
<span className='fw-bold'>you {found ? 'found a' : 'lost your'} gun</span>
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
</div>
</div>
@ -700,7 +738,7 @@ function Reminder ({ n }) {
return (
<>
<NoteHeader color='info'>
you asked to be reminded of this {n.item.title ? 'post' : 'comment'}
you requested this reminder
</NoteHeader>
<NoteItem item={n.item} />
</>

View File

@ -1,5 +1,4 @@
import React from 'react'
import Button from 'react-bootstrap/Button'
import styles from './pay-bounty.module.css'
import ActionTooltip from './action-tooltip'
import { useMe } from './me'
@ -7,9 +6,10 @@ import { numWithUnits } from '@/lib/format'
import { useShowModal } from './modal'
import { useRoot } from './root'
import { ActCanceledError, useAct } from './item-act'
import { useLightning } from './lightning'
import { useAnimation } from '@/components/animation'
import { useToast } from './toast'
import { useSendWallets } from '@/wallets/index'
import { useHasSendWallet } from '@/wallets/client/hooks'
import { Form, SubmitButton } from './form'
export const payBountyCacheMods = {
onPaid: (cache, { data }) => {
@ -48,11 +48,11 @@ export default function PayBounty ({ children, item }) {
const { me } = useMe()
const showModal = useShowModal()
const root = useRoot()
const strike = useLightning()
const animate = useAnimation()
const toaster = useToast()
const wallets = useSendWallets()
const hasSendWallet = useHasSendWallet()
const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet: wallets.length > 0 }
const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet }
const act = useAct({
variables,
optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } },
@ -61,7 +61,7 @@ export default function PayBounty ({ children, item }) {
const handlePayBounty = async onCompleted => {
try {
strike()
animate()
const { error } = await act({ onCompleted })
if (error) throw error
} catch (error) {
@ -90,11 +90,12 @@ export default function PayBounty ({ children, item }) {
<div className='text-center fw-bold text-muted'>
Pay this bounty to {item.user.name}?
</div>
<div className='text-center'>
<Button className='mt-4' variant='primary' onClick={() => handlePayBounty(onClose)}>
pay <small>{numWithUnits(root.bounty)}</small>
</Button>
</div>
{/* initial={{ id: item.id }} is a hack to allow SubmitButton to be used as a button */}
<Form className='text-center' onSubmit={() => handlePayBounty(onClose)} initial={{ id: item.id }}>
<SubmitButton className='mt-4' variant='primary' submittingText='paying...' appendText={numWithUnits(root.bounty)}>
pay
</SubmitButton>
</Form>
</>
))
}}

View File

@ -1,4 +1,4 @@
import { DateTimeInput, Form, Input, MarkdownInput, VariableInput } from '@/components/form'
import { Checkbox, DateTimeInput, Form, Input, MarkdownInput, VariableInput } from '@/components/form'
import { useApolloClient } from '@apollo/client'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
@ -30,6 +30,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
text: item?.text || '',
options: initialOptions || ['', ''],
crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting,
randPollOptions: item?.poll?.randPollOptions || false,
pollExpiresAt: item ? item.pollExpiresAt : datePivot(new Date(), { hours: 25 }),
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
...SubSelectInitial({ sub: item?.subName || sub?.name })
@ -68,6 +69,11 @@ export function PollForm ({ item, sub, editThreshold, children }) {
label='poll expiration'
name='pollExpiresAt'
className='pr-4'
groupClassName='mb-0'
/>
<Checkbox
label={<div className='d-flex align-items-center'>randomize order of poll choices</div>}
name='randPollOptions'
/>
</AdvPostForm>
<ItemButtonBar itemId={item?.id} />

View File

@ -110,7 +110,7 @@ export function PostForm ({ type, sub, children }) {
noForm
size='medium'
sub={sub?.name}
info={sub && <TerritoryInfo sub={sub} />}
info={sub && <TerritoryInfo sub={sub} includeLink />}
hint={sub?.moderated && 'this territory is moderated'}
/>
<div>
@ -176,7 +176,7 @@ export default function Post ({ sub }) {
className='d-flex'
size='medium'
label='territory'
info={sub && <TerritoryInfo sub={sub} />}
info={sub && <TerritoryInfo sub={sub} includeLink />}
hint={sub?.moderated && 'this territory is moderated'}
/>}
</PostForm>

View File

@ -0,0 +1,53 @@
export default function preserveScroll (callback) {
// preserve the actual scroll position
const scrollTop = window.scrollY
// if the scroll position is at the top, we don't need to preserve it, just call the callback
if (scrollTop <= 0) {
callback()
return
}
// get a reference element at the center of the viewport to track if content is added above it
const ref = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
const refTop = ref ? ref.getBoundingClientRect().top + scrollTop : scrollTop
// observe the document for changes in height
const observer = new window.MutationObserver(() => {
// request animation frame to ensure the DOM is updated
window.requestAnimationFrame(() => {
// we can't proceed if we couldn't find a traceable reference element
if (!ref) {
cleanup()
return
}
// get the new position of the reference element along with the new scroll position
const newRefTop = ref ? ref.getBoundingClientRect().top + window.scrollY : window.scrollY
// has the reference element moved?
const refMoved = newRefTop - refTop
// if the reference element moved, we need to scroll to the new position
if (refMoved > 0) {
window.scrollTo({
// some browsers don't respond well to fractional scroll position, so we round up the new position to the nearest integer
top: scrollTop + Math.ceil(refMoved),
behavior: 'instant'
})
}
cleanup()
})
})
const timeout = setTimeout(() => cleanup(), 1000) // fallback
function cleanup () {
clearTimeout(timeout)
observer.disconnect()
}
observer.observe(document.body, { childList: true, subtree: true })
callback()
}

View File

@ -13,6 +13,7 @@ import { useRoot } from './root'
import { CREATE_COMMENT } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit'
import gql from 'graphql-tag'
import { updateAncestorsCommentCount } from '@/lib/comments'
export default forwardRef(function Reply ({
item,
@ -82,17 +83,7 @@ export default forwardRef(function Reply ({
const ancestors = item.path.split('.')
// update all ancestors
ancestors.forEach(id => {
cache.modify({
id: `Item:${id}`,
fields: {
ncomments (existingNComments = 0) {
return existingNComments + 1
}
},
optimistic: true
})
})
updateAncestorsCommentCount(cache, ancestors, 1)
// so that we don't see indicator for our own comments, we record this comments as the latest time
// but we also have record num comments, in case someone else commented when we did

View File

@ -1,22 +1,26 @@
import Container from 'react-bootstrap/Container'
import styles from './search.module.css'
import SearchIcon from '@/svgs/search-line.svg'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Form, Input, Select, DatePicker, SubmitButton } from './form'
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import {
Form,
Input,
Select,
DatePicker,
SubmitButton,
useDualAutocomplete,
DualAutocompleteWrapper
} from './form'
import { useRouter } from 'next/router'
import { whenToFrom } from '@/lib/time'
import { useMe } from './me'
import { useField } from 'formik'
export default function Search ({ sub }) {
const router = useRouter()
const [q, setQ] = useState(router.query.q || '')
const inputRef = useRef(null)
const { me } = useMe()
useEffect(() => {
inputRef.current?.focus()
}, [])
const search = async values => {
let prefix = ''
if (sub) {
@ -63,18 +67,13 @@ export default function Search ({ sub }) {
onSubmit={values => search({ ...values })}
>
<div className={`${styles.active} mb-3`}>
<Input
<SearchInput
name='q'
required
autoFocus
groupClassName='me-3 mb-0 flex-grow-1'
className='flex-grow-1'
clear
innerRef={inputRef}
overrideValue={q}
onChange={async (formik, e) => {
setQ(e.target.value?.trim())
}}
setOuterQ={setQ}
/>
<SubmitButton variant='primary' className={styles.search}>
<SearchIcon width={22} height={22} />
@ -135,3 +134,52 @@ export default function Search ({ sub }) {
</>
)
}
function SearchInput ({ name, setOuterQ, ...props }) {
const [, meta, helpers] = useField(name)
const inputRef = useRef(null)
useEffect(() => {
if (meta.value !== undefined) setOuterQ(meta.value.trim())
}, [meta.value, setOuterQ])
const setCaret = useCallback(({ start, end }) => {
inputRef.current?.setSelectionRange(start, end)
}, [])
const { userAutocomplete, territoryAutocomplete, handleTextChange, handleKeyDown, handleBlur } = useDualAutocomplete({
meta,
helpers,
innerRef: inputRef,
setSelectionRange: setCaret
})
const handleChangeWithOuter = useCallback((formik, e) => {
setOuterQ(e.target.value.trim())
handleTextChange(e)
}, [setOuterQ, handleTextChange])
return (
<div className='position-relative flex-grow-1'>
<DualAutocompleteWrapper
userAutocomplete={userAutocomplete}
territoryAutocomplete={territoryAutocomplete}
>
{({ userSuggestOnKeyDown, territorySuggestOnKeyDown, resetUserSuggestions, resetTerritorySuggestions }) => (
<Input
name={name}
innerRef={inputRef}
clear
autoComplete='off'
onChange={handleChangeWithOuter}
onKeyDown={(e) => {
handleKeyDown(e, userSuggestOnKeyDown, territorySuggestOnKeyDown)
}}
onBlur={() => handleBlur(resetUserSuggestions, resetTerritorySuggestions)}
{...props}
/>
)}
</DualAutocompleteWrapper>
</div>
)
}

View File

@ -1,20 +1,16 @@
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
import { Workbox } from 'workbox-window'
import { gql, useMutation } from '@apollo/client'
import { detectOS, useServiceWorkerLogger } from './logger'
import { requestPersistentStorage } from './use-indexeddb'
const applicationServerKey = process.env.NEXT_PUBLIC_VAPID_PUBKEY
const ServiceWorkerContext = createContext()
// message types for communication between app and service worker
export const MESSAGE_PORT = 'MESSAGE_PORT' // message to exchange message channel on which service worker will send messages back to app
export const ACTION_PORT = 'ACTION_PORT' // message to exchange action channel on which service worker will send actions back to app
export const SYNC_SUBSCRIPTION = 'SYNC_SUBSCRIPTION' // trigger onPushSubscriptionChange event in service worker manually
export const RESUBSCRIBE = 'RESUBSCRIBE' // trigger resubscribing to push notifications (sw -> app)
export const DELETE_SUBSCRIPTION = 'DELETE_SUBSCRIPTION' // delete subscription in IndexedDB (app -> sw)
export const STORE_SUBSCRIPTION = 'STORE_SUBSCRIPTION' // store subscription in IndexedDB (app -> sw)
export const STORE_OS = 'STORE_OS' // store OS in service worker
export const DELETE_SUBSCRIPTION = 'DELETE_SUBSCRIPTION'
export const STORE_SUBSCRIPTION = 'STORE_SUBSCRIPTION'
export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS'
export const ServiceWorkerProvider = ({ children }) => {
const [registration, setRegistration] = useState(null)
@ -38,13 +34,12 @@ export const ServiceWorkerProvider = ({ children }) => {
`)
const [deletePushSubscription] = useMutation(
gql`
mutation deletePushSubscription($endpoint: String!) {
deletePushSubscription(endpoint: $endpoint) {
id
}
mutation deletePushSubscription($endpoint: String!) {
deletePushSubscription(endpoint: $endpoint) {
id
}
`)
const logger = useServiceWorkerLogger()
}
`)
// I am not entirely sure if this is needed since at least in Brave,
// using `registration.pushManager.subscribe` also prompts the user.
@ -77,7 +72,6 @@ export const ServiceWorkerProvider = ({ children }) => {
// see https://stackoverflow.com/a/69624651
let pushSubscription = await registration.pushManager.subscribe(subscribeOptions)
const { endpoint } = pushSubscription
logger.info('subscribed to push notifications', { endpoint })
// convert keys from ArrayBuffer to string
pushSubscription = JSON.parse(JSON.stringify(pushSubscription))
// Send subscription to service worker to save it so we can use it later during `pushsubscriptionchange`
@ -86,7 +80,8 @@ export const ServiceWorkerProvider = ({ children }) => {
action: STORE_SUBSCRIPTION,
subscription: pushSubscription
})
logger.info('sent STORE_SUBSCRIPTION to service worker', { endpoint })
requestPersistentStorage()
// send subscription to server
const variables = {
endpoint,
@ -94,34 +89,21 @@ export const ServiceWorkerProvider = ({ children }) => {
auth: pushSubscription.keys.auth
}
await savePushSubscription({ variables })
logger.info('sent push subscription to server', { endpoint })
}
const unsubscribeFromPushNotifications = async (subscription) => {
await subscription.unsubscribe()
const { endpoint } = subscription
logger.info('unsubscribed from push notifications', { endpoint })
await deletePushSubscription({ variables: { endpoint } })
// also delete push subscription in IndexedDB so we can tell if the user disabled push subscriptions
// or we lost the push subscription due to a bug
navigator.serviceWorker.controller.postMessage({ action: DELETE_SUBSCRIPTION })
logger.info('deleted push subscription from server', { endpoint })
}
const togglePushSubscription = useCallback(async () => {
const pushSubscription = await registration.pushManager.getSubscription()
if (pushSubscription) {
return unsubscribeFromPushNotifications(pushSubscription)
return await unsubscribeFromPushNotifications(pushSubscription)
}
return subscribeToPushNotifications().then(async () => {
// request persistent storage: https://web.dev/learn/pwa/offline-data#data_persistence
const persisted = await navigator?.storage?.persisted?.()
if (!persisted && navigator?.storage?.persist) {
return navigator.storage.persist().then(persistent => {
logger.info('persistent storage:', persistent)
}).catch(logger.error)
}
})
await subscribeToPushNotifications()
})
useEffect(() => {
@ -133,37 +115,15 @@ export const ServiceWorkerProvider = ({ children }) => {
setPermission({ notification: 'Notification' in window ? window.Notification.permission : 'denied' })
if (!('serviceWorker' in navigator)) {
logger.info('device does not support service worker')
return
}
const wb = new Workbox('/sw.js', { scope: '/' })
wb.register().then(registration => {
logger.info('service worker registration successful')
setRegistration(registration)
})
}, [])
useEffect(() => {
// wait until successful registration
if (!registration) return
// setup channel between app and service worker
const channel = new MessageChannel()
navigator?.serviceWorker?.controller?.postMessage({ action: ACTION_PORT }, [channel.port2])
channel.port1.onmessage = (event) => {
if (event.data.action === RESUBSCRIBE && permission.notification === 'granted') {
return subscribeToPushNotifications()
}
}
// since (a lot of) browsers don't support the pushsubscriptionchange event,
// we sync with server manually by checking on every page reload if the push subscription changed.
// see https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f
navigator?.serviceWorker?.controller?.postMessage?.({ action: STORE_OS, os: detectOS() })
logger.info('sent STORE_OS to service worker: ', detectOS())
navigator?.serviceWorker?.controller?.postMessage?.({ action: SYNC_SUBSCRIPTION })
logger.info('sent SYNC_SUBSCRIPTION to service worker')
}, [registration, permission.notification])
const contextValue = useMemo(() => ({
registration,
support,
@ -179,6 +139,10 @@ export const ServiceWorkerProvider = ({ children }) => {
)
}
export function clearNotifications () {
return navigator.serviceWorker?.controller?.postMessage({ action: CLEAR_NOTIFICATIONS })
}
export function useServiceWorker () {
return useContext(ServiceWorkerContext)
}

View File

@ -20,6 +20,25 @@ export default function SubscribeDropdownItem ({ item: { id, meSubscription } })
},
optimistic: true
})
const unsubscribed = !subscribeItem.meSubscription
if (!unsubscribed) return
const cacheState = cache.extract()
Object.keys(cacheState)
.filter(key => key.startsWith('Item:'))
.forEach(key => {
cache.modify({
id: key,
fields: {
meSubscription: (existing, { readField }) => {
const path = readField('path')
return !path || !path.includes(id) ? existing : false
}
},
optimistic: true
})
})
}
}
)

View File

@ -1,3 +1,4 @@
import { createContext, useContext } from 'react'
import { Badge, Button, CardFooter, Dropdown } from 'react-bootstrap'
import { AccordianCard } from './accordian-item'
import TerritoryPaymentDue, { TerritoryBillingLine } from './territory-payment-due'
@ -13,6 +14,16 @@ import { useToast } from './toast'
import ActionDropdown from './action-dropdown'
import { TerritoryTransferDropdownItem } from './territory-transfer'
const SubscribeTerritoryContext = createContext({ refetchQueries: [] })
export const SubscribeTerritoryContextProvider = ({ children, value }) => (
<SubscribeTerritoryContext.Provider value={value}>
{children}
</SubscribeTerritoryContext.Provider>
)
export const useSubscribeTerritoryContext = () => useContext(SubscribeTerritoryContext)
export function TerritoryDetails ({ sub, children }) {
return (
<AccordianCard
@ -42,9 +53,10 @@ export function TerritoryInfoSkeleton ({ children, className }) {
)
}
export function TerritoryInfo ({ sub }) {
export function TerritoryInfo ({ sub, includeLink }) {
return (
<>
{includeLink && <Link href={`/~${sub.name}`}>{sub.name}</Link>}
<div className='py-2'>
<Text>{sub.desc}</Text>
</div>
@ -148,12 +160,15 @@ export default function TerritoryHeader ({ sub }) {
export function MuteSubDropdownItem ({ item, sub }) {
const toaster = useToast()
const { refetchQueries } = useSubscribeTerritoryContext()
const [toggleMuteSub] = useMutation(
gql`
mutation toggleMuteSub($name: String!) {
toggleMuteSub(name: $name)
}`, {
refetchQueries,
awaitRefetchQueries: true,
update (cache, { data: { toggleMuteSub } }) {
cache.modify({
id: `Sub:{"name":"${sub.name}"}`,
@ -212,11 +227,14 @@ export function PinSubDropdownItem ({ item: { id, position } }) {
export function ToggleSubSubscriptionDropdownItem ({ sub: { name, meSubscription } }) {
const toaster = useToast()
const { refetchQueries } = useSubscribeTerritoryContext()
const [toggleSubSubscription] = useMutation(
gql`
mutation toggleSubSubscription($name: String!) {
toggleSubSubscription(name: $name)
}`, {
refetchQueries,
awaitRefetchQueries: true,
update (cache, { data: { toggleSubSubscription } }) {
cache.modify({
id: `Sub:{"name":"${name}"}`,

View File

@ -5,8 +5,10 @@ import React, { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@apollo/client'
import MoreFooter from './more-footer'
import { useData } from './use-data'
import { useMe } from './me'
import Info from './info'
import { TerritoryInfo } from './territory-header'
import ActionDropdown from './action-dropdown'
import { TerritoryInfo, ToggleSubSubscriptionDropdownItem, MuteSubDropdownItem } from './territory-header'
// all of this nonsense is to show the stat we are sorting by first
const Revenue = ({ sub }) => (sub.optional.revenue !== null && <span>{abbrNum(sub.optional.revenue)} revenue</span>)
@ -35,16 +37,17 @@ function separate (arr, separator) {
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, separator] : [x])
}
export default function TerritoryList ({ ssrData, query, variables, destructureData, rank }) {
export default function TerritoryList ({ ssrData, query, variables, destructureData, rank, subActionDropdown, statCompsProp = STAT_COMPONENTS }) {
const { data, fetchMore } = useQuery(query, { variables })
const dat = useData(data, ssrData)
const [statComps, setStatComps] = useState(separate(STAT_COMPONENTS, Separator))
const { me } = useMe()
const [statComps, setStatComps] = useState(separate(statCompsProp, Separator))
useEffect(() => {
// shift the stat we are sorting by to the front
const comps = [...STAT_COMPONENTS]
const comps = [...statCompsProp]
setStatComps(separate([...comps.splice(STAT_POS[variables?.by || 0], 1), ...comps], Separator))
}, [variables?.by])
}, [variables?.by], statCompsProp)
const { subs, cursor } = useMemo(() => {
if (!dat) return {}
@ -77,6 +80,12 @@ export default function TerritoryList ({ ssrData, query, variables, destructureD
{sub.name}
</Link>
<Info className='d-flex'><TerritoryInfo sub={sub} /></Info>
{me && subActionDropdown && (
<ActionDropdown>
<ToggleSubSubscriptionDropdownItem sub={sub} />
<MuteSubDropdownItem sub={sub} />
</ActionDropdown>
)}
</div>
<div className={styles.other}>
{statComps.map((Comp, i) => <Comp key={i} sub={sub} />)}

View File

@ -133,12 +133,12 @@ export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, child
if (outlawed) {
return href
}
const isHashLink = href.startsWith('#')
// eslint-disable-next-line
return <Link id={props.id} target='_blank' rel={rel} href={href}>{children}</Link>
return <Link id={props.id} target={isHashLink ? undefined : '_blank'} rel={rel} href={href}>{children}</Link>
},
img: TextMediaOrLink,
embed: Embed
embed: (props) => <Embed {...props} topLevel={topLevel} />
}), [outlawed, rel, TextMediaOrLink, topLevel])
const carousel = useCarousel()

View File

@ -252,11 +252,18 @@
margin-top: .25rem;
}
.text li > :is(.twitterContainer, .nostrContainer, .wavlakeWrapper, .spotifyWrapper, .onlyImages) {
display: inline-flex;
vertical-align: top;
width: 100%;
}
.text ul,
.text ol {
margin-top: 0;
margin-bottom: 0rem;
padding-left: 2rem;
max-width: calc(100% - 1rem);
}
.text ol ol,

33
components/use-cookie.js Normal file
View File

@ -0,0 +1,33 @@
import { useCallback, useEffect, useState } from 'react'
import * as cookie from 'cookie'
import { cookieOptions } from '@/lib/auth'
export default function useCookie (name) {
const [value, setValue] = useState(null)
useEffect(() => {
const checkCookie = () => {
const oldValue = value
const newValue = cookie.parse(document.cookie)[name]
if (oldValue !== newValue) setValue(newValue)
}
checkCookie()
// there's no way to listen for cookie changes that is supported by all browsers
// so we poll to detect changes
// see https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API
const interval = setInterval(checkCookie, 1000)
return () => clearInterval(interval)
}, [value])
const set = useCallback((value, options = {}) => {
document.cookie = cookie.serialize(name, value, { ...cookieOptions(), ...options })
setValue(value)
}, [name])
const remove = useCallback(() => {
document.cookie = value.serialize(name, '', { expires: 0, maxAge: 0 })
setValue(null)
}, [name])
return [value, set, remove]
}

View File

@ -17,7 +17,7 @@ function itemToContent (item, { includeTitle = true } = {}) {
content += `\n\n${item.text}`
}
content += `\n\noriginally posted at https://stacker.news/items/${item.id}`
content += `\n\nhttps://stacker.news/items/${item.id}`
return content.trim()
}

View File

@ -1,8 +1,8 @@
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import { clearNotifications } from '@/lib/badge'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { useQuery } from '@apollo/client'
import React, { useContext } from 'react'
import { clearNotifications } from '@/components/serviceworker'
export const HasNewNotesContext = React.createContext(false)

View File

@ -1,300 +1,180 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useMe } from '@/components/me'
import { useCallback, useMemo } from 'react'
export function getDbName (userId, name) {
return `app:storage:${userId ?? ''}${name ? `:${name}` : ''}`
}
const VERSION = 2
const DEFAULT_OPTIONS = { keyPath: 'id', autoIncrement: true }
const DEFAULT_INDICES = []
const DEFAULT_VERSION = 1
export function useIndexedDB (dbName) {
const { me } = useMe()
if (!dbName) dbName = me?.id ? `app:storage:${me.id}` : 'app:storage'
function useIndexedDB ({ dbName, storeName, options = DEFAULT_OPTIONS, indices = DEFAULT_INDICES, version = DEFAULT_VERSION }) {
const [db, setDb] = useState(null)
const [error, setError] = useState(null)
const [notSupported, setNotSupported] = useState(false)
const operationQueue = useRef([])
const handleError = useCallback((error) => {
console.error('IndexedDB error:', error)
setError(error)
}, [])
const processQueue = useCallback((db) => {
if (!db) return
const set = useCallback(async (storeName, key, value) => {
const db = await _open(dbName, VERSION)
try {
// try to run a noop to see if the db is ready
db.transaction(storeName)
while (operationQueue.current.length > 0) {
const operation = operationQueue.current.shift()
// if the db is the same as the one we're processing, run the operation
// else, we'll just clear the operation queue
// XXX this is a consquence of using a ref to store the queue and should be fixed
if (dbName === db.name) {
operation(db)
}
}
} catch (error) {
handleError(error)
return await _set(db, storeName, key, value)
} finally {
db.close()
}
}, [dbName, storeName, handleError, operationQueue])
}, [dbName])
useEffect(() => {
let isMounted = true
const get = useCallback(async (storeName, key) => {
const db = await _open(dbName, VERSION)
try {
return await _get(db, storeName, key)
} finally {
db.close()
}
}, [dbName])
const deleteDb = useCallback(async () => {
return await _delete(dbName)
}, [dbName])
const open = useCallback(async () => {
return await _open(dbName, VERSION)
}, [dbName])
return useMemo(() => ({ set, get, deleteDb, open }), [set, get, deleteDb, open])
}
async function _open (dbName, version = 1) {
return await new Promise((resolve, reject) => {
if (typeof window.indexedDB === 'undefined') {
return reject(new IndexedDBOpenError('IndexedDB unavailable'))
}
const request = window.indexedDB.open(dbName, version)
request.onupgradeneeded = (event) => {
try {
const db = event.target.result
if (!db.objectStoreNames.contains('vault')) db.createObjectStore('vault')
if (db.objectStoreNames.contains('wallet_logs')) db.deleteObjectStore('wallet_logs')
} catch (error) {
reject(new IndexedDBOpenError(`upgrade failed: ${error?.message}`))
}
}
request.onerror = (event) => {
reject(new IndexedDBOpenError(request.error?.message))
}
request.onsuccess = (event) => {
const db = request.result
resolve(db)
}
})
}
async function _set (db, storeName, key, value) {
return await new Promise((resolve, reject) => {
let request
try {
if (!window.indexedDB) {
console.log('IndexedDB is not supported')
setNotSupported(true)
return
}
request = window.indexedDB.open(dbName, version)
request.onerror = (event) => {
handleError(new Error('Error opening database'))
}
request.onsuccess = (event) => {
if (isMounted) {
const database = event.target.result
database.onversionchange = () => {
database.close()
setDb(null)
handleError(new Error('Database is outdated, please reload the page'))
}
setDb(database)
processQueue(database)
}
}
request.onupgradeneeded = (event) => {
const database = event.target.result
try {
const store = database.createObjectStore(storeName, options)
indices.forEach(index => {
store.createIndex(index.name, index.keyPath, index.options)
})
} catch (error) {
handleError(new Error('Error upgrading database: ' + error.message))
}
}
request = db
.transaction(storeName, 'readwrite')
.objectStore(storeName)
.put(value, key)
} catch (error) {
handleError(new Error('Error opening database: ' + error.message))
return reject(new IndexedDBSetError(error?.message))
}
return () => {
isMounted = false
if (db) {
db.close()
}
}
}, [dbName, storeName, version, indices, options, handleError, processQueue])
const queueOperation = useCallback((operation) => {
if (notSupported) {
return Promise.reject(new Error('IndexedDB is not supported'))
}
if (error) {
return Promise.reject(new Error('Database error: ' + error.message))
request.onerror = (event) => {
reject(new IndexedDBSetError(event.target?.error?.message))
}
return new Promise((resolve, reject) => {
const wrappedOperation = (db) => {
try {
const result = operation(db)
resolve(result)
} catch (error) {
reject(error)
}
}
operationQueue.current.push(wrappedOperation)
processQueue(db)
})
}, [processQueue, db, notSupported, error])
const add = useCallback((value) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.add(value)
request.onerror = () => reject(new Error('Error adding data'))
request.onsuccess = () => resolve(request.result)
})
})
}, [queueOperation, storeName])
const get = useCallback((key) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const request = store.get(key)
request.onerror = () => reject(new Error('Error getting data'))
request.onsuccess = () => resolve(request.result ? request.result : undefined)
})
})
}, [queueOperation, storeName])
const getAll = useCallback(() => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const request = store.getAll()
request.onerror = () => reject(new Error('Error getting all data'))
request.onsuccess = () => resolve(request.result)
})
})
}, [queueOperation, storeName])
const set = useCallback((key, value) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.put(value, key)
request.onerror = () => reject(new Error('Error setting data'))
request.onsuccess = () => resolve(request.result)
})
})
}, [queueOperation, storeName])
const remove = useCallback((key) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.delete(key)
request.onerror = () => reject(new Error('Error removing data'))
request.onsuccess = () => resolve()
})
})
}, [queueOperation, storeName])
const clear = useCallback((indexName = null, query = null) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
if (!query) {
// Clear all data if no query is provided
const request = store.clear()
request.onerror = () => reject(new Error('Error clearing all data'))
request.onsuccess = () => resolve()
} else {
// Clear data based on the query
const index = indexName ? store.index(indexName) : store
const request = index.openCursor(query)
let deletedCount = 0
request.onerror = () => reject(new Error('Error clearing data based on query'))
request.onsuccess = (event) => {
const cursor = event.target.result
if (cursor) {
const deleteRequest = cursor.delete()
deleteRequest.onerror = () => reject(new Error('Error deleting item'))
deleteRequest.onsuccess = () => {
deletedCount++
cursor.continue()
}
} else {
resolve(deletedCount)
}
}
}
})
})
}, [queueOperation, storeName])
const getByIndex = useCallback((indexName, key) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const index = store.index(indexName)
const request = index.get(key)
request.onerror = () => reject(new Error('Error getting data by index'))
request.onsuccess = () => resolve(request.result)
})
})
}, [queueOperation, storeName])
const getAllByIndex = useCallback((indexName, query, direction = 'next', limit = Infinity) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const index = store.index(indexName)
const request = index.openCursor(query, direction)
const results = []
request.onerror = () => reject(new Error('Error getting data by index'))
request.onsuccess = (event) => {
const cursor = event.target.result
if (cursor && results.length < limit) {
results.push(cursor.value)
cursor.continue()
} else {
resolve(results)
}
}
})
})
}, [queueOperation, storeName])
const getPage = useCallback((page = 1, pageSize = 10, indexName = null, query = null, direction = 'next') => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const target = indexName ? store.index(indexName) : store
const request = target.openCursor(query, direction)
const results = []
let skipped = 0
let hasMore = false
request.onerror = () => reject(new Error('Error getting page'))
request.onsuccess = (event) => {
const cursor = event.target.result
if (cursor) {
if (skipped < (page - 1) * pageSize) {
skipped++
cursor.continue()
} else if (results.length < pageSize) {
results.push(cursor.value)
cursor.continue()
} else {
hasMore = true
}
}
if (hasMore || !cursor) {
const countRequest = target.count()
countRequest.onsuccess = () => {
resolve({
data: results,
total: countRequest.result,
hasMore
})
}
countRequest.onerror = () => reject(new Error('Error counting items'))
}
}
})
})
}, [queueOperation, storeName])
return { add, get, getAll, set, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported }
request.onsuccess = () => {
resolve(request.result)
}
})
}
export default useIndexedDB
async function _get (db, storeName, key) {
return await new Promise((resolve, reject) => {
let request
try {
request = db
.transaction(storeName)
.objectStore(storeName)
.get(key)
} catch (error) {
return reject(new IndexedDBGetError(error?.message))
}
request.onerror = (event) => {
reject(new IndexedDBGetError(event.target?.error?.message))
}
request.onsuccess = () => {
resolve(request.result)
}
})
}
async function _delete (dbName) {
return await new Promise((resolve, reject) => {
if (typeof window.indexedDB === 'undefined') {
return reject(new IndexedDBOpenError('IndexedDB unavailable'))
}
const request = window.indexedDB.deleteDatabase(dbName)
request.onerror = (event) => {
reject(new IndexedDBDeleteError(event.target?.error?.message))
}
request.onsuccess = () => {
resolve(request.result)
}
})
}
export async function requestPersistentStorage () {
try {
if (!('persisted' in navigator.storage) || !('persist' in navigator.storage)) {
throw new Error('persistent storage not supported')
}
const persisted = await navigator.storage.persisted()
if (!persisted) {
// browser might prompt the user to allow persistent storage
return await navigator.storage.persist()
}
} catch (err) {
console.error('failed to request persistent storage:', err)
}
}
class IndexedDBError extends Error {
constructor (message) {
super(message)
this.name = 'IndexedDBError'
}
}
class IndexedDBOpenError extends IndexedDBError {
constructor (message) {
super(message)
this.name = 'IndexedDBOpenError'
}
}
class IndexedDBSetError extends IndexedDBError {
constructor (message) {
super(message)
this.name = 'IndexedDBSetError'
}
}
class IndexedDBGetError extends IndexedDBError {
constructor (message) {
super(message)
this.name = 'IndexedDBGetError'
}
}
class IndexedDBDeleteError extends IndexedDBError {
constructor (message) {
super(message)
this.name = 'IndexedDBDeleteError'
}
}

View File

@ -1,8 +1,8 @@
import { useApolloClient, useMutation } from '@apollo/client'
import { useCallback, useMemo } from 'react'
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors'
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/client/errors'
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet'
import { INVOICE, CANCEL_INVOICE } from '@/fragments/invoice'
export default function useInvoice () {
const client = useApolloClient()

View File

@ -8,6 +8,7 @@ import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
import gql from 'graphql-tag'
import { USER_ID } from '@/lib/constants'
import { useMe } from './me'
import { useWalletRecvPrompt, WalletPromptClosed } from '@/wallets/client/hooks'
// this is intented to be compatible with upsert item mutations
// so that it can be reused for all post types and comments and we don't have
@ -22,9 +23,17 @@ export default function useItemSubmit (mutation,
const crossposter = useCrossposter()
const [upsertItem] = usePaidMutation(mutation)
const { me } = useMe()
const walletPrompt = useWalletRecvPrompt()
return useCallback(
async ({ boost, crosspost, title, options, bounty, status, ...values }, { resetForm }) => {
try {
await walletPrompt()
} catch (err) {
if (err instanceof WalletPromptClosed) return
throw err
}
if (options) {
// remove existing poll options since else they will be appended as duplicates
options = options.slice(item?.poll?.options?.length || 0).filter(o => o.trim().length > 0)
@ -93,7 +102,7 @@ export default function useItemSubmit (mutation,
}
}
}, [me, upsertItem, router, crossposter, item, sub, onSuccessfulSubmit,
navigateOnSubmit, extraValues, paidMutationOptions]
navigateOnSubmit, extraValues, paidMutationOptions, walletPrompt]
)
}

View File

@ -0,0 +1,124 @@
import preserveScroll from './preserve-scroll'
import { GET_NEW_COMMENTS } from '../fragments/comments'
import { useEffect, useState } from 'react'
import { SSR, COMMENT_DEPTH_LIMIT } from '../lib/constants'
import { useQuery, useApolloClient } from '@apollo/client'
import { commentsViewedAfterComment } from '../lib/new-comments'
import {
updateItemQuery,
updateCommentFragment,
getLatestCommentCreatedAt,
updateAncestorsCommentCount,
calculateDepth
} from '../lib/comments'
const POLL_INTERVAL = 1000 * 5 // 5 seconds
// prepares and creates a fragment for injection into the cache
// also handles side effects like updating comment counts and viewedAt timestamps
function prepareComments (item, cache, newComment) {
const existingComments = item.comments?.comments || []
// is the incoming new comment already in item's existing comments?
// if so, we don't need to update the cache
if (existingComments.some(comment => comment.id === newComment.id)) return item
// count the new comment (+1) and its children (+ncomments)
const totalNComments = newComment.ncomments + 1
const itemHierarchy = item.path.split('.')
// update all ancestors comment count, but not the item itself
const ancestors = itemHierarchy.slice(0, -1)
updateAncestorsCommentCount(cache, ancestors, totalNComments)
// update commentsViewedAt to now, and add the number of new comments
const rootId = itemHierarchy[0]
commentsViewedAfterComment(rootId, Date.now(), totalNComments)
// add a flag to the new comment to indicate it was injected
const injectedComment = { ...newComment, injected: true }
// an item can either have a comments.comments field, or not
const payload = item.comments
? {
...item,
ncomments: item.ncomments + totalNComments,
comments: {
...item.comments,
comments: [injectedComment, ...item.comments.comments]
}
}
// when the fragment doesn't have a comments field, we just update stats fields
: {
...item,
ncomments: item.ncomments + totalNComments
}
return payload
}
function cacheNewComments (cache, rootId, newComments, sort) {
for (const newComment of newComments) {
const { parentId } = newComment
const topLevel = Number(parentId) === Number(rootId)
// if the comment is a top level comment, update the item, else update the parent comment
if (topLevel) {
updateItemQuery(cache, rootId, sort, (item) => prepareComments(item, cache, newComment))
} else {
// if the comment is too deep, we can skip it
const depth = calculateDepth(newComment.path, rootId, parentId)
if (depth > COMMENT_DEPTH_LIMIT) continue
// inject the new comment into the parent comment's comments field
updateCommentFragment(cache, parentId, (parent) => prepareComments(parent, cache, newComment))
}
}
}
// useLiveComments fetches new comments under an item (rootId),
// that are newer than the latest comment createdAt (after), and injects them into the cache.
export default function useLiveComments (rootId, after, sort) {
const latestKey = `liveCommentsLatest:${rootId}`
const { cache } = useApolloClient()
const [latest, setLatest] = useState(after)
const [initialized, setInitialized] = useState(false)
useEffect(() => {
if (typeof window !== 'undefined') {
const storedLatest = window.sessionStorage.getItem(latestKey)
if (storedLatest && storedLatest > after) {
setLatest(storedLatest)
} else {
setLatest(after)
}
}
// Apollo might update the cache before the page has fully rendered, causing reads of stale cached data
// this prevents GET_NEW_COMMENTS from producing results before the page has fully rendered
setInitialized(true)
}, [after])
const { data } = useQuery(GET_NEW_COMMENTS, SSR || !initialized
? {}
: {
pollInterval: POLL_INTERVAL,
// only get comments newer than the passed latest timestamp
variables: { rootId, after: latest },
nextFetchPolicy: 'cache-and-network'
})
useEffect(() => {
if (!data?.newComments?.comments?.length) return
// directly inject new comments into the cache, preserving scroll position
// quirk: scroll is preserved even if we are not injecting new comments due to dedupe
preserveScroll(() => cacheNewComments(cache, rootId, data.newComments.comments, sort))
// update latest timestamp to the latest comment created at
// save it to session storage, to persist between client-side navigations
const newLatest = getLatestCommentCreatedAt(data.newComments.comments, latest)
setLatest(newLatest)
if (typeof window !== 'undefined') {
window.sessionStorage.setItem(latestKey, newLatest)
}
}, [data, cache, rootId, sort, latest])
}

View File

@ -2,9 +2,9 @@ import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import { useCallback, useState } from 'react'
import useQrPayment from '@/components/use-qr-payment'
import useInvoice from '@/components/use-invoice'
import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/errors'
import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/client/errors'
import { GET_PAID_ACTION } from '@/fragments/paidAction'
import { useWalletPayment } from '@/wallets/payment'
import { useWalletPayment } from '@/wallets/client/hooks'
/*
this is just like useMutation with a few changes:

View File

@ -1,9 +1,9 @@
import { useCallback } from 'react'
import Invoice from '@/components/invoice'
import { InvoiceCanceledError, InvoiceExpiredError, AnonWalletError } from '@/wallets/errors'
import { AnonWalletError, InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/client/errors'
import { useShowModal } from '@/components/modal'
import useInvoice from '@/components/use-invoice'
import { sendPayment } from '@/wallets/webln/client'
import { sendPayment as weblnSendPayment } from '@/wallets/client/protocols/webln'
export default function useQrPayment () {
const invoice = useInvoice()
@ -19,7 +19,7 @@ export default function useQrPayment () {
) => {
// if anon user and webln is available, try to pay with webln
if (typeof window.webln !== 'undefined' && (walletError instanceof AnonWalletError)) {
sendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) })
weblnSendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) })
}
return await new Promise((resolve, reject) => {
let paid

View File

@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
// observe the passed element ref and return its visibility
export default function useVisibility (elementRef, options = {}) {
// threshold is the percentage of the element that must be visible to be considered visible
// with pastElement, we consider the element not visible only when we're past it
const { threshold = 0, pastElement = false } = options
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
const element = elementRef.current
if (!element || !window.IntersectionObserver || typeof window === 'undefined') return
const observer = new window.IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
} else if (pastElement) {
setIsVisible(entry.boundingClientRect.top > 0)
} else {
setIsVisible(false)
}
}, { threshold }
)
// observe the passed element ref
observer.observe(element)
return () => observer.disconnect()
}, [threshold, elementRef, pastElement])
return isVisible
}

View File

@ -8,6 +8,7 @@ import { useState, useEffect } from 'react'
import { Form, Input, SubmitButton } from './form'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import styles from './user-header.module.css'
import navStyles from '@/styles/nav.module.css'
import { useMe } from './me'
import { NAME_MUTATION } from '@/fragments/users'
import { QRCodeSVG } from 'qrcode.react'
@ -28,9 +29,11 @@ import { hexToBech32 } from '@/lib/nostr'
import NostrIcon from '@/svgs/nostr.svg'
import GithubIcon from '@/svgs/github-fill.svg'
import TwitterIcon from '@/svgs/twitter-fill.svg'
import { UNKNOWN_LINK_REL, MEDIA_URL } from '@/lib/constants'
import { UNKNOWN_LINK_REL } from '@/lib/constants'
import ItemPopover from './item-popover'
const MEDIA_URL = process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}`
export default function UserHeader ({ user }) {
const router = useRouter()
@ -42,7 +45,7 @@ export default function UserHeader ({ user }) {
<>
<HeaderHeader user={user} />
<Nav
className={styles.nav}
className={navStyles.nav}
activeKey={activeKey}
>
<Nav.Item>

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