Compare commits

...

114 Commits

Author SHA1 Message Date
ekzyis
b4298ca866
Configurable bip39 validation (#2574) 2025-09-23 10:09:24 +02:00
ekzyis
a3657740a8 Update wallets/README.md 2025-09-23 10:03:53 +02:00
ekzyis
6517b6b085 Remove outdated genwallet script 2025-09-23 06:55:46 +02:00
github-actions[bot]
76d7184e0f
Extending awards.csv (#2569)
* 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-09-22 12:51:22 -05:00
Bryan Mutai
3b8840c731
fix(Comment): Remove scroll to top when changing comment sort order. (#2566) 2025-09-22 12:50:44 -05:00
ekzyis
256cb794a1
Open guides in new tab (#2550) 2025-09-22 12:49:40 -05:00
ekzyis
94177af702
CLINK offers (#2518) 2025-09-22 12:45:00 -05:00
pory
3df155f4f1
fixing magic code input error (#2514)
Co-authored-by: pory-gone <pory-gone@kali>
2025-09-22 12:43:41 -05:00
ekzyis
4df6cfad91
Fix comments not shown if all are from us and pending (#2505) 2025-09-22 12:41:27 -05:00
ekzyis
53c0f8c277
Fix initial value not set from NWC lud16 param (#2567) 2025-09-22 10:10:56 -05:00
ekzyis
f3ac91abee
Improve error messages on wrong permissions (#2561) 2025-09-21 17:50:10 -05:00
ekzyis
4a69e9d89c
Refactor NWC url parsing (#2563)
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-09-21 17:48:52 -05:00
ekzyis
4676fbaf60
Also check protocol name before setting address field (#2562)
* Also check protocol name before setting address field

* Add comment why we remove the domain part if lud16Domain is set

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-09-21 17:46:27 -05:00
ekzyis
a544e3952d
Use NWC lud16 parameter as initial value for lightning addresses (#2564)
* Also check protocol name before setting address field

* Add comment why we remove the domain part if lud16Domain is set

* Refactor NWC url parsing

* Use NWC's lud16 parameter as initial value

* Select lnAddr form by default if lud16 was parsed
2025-09-21 17:35:44 -05:00
ekzyis
fe160ef698 Fix comment indentation 2025-09-21 20:44:19 +02:00
ekzyis
33d8561890
Add Coinos guide (#2557) 2025-09-20 19:20:00 -05:00
ekzyis
1f4d293206
Remove lightning address domain on paste (#2559)
* Move append logic into transform fn

* Remove lightning address domain on paste

* Fix paste of other values
2025-09-20 15:35:51 -05:00
ekzyis
e164d10a03
Add Rizful guide (#2551) 2025-09-20 11:58:20 -05:00
ekzyis
018ff1af46 Remove empty line in Phoenixd guide 2025-09-20 18:45:39 +02:00
ekzyis
88473ba2ae
Remove NWC receive for Coinos (#2556) 2025-09-20 11:44:32 -05:00
ekzyis
596001576b Remove commented out code 2025-09-20 12:19:00 +02:00
ekzyis
06872a1314
Fix verification attempts not transactional (#2549)
* Run verification token check in one transaction

* Only fetch unexpired verification requests
2025-09-19 10:55:34 -05:00
ekzyis
919e71de79 Remove wallet v2 TODOs 2025-09-19 15:02:13 +02:00
ekzyis
21b4c4befb
Add comment about magic login code security (#2544) 2025-09-17 15:50:55 -05:00
ekzyis
a0dffb2f18
Poll for wallets until success (#2541)
* Add unit to poll interval constants

* Poll for wallets until success

* Stop polling on (impossible) unmount

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-09-16 12:45:42 -05:00
ekzyis
e00c04c9bb
Add unit to poll interval constants (#2539) 2025-09-16 12:44:24 -05:00
ekzyis
292a20bad5
Remove unused forceWaitForPayment (#2529) 2025-09-15 15:11:31 -05:00
ekzyis
e967455ded
Add phoenixd guide (#2494) 2025-09-15 15:10:09 -05:00
ekzyis
75de9d68e1
Delete NWC plaintext credentials that allow payments (#2367)
* Delete NWC plaintext credentials that allow payments

* Fix row in Wallet and WalletProtocol not deleted

* Fix wallet delete because CTE evaluated too late iiuc

* Mention badges

* Fix missing semicolon
2025-09-15 15:09:12 -05:00
ekzyis
5aa5956afc
Use WindowClient.navigate() instead of service worker message (#2294) 2025-09-15 15:08:08 -05:00
ekzyis
c4042fdf4b
Fix error message if no route found (#2534) 2025-09-15 15:06:25 -05:00
ekzyis
d9d0ba2903
Update wallets/README.md (#2530) 2025-09-14 03:00:29 +02:00
ekzyis
66f131b415
Add sox to footer (#2527) 2025-09-13 17:35:33 -05:00
ekzyis
51d977573b
Replace SimpleX group with Signal group (#2525)
* Replace SimpleX group with Signal group

* Remove commented-out Sphinx chat link
2025-09-13 17:35:09 -05:00
ekzyis
cab3bb3ecd
Fix missing custom expiry for phoenixd invoices (#2524) 2025-09-13 17:08:07 -05:00
ekzyis
d523218fc5
Add sox to ids of SN admins (#2519) 2025-09-12 10:37:56 -05:00
ekzyis
ca612c156a Order dependencies like npm install would 2025-09-12 06:24:57 +02:00
ekzyis
50bc84fa1f
Fix "true" passed instead of boolean for multiAuth (#2512)
* Fix "true" passed instead of boolean for multiAuth

* Fix false as default
2025-09-11 17:20:05 -05:00
ekzyis
57fa2956ac Trim whitespace in README 2025-09-10 23:42:34 +02:00
ekzyis
e1fc309d3e
Change order of tag and level in log message (#2504)
* Change order of tag and level in log message

* Decrease min-width of level
2025-09-10 14:48:55 -05:00
soxa
cda6d23bb2
Alby Hub guide (#2506)
* albyhub guide

* add more shortcuts, fix systemd code block

* add link to macOS guide for service installation

* add some formatting and better separation of concerns

* fix headings

* add references to SN

* correct order, new screenshots, in-depth steps
2025-09-10 11:13:46 -05:00
ekzyis
362eac2234
Add question about AI usage to PR template (#2507) 2025-09-10 15:21:53 +02:00
ekzyis
b5003be8cb
Fix module not found if new package added (#2503)
* Fix module not found if new package added

* remove incorrect comment

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-09-09 19:32:32 -05:00
ekzyis
25f98c7ea9
Fix cashu.me lnaddr support (#2497)
* Fix cashu.me lnaddr support

* Update migration name
2025-09-08 20:23:58 -05:00
ekzyis
f4dcc3bbaf
Only run npm ci during image build (#2498) 2025-09-08 20:23:20 -05:00
k00b
34d51e3dcd checkin copy-webpack-plugin lock 2025-09-08 17:34:48 -05:00
k00b
bd936b2403 copy webpack not dev dep 2025-09-08 12:18:51 -05:00
ekzyis
96bbe0c1c2
Use bot icon as badge (#2450)
* Use bot icon as badge

* Only show bot icon if bot
2025-09-08 12:09:17 -05:00
ekzyis
36d2179c9d
Link to wallet guide if exists (#2490)
* Link to guide if exists

* Add LNbits guide

* Update id to LNbits guide

also update guide and add id to ADMIN_ITEMS for perpetual edits

* Use button instead of link

This fixes following console warning:

> Warning: validateDOMNesting(...): <a> cannot appear as a descendant of <a>.

* Fix alignment

* Fix wrong mention of super user requirement

* Move guideUrl to wallets.json
2025-09-08 12:08:43 -05:00
soxa
f0e3516cf0
Refactor live comments and comment injection (#2462)
* Fix duplicate comment on pessimistic creation

- comment creation checks for comment's ID existence in cache
- invoice.confirmedAt included in useCanEdit deps for anons live comments

* switch to some as sets are not worth it

* only check for duplicates if a pessimistic payment method has been used

* default to empty array

* add comment about side-effects

* record ownership of an item to avoid injecting it via live comments

* trigger check only if the incoming comment is ours, cleanup

* correct conditions, correct comments, light cleanup

* fix: add defensive condition to ownership recorder, better name

* refactor: unified comment injection logic with deduplication, useCommentsView hook; revert sessionStorage-based fix

* adjust live comments naming around the codebase

* listen for hmac presence for anon edits

* always return the injected comment createdAt to bump live comments

* refactor: improve live comments hook readability

- latest comment createdAt persistence helper
- preserveScroll returns the returning value of the callback
- compact conditional logic
- refresh code comments
- refresh naming
- group constants
- reorder imports

* flat comment injection, fetch flat comments instead of the entire subtree that would've been deduplicated anyway, cleanup

* always align new comment fragment to the comments query structure

* generic useCommentsView hook

* update comment counts if live injecting into fragments without comments field

* fix: pass parentId, if a comment has a top level parent it always has the comments field

* fix: update CommentsViewAt only if we actually injected a comment into cache

* correct injectComment result usage

* pass markViewedAt to further centralize side effects, remove live from Item server typedefs

* fix: don't update counts for ancestors that are already up to date, update commentsViewedAt per batch not per comment

* port: fix coalesce, useCommentsView hook and outline changes

* update hmac field in cache on paid invoice, hmac as useCanEdit effect dependency

* comments and light cleanup, update useCommentsView

* efficient hasComments logic for live comments, establish a gql fragment

* fix: typo on topLevel evaluation

* limit extra evaluations to live comments scenarios

* update comments

* support live comments ncomments increments for anon view tracking
2025-09-07 16:04:34 -05:00
soxa
fbeba23e27
Fix comments view tracking (#2485)
* backport useCommentsView from comments refactor

* adapt live comments and creation to useCommentsView; better outline conditions

* better deps usage, remove unused props

* safer usage of root and item

* light cleanup

* cleanup: remove unused useRoot on live comments

* light cleanup and affirm purpose of each function

* fallback to createdAt if no lastCommentAt only if we actually visit the item, not by default

* fix: don't track comments, remove unused useRoot, fix signature
2025-09-06 20:03:04 -05:00
ekzyis
c572fae8ae
Fix 'LNbits send does not support tor' (#2491) 2025-09-06 05:48:06 +02:00
ekzyis
243e8eeb65
LNbits send does not support tor (#2489) 2025-09-06 05:23:07 +02:00
ekzyis
c1d51272ed
Fix CLN container build on ARM (#2486)
* Remove unnecessary CLNRest plugin dependencies install

* Fix comments in CLN docker image
2025-09-05 15:25:24 -05:00
ekzyis
9870fa5994
npx prisma format (#2488) 2025-09-05 20:02:55 +02:00
ekzyis
8b139f08da
Use precache manifest generated by webpack plugin (#2464) 2025-09-04 12:15:52 -05:00
ekzyis
de463e1f99
Send via CLNRest (#2475)
* Send via CLNRest

* Add cURL script to send payments using CLNRest

* Serve CLNRest over HTTP in dev

Upgrade to 24.11 fixed CORS issue

* Update cln.md
2025-09-04 12:15:14 -05:00
soxa
a794b59b91
navigator: also track comments visible in viewport (#2482) 2025-09-03 17:41:36 -05:00
k00b
5f697f3434 pay the pending awards that we can 2025-09-03 15:28:41 -05:00
Keyan
7726fa4640
Update awards.csv 2025-09-03 13:47:59 -05:00
soxa
83198e7467
hotfix: make meCommentsViewedAt fallback to createdAt only if there's not lastCommentAt (#2481) 2025-09-03 13:18:29 -05:00
k00b
5c9de4c433 truncate related queries 2025-09-03 12:15:22 -05:00
k00b
560be165ee limit search query length 2025-09-03 12:10:21 -05:00
ekzyis
4d4ce66404
Include localhost as SAN in CLN TLS certificates (#2479)
Before, we would get this error when trying to use cURL from the host machine:

```
$ curl https://localhost:9092/v1/list-methods --cacert docker/cln/ca.pem
curl: (60) SSL: certificate subject name 'cln' does not match target hostname 'localhost'
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the webpage mentioned above.
```

This commit fixes it by including localhost in the alternative subject names of the TLS certificate.

I followed the instructions at https://docs.corelightning.org/docs/grpc#generating-custom-certificates-optional.
2025-09-02 14:25:41 -05:00
ekzyis
0e583a179d
Update CLN createrune & certificate help (#2478)
* Update CLN createrune help

* Update CLN certificate help
2025-09-02 13:50:18 -05:00
soxa
b5af28c48b
Server-side tracking of comments view time (#2432)
* server-side comments view tracking, model structure, mutation

* full commentsViewedAt refactor, adjust comment creation and injection, adjust item navigation

* update server-side tracking only if there's a change, light cleanup

* coalesce meCommentsViewedAt to the item's createdAt, wip PoC comment outlining

* don't update cache on item visit, use useRoot hook for outlining

* add meCommentsViewedAt to root, better naming, light cleanup

* better timestamp logic and comparisons, add lastCommentAt to root item object, added TODOs

* fix: track commentsViewedAt only for root item, use topLevelId to fetch live comments only for the current item

* only track commentsViewedAt for root item, light cleanup

* light cleanup, correct live comments timestamp deps

* worker: on midnight, untrack items that were never viewed and had no comments in the last 21 days
2025-09-02 13:13:44 -05:00
github-actions[bot]
b0f01c1dd4
Extending awards.csv (#2474)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-08-29 19:54:14 -05:00
ekzyis
c43f19fd7d
Improve CSS for lists in modals (#2473) 2025-08-29 19:53:44 -05:00
Scroogey-SN
5ff6214255
fix #2392: make subs return meSubscription (#2451)
* fix #2392: make subs return meSubscription

* convert queryRawUnsafe to queryRaw

* Fix syntax error in $queryRaw

---------

Co-authored-by: ekzyis <ek@stacker.news>
2025-08-29 19:51:48 -05:00
ekzyis
a95da3d108
Improve image detection (#2472)
* Mark every non-https link as 'not image'

* Mark xcancel.com links as 'not image'
2025-08-29 19:50:12 -05:00
ekzyis
5898f09b34
Image decode error as warning (#2471) 2025-08-29 23:05:36 +02:00
soxa
4ff3dfa412
also untrack descendants of an outlined new comment; micro-optimize classList logic (#2463) 2025-08-29 13:26:46 -05:00
ekzyis
6f67aaaef9
Map lnAddr to correct wallet on save via prompt (#2456) 2025-08-29 13:25:57 -05:00
ekzyis
2d3d3ac6c9
Refactor Indicator component (#2453)
* Fix inconsistent indicator style in dropdown, offcanvas

* Fix missing offcanvas indicator

* Refactor indicators

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-08-29 13:22:05 -05:00
github-actions[bot]
a83709dfe3
Extending awards.csv (#2469)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-08-29 13:04:51 -05:00
ekzyis
6933d72e5f
Indicator fixes: inconsistent CSS and missing indicator (#2452)
* Fix inconsistent indicator style in dropdown, offcanvas

* Fix missing offcanvas indicator
2025-08-29 13:04:18 -05:00
Scroogey-SN
acd5b69087
fix #2443: add parseYoutubeStart() to convert start times (#2447)
* fix #2443: add parseYoutubeStart() to convert start times

* handle null parameter

* add spaces for lint

* switch to regex, handles more cases, simply don't touch unexpected input

* force braces for lint
2025-08-29 12:54:39 -05:00
ekzyis
cbcb8cb96c
Change layout in wallet prompt (#2454) 2025-08-29 12:15:35 -05:00
ekzyis
e3445a137d
Optimize chain sync of LND nodes (#2467) 2025-08-29 06:12:53 +02:00
ekzyis
03d3cd1e9e
Update link to CLN docs (#2466) 2025-08-29 03:35:52 +02:00
ekzyis
74361aea18
npx prisma format (#2465) 2025-08-29 02:17:26 +02:00
ekzyis
c5b50d71ac
Small refactor of code flow in multiAuthMiddleware (#2455) 2025-08-27 20:50:18 +02:00
ekzyis
7fcc1b1b78
Fix progress line not connecting if there's only one (#2449) 2025-08-27 09:05:47 -05:00
github-actions[bot]
8fa158246c
Extending awards.csv (#2448)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-08-26 17:31:25 -05:00
k00b
2a8085a995 disable graphql landing page 2025-08-26 17:22:45 -05:00
Scroogey-SN
e0bea64175
fix #2444: div for table scrollbar (#2446)
* fix #2444: div for table scrollbar

* span to div, white-space: nowrap in .css
2025-08-26 17:12:17 -05:00
k00b
47a48fe952 pay remaining rewards 2025-08-26 13:42:52 -05:00
k00b
7890f97282 pay awards 2025-08-26 10:24:19 -05:00
ekzyis
500657cb93
Merge commit from fork 2025-08-26 09:52:26 -05:00
github-actions[bot]
438dc8c121
Extending awards.csv (#2445)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-08-26 09:47:54 -05:00
Edward Kung
4998041d73
Automatically generate table of contents in text (#2213)
* automatic toc generation in markdown

* don't open hash links in new tab

* only process toc for top level items

---------

Co-authored-by: ekzyis <ek@stacker.news>
2025-08-26 09:42:01 -05:00
Scroogey-SN
68bd96a65c
pass limit in nextCursorEncoded() (#2434)
* pass limit in nextCursorEncoded()

* Also fix cursor in query for related items

---------

Co-authored-by: ekzyis <ek@stacker.news>
2025-08-26 09:40:28 -05:00
soxa
28bce7c9f7
load new replies to comments on fetch more (#2430) 2025-08-26 09:39:53 -05:00
soxa
8517e7277c
live comments: toggle (#2421)
* enhance: toggle live comments on posts, default status set by user settings

* wip: toggle via mutation, footer placement

* chat icon on footer, consistent naming, perf tweaks

* update all tabs on toggle by dispatching events, correct icon, cleanup

cleanup:
- remove useless window checks
- use skip instead of conditional options
- correct naming

* update localstorage on user setting change

* revert disableLiveComments user setting

* avoid redundant setState and usage of maybe stale state

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-08-26 09:39:09 -05:00
ekzyis
e46f4f01b2
Wallet flow (#2362)
* Wallet flow

* Prepopulate fields of complementary protocol

* Remove TODO about one mutation for save

We need to save protocols in separate mutations so we can use the wallet id returned by the first protocol save for the following protocol saves and save them all to the same wallet.

* Fix badges not updated on wallet delete

* Fix useProtocol call

* Fix lightning address save via prompt

* Don't pass share as attribute to DOM

* Fix useCallback dependency

* Progress numbers as SVGs

* Fix progress line margins

* Remove unused saveWallet arguments

* Update cache with settings response

* Fix line does not connect with number 1

* Don't reuse page nav arrows in form nav

* Fix missing SVG hover style

* Fix missing space in wallet save log message

* Reuse CSS from nav.module.css

* align buttons and their icons/text

* center form progress line

* increase top padding of form on smaller screens

* provide margin above button bar on settings form

---------

Co-authored-by: k00b <k00b@stacker.news>
2025-08-26 09:19:52 -05:00
github-actions[bot]
a620c0b0ce
Extending awards.csv (#2441)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-08-25 15:00:18 -05:00
Bryan Mutai
fd05585eaa
add devcontainer config for codespaces (#2248)
* add devcontainer config for codespaces

* fix issues in documenting changes.
2025-08-25 12:47:37 -05:00
github-actions[bot]
616c9b3440
Extending awards.csv (#2439)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-08-25 09:06:03 -05:00
k00b
19146844ca do not show deleted replies in notifications 2025-08-25 08:45:51 -05:00
pory
37a7f79625
remove free comments (#2420)
* remove free comments

* working on logic with bios and fees

* Revert "working on logic with bios and fees"

This reverts commit 0de31cbaaddd6733d8a2e8c099d0108d9f634512.

---------

Co-authored-by: pory-gone <pory-gone@users.noreply.github.com>
2025-08-25 08:28:46 -05:00
k00b
6179ffb82b add boost details 2025-08-20 07:45:25 -05:00
k00b
636122dd51 fix outlawed boosts 2025-08-20 07:40:57 -05:00
soxa
a3c5a33bc4
Fix paginated comments button not showing on comment injection/creation (#2426)
* fix: also tick nDirectComments to respect pagination logic

* update nDirectComments only for the provided parentId

* explicit type conversion
2025-08-17 11:43:19 -05:00
k00b
baa92c0ddc better horizontal comment density + fix downzap icon width 2025-08-17 01:41:58 -05:00
soxa
6a7796fc87
fix: key the navigator provider to unmount on item id changes (#2425) 2025-08-17 00:30:21 -05:00
soxa
4a83723dbf
fix: don't use route changes to clear the navigator, misclearing favicon (#2423) 2025-08-15 17:59:51 -05:00
k00b
5f4e7c8a8b pay rewards 2025-08-15 13:51:18 -05:00
soxa
610e6dcb91
live comments: favicon (#2400)
* live comments: stable navigator for new outlined comments

* favicons: FaviconProvider, handle new comments favicon via navigator

* navigator keyboard shortcuts: arrow right/escape key

* enhance: responsive fixed positioning; cleanup

enhance:
- two types of padding for desktop and mobile via CSS

cleanup:
- use appropriate <aside> for navigator
- reorder CSS

* Comments Navigator Context, new comments dot UI, refs autosorting, auto-untrack children

- Navigator Context for item pages

UI/UX
- WIP: compact comments dot UI on navbars
- long press to clear tracked refs
- auto-untrack node's children on scroll

Logic
- auto-sort comment refs via createdAt
- remove outline on untrack if called by scroll

* stable navigator dot UI positioning

* cleanup: better naming, clear structure

* re-instate favicon state updates on navigator

* CSS visibility tweaks

* scroll to start position of ref

* fix undefined navigator on other comment calls

* add explanation for early favicon clear

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-08-15 13:43:31 -05:00
soxa
df2ccd9840
live comments: comments navigation (#2377)
* live comments: stable navigator for new outlined comments

* navigator keyboard shortcuts: arrow right/escape key

* enhance: responsive fixed positioning; cleanup

enhance:
- two types of padding for desktop and mobile via CSS

cleanup:
- use appropriate <aside> for navigator
- reorder CSS

* Comments Navigator Context, new comments dot UI, refs autosorting, auto-untrack children

- Navigator Context for item pages

UI/UX
- WIP: compact comments dot UI on navbars
- long press to clear tracked refs
- auto-untrack node's children on scroll

Logic
- auto-sort comment refs via createdAt
- remove outline on untrack if called by scroll

* stable navigator dot UI positioning

* cleanup: better naming, clear structure

* CSS visibility tweaks

* scroll to start position of ref

* fix undefined navigator on other comment calls

* remove pulse animation
2025-08-15 13:22:06 -05:00
soxa
0394a5bdc2
fix: prevent pull-to-refresh from triggering on unexpected scroll positions (#2419) 2025-08-13 13:31:49 -05:00
github-actions[bot]
3feb4f055f
Extending awards.csv (#2418)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-08-12 18:36:42 -05:00
Edward Kung
97cbfec38e
Order carousel as images appear in items / markdown (#2239)
* carousel sort in deterministic order

* imgIndex 0 for ItemEmbed

* fix order for item-full

* fix indexing in ItemEmbed

* Revert "fix indexing in ItemEmbed"

This reverts commit f7863af30a1a02b189bfc79237606851c4da1abf.

* Revert "fix order for item-full"

This reverts commit 489e25ea82056bd83a818e581eb2bbfcf947e401.

* Revert "imgIndex 0 for ItemEmbed"

This reverts commit cd5fff1bae151e44db717f2a2173f673793bc6d0.

* carousel preserves ordering rendered on screen

* reorder carousel when sort changes

* fix cursor detected bugs

* register media to carousel before image load, confirm afterwards

* Remove unnecessary ref from dependencies

* Add missing dependencies

* Add missing dependencies

* Check if src was found in Carousel

---------

Co-authored-by: ekzyis <ek@stacker.news>
2025-08-12 18:30:46 -05:00
github-actions[bot]
af77985b38
Extending awards.csv (#2417)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-08-12 17:42:27 -05:00
Abhi Shandilya
6b01a41705
fix: include subs in grace status in suggestions (#2405)
* include subs in grace status in suggestions

* make myself a contributor

* Refactor subSuggestions with Prisma.sql

---------

Co-authored-by: ekzyis <ek@stacker.news>
2025-08-12 17:17:54 -05:00
188 changed files with 4200 additions and 2034 deletions

View File

@ -0,0 +1,21 @@
{
"name": "sndev",
"hostRequirements": {
"cpus": 4,
"memory": "16gb",
"storage": "32gb"
},
"customizations": {
"vscode": {
"extensions": [
"ms-azuretools.vscode-containers"
]
}
},
"containerEnv": {
"CPU_SHARES_IMPORTANT": "1024",
"CPU_SHARES_MODERATE": "512",
"CPU_SHARES_LOW": "128"
},
"postAttachCommand": "./scripts/setup-codespaces.sh && source .env.local && ./sndev start"
}

View File

@ -0,0 +1,22 @@
{
"name": "sndev MINIMAL",
"hostRequirements": {
"cpus": 4,
"memory": "16gb",
"storage": "32gb"
},
"customizations": {
"vscode": {
"extensions": [
"ms-azuretools.vscode-containers"
]
}
},
"containerEnv": {
"CPU_SHARES_IMPORTANT": "1024",
"CPU_SHARES_MODERATE": "512",
"CPU_SHARES_LOW": "128",
"COMPOSE_PROFILES": "minimal"
},
"postAttachCommand": "./scripts/setup-codespaces.sh && source .env.local && ./sndev start"
}

View File

@ -107,10 +107,10 @@ DB_WORKER_CONNECTION_LIMIT=2
DB_TRANSACTION_TIMEOUT=5000
# polling intervals
NEXT_PUBLIC_FAST_POLL_INTERVAL=1000
NEXT_PUBLIC_NORMAL_POLL_INTERVAL=30000
NEXT_PUBLIC_LONG_POLL_INTERVAL=60000
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
NEXT_PUBLIC_FAST_POLL_INTERVAL_MS=1000
NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS=30000
NEXT_PUBLIC_LONG_POLL_INTERVAL_MS=60000
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL_MS=300000
# containers can't use localhost, so we need to use the container name
IMGPROXY_URL_DOCKER=http://imgproxy:8080
@ -140,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_PUBKEY=03dc0de8fbe29ef3d26554c615adfd17aaca959403c4e9ecebaac4b83978d86342
SN_LND_ADDR=bcrt1qu6g49vrl8n4ay99hr04wefkfy2e8g0z4nc0sjw
SN_LND_PUBKEY=034fcbe80658a2b0e32d416ca34c91cf359f7010b3529582fce6b1deddfadb2ba6
SN_LND_ADDR=bcrt1qq6m9009pl3nkhku5905hl8nktq8zggfa75tfcy
# sn_lndk stuff
SN_LNDK_GRPC_PORT=10012
@ -149,8 +149,8 @@ SN_LNDK_GRPC_PORT=10012
LND_REST_PORT=8081
LND_GRPC_PORT=10010
# docker exec -u lnd lnd lncli newaddress p2wkh --unused
LND_ADDR=bcrt1qfqau4ug9e6rtrvxrgclg58e0r93wshucumm9vu
LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35
LND_ADDR=bcrt1qvqjlqw3zmpqh9cv2jr20n2qn7u4n6k7f74yw4p
LND_PUBKEY=03eff113993bd7dbb43f5e923d5569ecefa43e3cc7ce5b5634a4b6854d4b287dfa
# cln container stuff
CLN_REST_PORT=9092
@ -167,8 +167,8 @@ ECLAIR_PUBKEY="02268c74cc07837041131474881f97d497706b89a29f939555da6d094b65bd5af
ROUTER_LND_REST_PORT=8082
ROUTER_LND_GRPC_PORT=10011
# docker exec -u lnd router_lnd lncli newaddress p2wkh --unused
ROUTER_LND_ADDR=bcrt1qfkmwfpwgn6wt0dd36s79x04swz8vleyafsdpdr
ROUTER_LND_PUBKEY=02750991fbf62e57631888bc469fae69c5e658bd1d245d8ab95ed883517caa33c3
ROUTER_LND_ADDR=bcrt1qx7envhw8q48huaspl2am9nack3ql0fnyvy7ygy
ROUTER_LND_PUBKEY=02862c14ccfc566234fd2ccb61f6f134ba4fa06cd13272d47a18bf4579c2df3cf4
LNCLI_NETWORK=regtest

View File

@ -11,10 +11,10 @@ NEXT_PUBLIC_MEDIA_DOMAIN=m.stacker.news
PUBLIC_URL=https://stacker.news
SELF_URL=http://127.0.0.1:8080
grpc_proxy=http://127.0.0.1:7050
NEXT_PUBLIC_FAST_POLL_INTERVAL=1000
NEXT_PUBLIC_NORMAL_POLL_INTERVAL=30000
NEXT_PUBLIC_LONG_POLL_INTERVAL=60000
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
NEXT_PUBLIC_FAST_POLL_INTERVAL_MS=1000
NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS=30000
NEXT_PUBLIC_LONG_POLL_INTERVAL_MS=60000
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL_MS=300000
NEXT_PUBLIC_URL=https://stacker.news
TOR_PROXY=http://127.0.0.1:7050/
PRISMA_SLOW_LOGS_MS=50

View File

@ -21,3 +21,6 @@ _For example, a change is not backward compatible if you removed a GraphQL field
**Did you introduce any new environment variables? If so, call them out explicitly here:**
**Did you use AI for this? If so, how much did it assist you?**

View File

@ -16,4 +16,6 @@ EXPOSE 3000
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps --loglevel verbose
CMD ["sh","-c","npm install --loglevel verbose --legacy-peer-deps && npx prisma migrate dev && npm run dev"]
# run npm ci again because we're mounting node_modules in local dev
CMD ["sh","-c","npm ci --legacy-peer-deps --loglevel verbose && npx prisma migrate dev && npm run dev"]

View File

@ -36,6 +36,34 @@ Go to [localhost:3000](http://localhost:3000).
<br>
### GitHub Codespaces
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/stackernews/stacker.news)
You can run Stacker News on Github Codespaces
#### Setup
1. Open the repository on GitHub and click the **"Code"** button
2. Select the Codespaces tab and create a new codespace.
- You can also configure your codespace to run select services based on `COMPOSE_PROFILES` as well as in a different region and machine type by clicking "..." and selecting "New with options...". Check [Modifying services](#modifying-services) for more information on `COMPOSE_PROFILES`
3. Wait for the environment to set up (this may take several minutes the first time)
4. Once ready, you'll see a terminal with the environment initialized
#### Usage
After the codespace is created, the development environment will be automatically set up and services started.
Access your running application at the URL shown in the forwarded ports panel (typically `https://your-codespace-name-3000.app.github.dev`).
#### Port Configuration
⚠️ **Important**: For various internal services and external access to work properly, you must set forwarded ports to **Public** in the Ports tab:
1. In your codespace, look for the "PORTS" tab in the bottom panel
2. Click the lock icon to change visibility from "Private" to "Public"
<br>
## Usage
Start the development environment
@ -170,6 +198,7 @@ To add/remove DNS records you can now use `./sndev domains dns`. More on this [h
# Table of Contents
- [Getting started](#getting-started)
- [Installation](#installation)
- [GitHub Codespaces](#github-codespaces)
- [Usage](#usage)
- [Modifying services](#modifying-services)
- [Running specific services](#running-specific-services)

View File

@ -60,17 +60,11 @@ export async function estimateRouteFee ({ lnd, destination, tokens, mtokens, req
timeout
}, (err, res) => {
if (err) {
if (res?.failure_reason) {
reject(new Error(`Unable to estimate route: ${res.failure_reason}`))
} else {
reject(err)
}
return
return reject(err)
}
if (res.routing_fee_msat < 0 || res.time_lock_delay <= 0) {
reject(new Error('Unable to estimate route, excessive values: ' + JSON.stringify(res)))
return
if (res.failure_reason !== 'FAILURE_REASON_NONE' || res.routing_fee_msat < 0 || res.time_lock_delay <= 0) {
return reject(new Error(`Unable to estimate route: ${res.failure_reason}`))
}
resolve({

View File

@ -49,10 +49,12 @@ export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio },
FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
+ ${satsToMsats(boost)}::INTEGER as cost`
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon,
// cost must be greater than user's balance, and user has not disabled freebies
const freebie = (parentId || bio) && cost <= baseCost && !!me &&
me?.msats < cost && !me?.disableFreebies && me?.mcredits < cost
// freebies currently only apply to bios (comments disabled). To keep togglable behavior,
// respect user's disableFreebies preference for bios as well.
// conditions: is a bio, cost <= baseCost, user logged in, insufficient balance & credits,
// and user has not disabled freebies
const freebie = bio && cost <= baseCost && !!me &&
me?.msats < cost && me?.mcredits < cost && !me?.disableFreebies
return freebie ? BigInt(0) : BigInt(cost)
}

View File

@ -1,5 +1,5 @@
import { ensureProtocol, removeTracking, stripTrailingSlash } from '@/lib/url'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { decodeCursor, nextCursorEncoded } from '@/lib/cursor'
import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import { ruleSet as publicationDateRuleSet } from '@/lib/timedate-scraper'
import domino from 'domino'
@ -54,7 +54,8 @@ function commentsOrderByClause (me, models, sort) {
async function comments (me, models, item, sort, cursor) {
const orderBy = commentsOrderByClause(me, models, sort)
if (item.nDirectComments === 0) {
// if we're logged in, there might be pending comments from us we want to show but weren't counted
if (!me && item.nDirectComments === 0) {
return {
comments: [],
cursor: null
@ -67,7 +68,7 @@ async function comments (me, models, item, sort, cursor) {
// XXX what a mess
let comments
if (me) {
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id}) AND "Item".created_at <= '${decodedCursor.time.toISOString()}'::TIMESTAMP(3) `
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id}) AND ("Item"."parentId" <> $1 OR "Item".created_at <= '${decodedCursor.time.toISOString()}'::TIMESTAMP(3)) `
if (item.ncomments > FULL_COMMENTS_THRESHOLD) {
const [{ item_comments_zaprank_with_me_limited: limitedComments }] = await models.$queryRawUnsafe(
'SELECT item_comments_zaprank_with_me_limited($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8, $9)',
@ -80,7 +81,7 @@ async function comments (me, models, item, sort, cursor) {
comments = fullComments
}
} else {
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID') AND "Item".created_at <= '${decodedCursor.time.toISOString()}'::TIMESTAMP(3) `
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID') AND ("Item"."parentId" <> $1 OR "Item".created_at <= '${decodedCursor.time.toISOString()}'::TIMESTAMP(3)) `
if (item.ncomments > FULL_COMMENTS_THRESHOLD) {
const [{ item_comments_limited: limitedComments }] = await models.$queryRawUnsafe(
'SELECT item_comments_limited($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5::INTEGER, $6, $7)',
@ -129,6 +130,7 @@ export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { m
'"Item"."parentId" IS NULL',
'"Item".bio = false',
'"Item".boost > 0',
await filterClause(me, models),
activeOrMine(),
subClause(sub, 1, 'Item', me, showNsfw),
muteClause(me))}
@ -179,7 +181,8 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark",
"ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward",
to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL)
|| jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub
|| jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub,
"CommentsViewAt"."last_viewed_at" as "meCommentsViewedAt"
FROM (
${query}
) "Item"
@ -191,6 +194,7 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}
LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id}
LEFT JOIN "CommentsViewAt" ON "CommentsViewAt"."itemId" = "Item".id AND "CommentsViewAt"."userId" = ${me.id}
LEFT JOIN LATERAL (
SELECT "itemId",
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND "InvoiceForward".id IS NOT NULL AND (act = 'FEE' OR act = 'TIP')) AS "meMsats",
@ -361,7 +365,7 @@ export default {
return count
},
items: async (parent, { sub, sort, type, cursor, name, when, from, to, by, limit = LIMIT }, { me, models }) => {
items: async (parent, { sub, sort, type, cursor, name, when, from, to, by, limit }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
let items, user, pins, subFull, table, ad
@ -582,7 +586,7 @@ export default {
break
}
return {
cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null,
cursor: items.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
items,
pins,
ad
@ -740,7 +744,7 @@ export default {
subMaxBoost: subAgg?._max.boost || 0
}
},
newComments: async (parent, { rootId, after }, { models, me }) => {
newComments: async (parent, { itemId, after }, { models, me }) => {
const comments = await itemQueryWithMeta({
me,
models,
@ -754,7 +758,7 @@ export default {
'"Item"."created_at" > $2'
)}
ORDER BY "Item"."created_at" ASC`
}, Number(rootId), after)
}, Number(itemId), after)
return { comments }
}
@ -1072,6 +1076,21 @@ export default {
])
return result
},
updateCommentsViewAt: async (parent, { id, meCommentsViewedAt }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const result = await models.commentsViewAt.upsert({
where: {
userId_itemId: { userId: Number(me.id), itemId: Number(id) }
},
update: { lastViewedAt: new Date(meCommentsViewedAt) },
create: { userId: Number(me.id), itemId: Number(id), lastViewedAt: new Date(meCommentsViewedAt) }
})
return result.lastViewedAt
}
},
ItemAct: {
@ -1222,7 +1241,8 @@ export default {
return item.comments
}
if (item.ncomments === 0) {
// if we're logged in, there might be pending comments from us we want to show but weren't counted
if (!me && item.ncomments === 0) {
return {
comments: [],
cursor: null

View File

@ -167,6 +167,7 @@ export default {
) as "Item"
${whereClause(
'"Item".created_at < $2',
'"Item"."deletedAt" IS NULL',
await filterClause(me, models),
muteClause(me),
activeOrMine(me))}

View File

@ -2,6 +2,7 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { whenToFrom } from '@/lib/time'
import { getItem, itemQueryWithMeta, SELECT } from './item'
import { parse } from 'tldts'
import { searchSchema, validateSchema } from '@/lib/validate'
function queryParts (q) {
const regex = /"([^"]*)"/gm
@ -24,7 +25,7 @@ function queryParts (q) {
export default {
Query: {
related: async (parent, { title, id, cursor, limit = LIMIT, minMatch }, { me, models, search }) => {
related: async (parent, { title, id, cursor, limit, minMatch }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor)
if (!id && (!title || title.trim().split(/\s+/).length < 1)) {
@ -80,7 +81,7 @@ export default {
{
neural: {
title_embedding: {
query_text: qtext,
query_text: qtitle,
model_id: process.env.OPENSEARCH_MODEL_ID,
k: decodedCursor.offset + LIMIT
}
@ -89,7 +90,7 @@ export default {
{
neural: {
text_embedding: {
query_text: qtitle,
query_text: qtext.slice(0, 100),
model_id: process.env.OPENSEARCH_MODEL_ID,
k: decodedCursor.offset + LIMIT
}
@ -168,11 +169,12 @@ export default {
})
return {
cursor: items.length === (limit || LIMIT) ? nextCursorEncoded(decodedCursor) : null,
cursor: items.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
items
}
},
search: async (parent, { q, cursor, sort, what, when, from: whenFrom, to: whenTo }, { me, models, search }) => {
await validateSchema(searchSchema, { q })
const decodedCursor = decodeCursor(cursor)
let sitems = null

View File

@ -6,6 +6,7 @@ import { notifyTerritoryTransfer } from '@/lib/webPush'
import performPaidAction from '../paidAction'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { uploadIdsFromText } from './upload'
import { Prisma } from '@prisma/client'
export async function getSub (parent, { name }, { models, me }) {
if (!name) return null
@ -36,24 +37,15 @@ export async function getSub (parent, { name }, { models, me }) {
export default {
Query: {
sub: getSub,
subSuggestions: async (parent, { q, limit = 5 }, { models }) => {
subSuggestions: async (parent, { q, limit }, { models }) => {
let subs = []
if (q) {
subs = await models.$queryRaw`
subs = await models.$queryRaw`
SELECT name
FROM "Sub"
WHERE status = 'ACTIVE'
AND SIMILARITY(name, ${q}) > 0.1
ORDER BY SIMILARITY(name, ${q}) DESC
WHERE status IN ('ACTIVE', 'GRACE')
${q ? Prisma.sql`AND SIMILARITY(name, ${q}) > 0.1` : Prisma.empty}
${q ? Prisma.sql`ORDER BY SIMILARITY(name, ${q}) DESC` : Prisma.sql`ORDER BY name ASC`}
LIMIT ${limit}`
} else {
subs = await models.$queryRaw`
SELECT name
FROM "Sub"
WHERE status = 'ACTIVE'
ORDER BY name ASC
LIMIT ${limit}`
}
return subs
},
@ -62,14 +54,15 @@ export default {
const currentUser = await models.user.findUnique({ where: { id: me.id } })
const showNsfw = currentUser ? currentUser.nsfwMode : false
return await models.$queryRawUnsafe(`
SELECT "Sub".*, "Sub".created_at as "createdAt", COALESCE(json_agg("MuteSub".*) FILTER (WHERE "MuteSub"."userId" IS NOT NULL), '[]') AS "MuteSub"
return await models.$queryRaw`
SELECT "Sub".*, "Sub".created_at as "createdAt", ss."userId" IS NOT NULL as "meSubscription", COALESCE(json_agg("MuteSub".*) FILTER (WHERE "MuteSub"."userId" IS NOT NULL), '[]') AS "MuteSub"
FROM "Sub"
LEFT JOIN "SubSubscription" ss ON "Sub".name = ss."subName" AND ss."userId" = ${me.id}::INTEGER
LEFT JOIN "MuteSub" ON "Sub".name = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}::INTEGER
WHERE status <> 'STOPPED' ${showNsfw ? '' : 'AND "Sub"."nsfw" = FALSE'}
GROUP BY "Sub".name, "MuteSub"."userId"
WHERE status <> 'STOPPED' ${showNsfw ? Prisma.empty : Prisma.sql`AND "Sub"."nsfw" = FALSE`}
GROUP BY "Sub".name, ss."userId", "MuteSub"."userId"
ORDER BY "Sub".name ASC
`)
`
}
return await models.sub.findMany({
@ -96,7 +89,7 @@ export default {
return latest?.createdAt
},
topSubs: async (parent, { cursor, when, by, from, to, limit = LIMIT }, { models, me }) => {
topSubs: async (parent, { cursor, when, by, from, to, limit }, { models, me }) => {
const decodedCursor = decodeCursor(cursor)
const range = whenRange(when, from, to || decodeCursor.time)
@ -128,7 +121,7 @@ export default {
subs
}
},
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models, me }) => {
userSubs: async (_parent, { name, cursor, when, by, from, to, limit }, { models, me }) => {
if (!name) {
throw new GqlInputError('must supply user name')
}

View File

@ -55,7 +55,7 @@ async function authMethods (user, args, { models, me }) {
}
}
export async function topUsers (parent, { cursor, when, by, from, to, limit = LIMIT }, { models, me }) {
export async function topUsers (parent, { cursor, when, by, from, to, limit }, { models, me }) {
const decodedCursor = decodeCursor(cursor)
const range = whenRange(when, from, to || decodeCursor.time)
@ -213,7 +213,7 @@ export default {
users
}
},
userSuggestions: async (parent, { q, limit = 5 }, { models }) => {
userSuggestions: async (parent, { q, limit }, { models }) => {
let users = []
if (q) {
users = await models.$queryRaw`
@ -287,6 +287,7 @@ export default {
'r.created_at > $2',
'r.created_at >= "ThreadSubscription".created_at',
'r."userId" <> $1',
'"Item"."deletedAt" IS NULL',
activeOrMine(me),
await filterClause(me, models),
muteClause(me),
@ -604,7 +605,7 @@ export default {
SELECT *
FROM users
WHERE (id > ${RESERVED_MAX_USER_ID} OR id IN (${USER_ID.anon}, ${USER_ID.delete}))
AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}`
AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit)}`
},
userStatsActions: async (parent, { when, from, to }, { me, models }) => {
const range = whenRange(when, from, to)

View File

@ -1,17 +1,18 @@
import { gql } from 'graphql-tag'
import { LIMIT } from '@/lib/cursor'
export default gql`
extend type Query {
items(sub: String, sort: String, type: String, cursor: String, name: String, when: String, from: String, to: String, by: String, limit: Limit): Items
items(sub: String, sort: String, type: String, cursor: String, name: String, when: String, from: String, to: String, by: String, limit: Limit! = ${LIMIT}): Items
item(id: ID!): Item
pageTitleAndUnshorted(url: String!): TitleUnshorted
dupes(url: String!): [Item!]
related(cursor: String, title: String, id: ID, minMatch: String, limit: Limit): Items
related(cursor: String, title: String, id: ID, minMatch: String, limit: Limit! = ${LIMIT}): Items
search(q: String, sub: String, cursor: String, what: String, sort: String, when: String, from: String, to: String): Items
auctionPosition(sub: String, id: ID, boost: Int): Int!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int!
newComments(rootId: ID, after: Date): Comments!
newComments(itemId: ID, after: Date): Comments!
}
type BoostPositions {
@ -64,6 +65,7 @@ export default gql`
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
pollVote(id: ID!): PollVotePaidAction!
toggleOutlaw(id: ID!): Item!
updateCommentsViewAt(id: ID!, meCommentsViewedAt: Date!): Date
}
type PollVoteResult {
@ -149,7 +151,6 @@ export default gql`
ncomments: Int!
nDirectComments: Int!
comments(sort: String, cursor: String): Comments!
injected: Boolean!
path: String
position: Int
prior: Int
@ -172,6 +173,7 @@ export default gql`
apiKey: Boolean
invoice: Invoice
cost: Int!
meCommentsViewedAt: Date
}
input ItemForwardInput {

View File

@ -1,14 +1,15 @@
import { gql } from 'graphql-tag'
import { LIMIT } from '@/lib/cursor'
export default gql`
extend type Query {
sub(name: String): Sub
subLatestPost(name: String!): String
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
topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit! = ${LIMIT}): Subs
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit! = ${LIMIT}): Subs
mySubscribedSubs(cursor: String): Subs
subSuggestions(q: String!, limit: Limit): [Sub!]!
subSuggestions(q: String!, limit: Limit! = 5): [Sub!]!
}
type Subs {

View File

@ -1,4 +1,5 @@
import { gql } from 'graphql-tag'
import { LIMIT } from '@/lib/cursor'
export default gql`
extend type Query {
@ -7,10 +8,10 @@ export default gql`
user(id: ID, name: String): User
users: [User!]
nameAvailable(name: String!): Boolean!
topUsers(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): UsersNullable!
topUsers(cursor: String, when: String, from: String, to: String, by: String, limit: Limit! = ${LIMIT}): UsersNullable!
topCowboys(cursor: String): UsersNullable!
searchUsers(q: String!, limit: Limit, similarity: Float): [User!]!
userSuggestions(q: String, limit: Limit): [User!]!
searchUsers(q: String!, limit: Limit! = 5, similarity: Float): [User!]!
userSuggestions(q: String, limit: Limit! = 5): [User!]!
hasNewNotes: Boolean!
mySubscribedUsers(cursor: String): Users!
myMutedUsers(cursor: String): Users!

View File

@ -55,6 +55,12 @@ const typeDefs = gql`
apiKey: VaultEntryInput!
): WalletSendBlink!
upsertWalletSendCLNRest(
${shared},
socket: String!,
rune: VaultEntryInput!,
): WalletSendCLNRest!
upsertWalletRecvBlink(
${shared},
currency: String!,
@ -102,6 +108,11 @@ const typeDefs = gql`
${shared}
): WalletSendWebLN!
upsertWalletRecvClink(
${shared},
noffer: String!
): WalletRecvClink!
# tests
testWalletRecvNWC(
url: String!
@ -138,9 +149,12 @@ const typeDefs = gql`
apiKey: String!
): Boolean!
testWalletRecvClink(
noffer: String!
): Boolean!
# delete
removeWallet(id: ID!): Boolean
removeWalletProtocol(id: ID!): Boolean
deleteWallet(id: ID!): Boolean
# crypto
updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean
@ -149,7 +163,7 @@ const typeDefs = gql`
disablePassphraseExport: Boolean
# settings
setWalletSettings(settings: WalletSettingsInput!): Boolean
setWalletSettings(settings: WalletSettingsInput!): WalletSettings!
setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean
# logs
@ -213,6 +227,7 @@ const typeDefs = gql`
| WalletSendBlink
| WalletSendWebLN
| WalletSendLNC
| WalletSendCLNRest
| WalletRecvNWC
| WalletRecvLNbits
| WalletRecvPhoenixd
@ -220,6 +235,7 @@ const typeDefs = gql`
| WalletRecvLightningAddress
| WalletRecvCLNRest
| WalletRecvLNDGRPC
| WalletRecvClink
type WalletSettings {
receiveCreditsBelowSats: Int!
@ -274,6 +290,12 @@ const typeDefs = gql`
serverHost: VaultEntry!
}
type WalletSendCLNRest {
id: ID!
socket: String!
rune: VaultEntry!
}
type WalletRecvNWC {
id: ID!
url: String!
@ -316,6 +338,11 @@ const typeDefs = gql`
cert: String
}
type WalletRecvClink {
id: ID!
noffer: String!
}
input AutowithdrawSettings {
autoWithdrawThreshold: Int!
autoWithdrawMaxFeePercent: Float!

View File

@ -256,5 +256,18 @@ 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,???
pory-gone,pr,#2381,#2370,good-first-issue,,,,20k,pory@porygone.xyz,2025-08-26
pory-gone,pr,#2413,#2361,easy,,,,100k,pory@porygone.xyz,2025-08-26
abhiShandy,pr,#2405,#2399,good-first-issue,,,,20k,abhishandy@stacker.news,2025-08-15
ed-kung,pr,#2239,#2197,medium,,,,250k,simplestacker@getalby.com,2025-08-15
pory-gone,pr,#2420,#2278,easy,,,,100k,pory@porygone.xyz,2025-08-26
brymut,pr,#2248,#2209,easy,,,,100k,brymut@stacker.news,2025-08-26
ed-kung,pr,#2213,#2208,medium,,,,250k,simplestacker@getalby.com,2025-08-26
ed-kung,issue,#2213,#2208,medium,,,,25k,simplestacker@getalby.com,2025-08-26
Scroogey-SN,pr,#2434,,good-first-issue,,,,20k,Scroogey@coinos.io,2025-08-26
Scroogey-SN,pr,#2446,#2444,good-first-issue,,1,,18k,Scroogey@coinos.io,2025-09-03
Scroogey-SN,pr,#2447,#2443,good-first-issue,,,,20k,Scroogey@coinos.io,2025-09-03
Scroogey-SN,pr,#2451,#2392,good-first-issue,,,,20k,Scroogey@coinos.io,2025-09-03
ed-kung,issue,#2432,#2427,medium,,,,25k,simplestacker@getalby.com,???
pory-gone,pr,#2514,#2511,good-first-issue,,1,,18k,pory@porygone.xyz,???
brymut,pr,#2566,#2555,good-first-issue,,,,20k,brymut@stacker.news,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
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 ??? 2025-08-26
260 pory-gone pr #2413 #2361 easy 100k pory@porygone.xyz ??? 2025-08-26
261 abhiShandy pr #2405 #2399 good-first-issue 20k abhishandy@stacker.news 2025-08-15
262 ed-kung pr #2239 #2197 medium 250k simplestacker@getalby.com 2025-08-15
263 pory-gone pr #2420 #2278 easy 100k pory@porygone.xyz 2025-08-26
264 brymut pr #2248 #2209 easy 100k brymut@stacker.news 2025-08-26
265 ed-kung pr #2213 #2208 medium 250k simplestacker@getalby.com 2025-08-26
266 ed-kung issue #2213 #2208 medium 25k simplestacker@getalby.com 2025-08-26
267 Scroogey-SN pr #2434 good-first-issue 20k Scroogey@coinos.io 2025-08-26
268 Scroogey-SN pr #2446 #2444 good-first-issue 1 18k Scroogey@coinos.io 2025-09-03
269 Scroogey-SN pr #2447 #2443 good-first-issue 20k Scroogey@coinos.io 2025-09-03
270 Scroogey-SN pr #2451 #2392 good-first-issue 20k Scroogey@coinos.io 2025-09-03
271 ed-kung issue #2432 #2427 medium 25k simplestacker@getalby.com ???
272 pory-gone pr #2514 #2511 good-first-issue 1 18k pory@porygone.xyz ???
273 brymut pr #2566 #2555 good-first-issue 20k brymut@stacker.news ???

View File

@ -32,7 +32,7 @@ const FormStatus = {
export function BoostHelp () {
return (
<ol style={{ lineHeight: 1.25 }}>
<ol>
<li>Boost ranks items higher based on the amount</li>
<li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li>
<li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li>
@ -45,6 +45,7 @@ export function BoostHelp () {
</li>
<li>boost can take a few minutes to show higher ranking in feed</li>
<li>100% of boost goes to the territory founder and top stackers as rewards</li>
<li>If a boost is outlawed, it will only be visible to stackers in wild west mode</li>
</ol>
)
}

View File

@ -5,11 +5,12 @@ import CowboyHatIcon from '@/svgs/cowboy.svg'
import AnonIcon from '@/svgs/spy-fill.svg'
import GunIcon from '@/svgs/revolver.svg'
import HorseIcon from '@/svgs/horse.svg'
import BotIcon from '@/svgs/robot-2-fill.svg'
import { numWithUnits } from '@/lib/format'
import { USER_ID } from '@/lib/constants'
import classNames from 'classnames'
export default function Badges ({ user, badge, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) {
export default function Badges ({ user, badge, bot, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) {
if (!user || Number(user.id) === USER_ID.ad) return null
if (Number(user.id) === USER_ID.anon) {
return (
@ -19,7 +20,7 @@ export default function Badges ({ user, badge, className = 'ms-1', badgeClassNam
)
}
const badges = []
let badges = []
const streak = user.optional.streak
if (streak !== null) {
@ -46,6 +47,13 @@ export default function Badges ({ user, badge, className = 'ms-1', badgeClassNam
})
}
if (bot) {
badges = [{
icon: BotIcon,
overlayText: 'posted as bot'
}]
}
if (badges.length === 0) return null
return (

View File

@ -1,6 +1,6 @@
import { createContext, useContext, useMemo } from 'react'
import { useQuery } from '@apollo/client'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { NORMAL_POLL_INTERVAL_MS, SSR } from '@/lib/constants'
import { BLOCK_HEIGHT } from '@/fragments/blockHeight'
import { datePivot } from '@/lib/time'
@ -18,7 +18,7 @@ export const BlockHeightProvider = ({ blockHeight, children }) => {
...(SSR
? {}
: {
pollInterval: NORMAL_POLL_INTERVAL,
pollInterval: NORMAL_POLL_INTERVAL_MS,
nextFetchPolicy: 'cache-and-network'
})
})

View File

@ -56,8 +56,9 @@ function useArrowKeys ({ moveLeft, moveRight }) {
function Carousel ({ close, mediaArr, src, setOptions }) {
const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src))
const [currentSrc, canGoLeft, canGoRight] = useMemo(() => {
if (index === -1) return [src, false, false]
return [mediaArr[index][0], index > 0, index < mediaArr.length - 1]
}, [mediaArr, index])
}, [src, mediaArr, index])
useEffect(() => {
if (index === -1) return
@ -115,8 +116,12 @@ export function CarouselProvider ({ children }) {
const showModal = useShowModal()
const showCarousel = useCallback(({ src }) => {
// only show confirmed entries
const confirmedEntries = Array.from(media.current.entries())
.filter(([, entry]) => entry.confirmed)
showModal((close, setOptions) => {
return <Carousel close={close} mediaArr={Array.from(media.current.entries())} src={src} setOptions={setOptions} />
return <Carousel close={close} mediaArr={confirmedEntries} src={src} setOptions={setOptions} />
}, {
fullScreen: true,
overflow: <CarouselOverflow {...media.current.get(src)} />
@ -124,14 +129,25 @@ export function CarouselProvider ({ children }) {
}, [showModal])
const addMedia = useCallback(({ src, originalSrc, rel }) => {
media.current.set(src, { src, originalSrc, rel })
media.current.set(src, { src, originalSrc, rel, confirmed: false })
}, [])
const confirmMedia = useCallback((src) => {
const mediaItem = media.current.get(src)
if (mediaItem) {
mediaItem.confirmed = true
media.current.set(src, mediaItem)
}
}, [])
const removeMedia = useCallback((src) => {
media.current.delete(src)
}, [])
const value = useMemo(() => ({ showCarousel, addMedia, removeMedia }), [showCarousel, addMedia, removeMedia])
const value = useMemo(
() => ({ showCarousel, addMedia, confirmMedia, removeMedia }),
[showCarousel, addMedia, confirmMedia, removeMedia]
)
return <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider>
}

View File

@ -1,6 +1,6 @@
import { createContext, useContext, useMemo } from 'react'
import { useQuery } from '@apollo/client'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { NORMAL_POLL_INTERVAL_MS, SSR } from '@/lib/constants'
import { CHAIN_FEE } from '@/fragments/chainFee'
export const ChainFeeContext = createContext({
@ -14,7 +14,7 @@ export const ChainFeeProvider = ({ chainFee, children }) => {
...(SSR
? {}
: {
pollInterval: NORMAL_POLL_INTERVAL,
pollInterval: NORMAL_POLL_INTERVAL_MS,
nextFetchPolicy: 'cache-and-network'
})
})

View File

@ -96,8 +96,9 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
}
export default function Comment ({
item, children, replyOpen, includeParent, topLevel, rootLastCommentAt,
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry
item, children, replyOpen, includeParent, topLevel,
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry,
navigator
}) {
const [edit, setEdit] = useState()
const { me } = useMe()
@ -116,13 +117,18 @@ export default function Comment ({
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')
}
const classes = ref.current.classList
const hasOutline = classes.contains('outline-new-comment')
const hasLiveOutline = classes.contains('outline-new-live-comment')
const hasOutlineUnset = classes.contains('outline-new-comment-unset')
// don't try to untrack and unset the outline if the comment is not outlined or we already unset the outline
if (!(hasLiveOutline || hasOutline) || hasOutlineUnset) return
classes.add('outline-new-comment-unset')
// untrack new comment and its descendants if it's not a live comment
navigator?.untrackNewComment(ref, { includeDescendants: hasOutline })
}
useEffect(() => {
@ -151,29 +157,37 @@ export default function Comment ({
}, [item.id, cache, router.query.commentId])
useEffect(() => {
if (me?.id === item.user?.id) return
// checking navigator because outlining should happen only on item pages
if (!navigator || 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')
const meViewedAt = new Date(root.meCommentsViewedAt).getTime()
const viewedAt = me?.id ? meViewedAt : router.query.commentsViewedAt
const isNewComment = viewedAt && itemCreatedAt > viewedAt
// live comments are new regardless of me or anon view time
const rootLast = new Date(root.lastCommentAt || root.createdAt).getTime()
const isNewLiveComment = item.live && itemCreatedAt > (meViewedAt || rootLast)
if (!isNewComment && !isNewLiveComment) return
if (item.live) {
// live comments (item.live) have to use a different class to outline every new comment
ref.current.classList.add('outline-new-live-comment')
// wait for the injection animation to end before removing its class
ref.current.addEventListener('animationend', () => {
ref.current.classList.remove(styles.injectedComment)
ref.current.classList.remove(styles.liveComment)
}, { once: true })
// animate the live comment injection
ref.current.classList.add(styles.injectedComment)
ref.current.classList.add(styles.liveComment)
} else {
ref.current.classList.add('outline-new-comment')
}
}, [item.id, rootLastCommentAt])
navigator.trackNewComment(ref, itemCreatedAt)
}, [item.id, root.lastCommentAt, root.meCommentsViewedAt])
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
@ -295,7 +309,7 @@ export default function Comment ({
? (
<>
{item.comments.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} rootLastCommentAt={rootLastCommentAt} />
<Comment depth={depth + 1} key={item.id} item={item} navigator={navigator} />
))}
{item.comments.comments.length < item.nDirectComments && (
<div className={`d-block ${styles.comment} pb-2 ps-3`}>

View File

@ -18,9 +18,7 @@
.dontLike {
fill: #a5a5a5;
margin-right: .35rem;
padding: 2px;
margin-left: 1px;
margin-right: 2px;
margin-top: 9px;
cursor: pointer;
}
@ -64,19 +62,23 @@
.children {
margin-top: 0;
margin-left: 27px;
margin-left: 15px;
}
.comments {
margin-left: -.75rem;
margin-left: -15px;
}
@media screen and (min-width: 768px) {
.comments {
margin-left: .75rem;
margin-left: 0px;
}
}
.children {
margin-left: 25px;
}
}
.skeleton .hunk {
width: 100%;
@ -137,34 +139,30 @@
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 {
.liveComment {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from { opacity: 0; }
to { opacity: 1; }
}
.commentNavigator {
display: flex;
align-items: center;
vertical-align: middle;
gap: 0.2rem;
padding-bottom: 0.2rem;
justify-content: center;
cursor: pointer;
/* prevent double tap from zooming */
touch-action: manipulation;
}
.newCommentDot {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #007cbe;
}

View File

@ -9,6 +9,7 @@ import { useRouter } from 'next/router'
import MoreFooter from './more-footer'
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
import useLiveComments from './use-live-comments'
import { useCommentsNavigatorContext } from './use-comments-navigator'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter()
@ -70,7 +71,10 @@ export default function Comments ({
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)
useLiveComments(parentId, lastCommentAt || parentCreatedAt)
// new comments navigator, tracks new comments and provides navigation controls
const { navigator } = useCommentsNavigatorContext()
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
@ -94,11 +98,11 @@ export default function Comments ({
: null}
{pins.map(item => (
<Fragment key={item.id}>
<Comment depth={1} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} pin />
<Comment depth={1} item={item} navigator={navigator} {...props} pin />
</Fragment>
))}
{comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} />
<Comment depth={1} key={item.id} item={item} navigator={navigator} {...props} />
))}
{ncomments > FULL_COMMENTS_THRESHOLD &&
<MoreFooter

View File

@ -7,6 +7,7 @@ import Flag from '@/svgs/flag-fill.svg'
import { useMemo } from 'react'
import getColor from '@/lib/rainbow'
import { gql, useMutation } from '@apollo/client'
import styles from './upvote.module.css'
export function DownZap ({ item, ...props }) {
const { meDontLikeSats } = item
@ -20,7 +21,9 @@ export function DownZap ({ item, ...props }) {
<DownZapper
item={item} As={({ ...oprops }) =>
<div className='upvoteParent'>
<Flag {...props} {...oprops} style={style} />
<div className={styles.upvoteWrapper}>
<Flag {...props} {...oprops} style={style} />
</div>
</div>}
/>
)

48
components/favicon.js Normal file
View File

@ -0,0 +1,48 @@
import { createContext, useContext, useMemo, useState } from 'react'
import { useHasNewNotes } from './use-has-new-notes'
import Head from 'next/head'
const FAVICONS = {
default: '/favicon.png',
notify: '/favicon-notify.png',
comments: '/favicon-comments.png',
notifyWithComments: '/favicon-notify-with-comments.png'
}
const getFavicon = (hasNewNotes, hasNewComments) => {
if (hasNewNotes && hasNewComments) return FAVICONS.notifyWithComments
if (hasNewNotes) return FAVICONS.notify
if (hasNewComments) return FAVICONS.comments
return FAVICONS.default
}
export const FaviconContext = createContext()
export default function FaviconProvider ({ children }) {
const hasNewNotes = useHasNewNotes()
const [hasNewComments, setHasNewComments] = useState(false)
const favicon = useMemo(() =>
getFavicon(hasNewNotes, hasNewComments),
[hasNewNotes, hasNewComments])
const contextValue = useMemo(() => ({
favicon,
hasNewNotes,
hasNewComments,
setHasNewComments
}), [favicon, hasNewNotes, hasNewComments, setHasNewComments])
return (
<FaviconContext.Provider value={contextValue}>
<Head>
<link rel='shortcut icon' href={favicon} />
</Head>
{children}
</FaviconContext.Provider>
)
}
export function useFavicon () {
return useContext(FaviconContext)
}

View File

@ -4,7 +4,7 @@ import ActionTooltip from './action-tooltip'
import Info from './info'
import styles from './fee-button.module.css'
import { gql, useQuery } from '@apollo/client'
import { ANON_FEE_MULTIPLIER, FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { ANON_FEE_MULTIPLIER, FAST_POLL_INTERVAL_MS, SSR } from '@/lib/constants'
import { numWithUnits } from '@/lib/format'
import { useMe } from './me'
import AnonIcon from '@/svgs/spy-fill.svg'
@ -14,7 +14,7 @@ import { SubmitButton } from './form'
const FeeButtonContext = createContext()
export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me }) {
export function postCommentBaseLineItems ({ baseCost = 1, comment = false, bio = false, me }) {
const anonCharge = me
? {}
: {
@ -28,10 +28,10 @@ export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me })
return {
baseCost: {
term: baseCost,
label: `${comment ? 'comment' : 'post'} cost`,
label: `${bio ? 'bio' : (comment ? 'comment' : 'post')} cost`,
op: '_',
modifier: (cost) => cost + baseCost,
allowFreebies: comment
allowFreebies: bio
},
...anonCharge
}
@ -45,7 +45,7 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
return function useRemoteLineItems () {
const [line, setLine] = useState({})
const { data } = useQuery(query, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL, nextFetchPolicy: 'cache-and-network' })
const { data } = useQuery(query, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL_MS, nextFetchPolicy: 'cache-and-network' })
useEffect(() => {
const repetition = data?.itemRepetition

View File

@ -1,7 +1,7 @@
import { gql, useQuery } from '@apollo/client'
import Link from 'next/link'
import { RewardLine } from '@/pages/rewards'
import { LONG_POLL_INTERVAL, SSR } from '@/lib/constants'
import { LONG_POLL_INTERVAL_MS, SSR } from '@/lib/constants'
const REWARDS = gql`
{
@ -12,7 +12,7 @@ const REWARDS = gql`
}`
export default function Rewards () {
const { data } = useQuery(REWARDS, SSR ? { ssr: false } : { pollInterval: LONG_POLL_INTERVAL, nextFetchPolicy: 'cache-and-network' })
const { data } = useQuery(REWARDS, SSR ? { ssr: false } : { pollInterval: LONG_POLL_INTERVAL_MS, nextFetchPolicy: 'cache-and-network' })
const total = data?.rewards?.[0]?.total
const time = data?.rewards?.[0]?.time
return (

View File

@ -12,10 +12,13 @@ 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 Live from '@/svgs/chat-unread-fill.svg'
import NoLive from '@/svgs/chat-off-fill.svg'
import Rewards from './footer-rewards'
import useDarkMode from './dark-mode'
import ActionTooltip from './action-tooltip'
import { useAnimationEnabled } from '@/components/animation'
import { useLiveCommentsToggle } from './use-live-comments'
const RssPopover = (
<Popover>
@ -97,13 +100,6 @@ const SocialsPopover = (
const ChatPopover = (
<Popover>
<Popover.Body style={{ fontWeight: 500, fontSize: '.9rem' }}>
{/* <a
href='https://tribes.sphinx.chat/t/stackerzchat' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
sphinx
</a>
<span className='mx-2 text-muted'> \ </span> */}
<a
href='https://t.me/k00bideh' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
@ -112,10 +108,10 @@ const ChatPopover = (
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2F6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE%3D%40smp10.simplex.im%2FebLYaEFGjsD3uK4fpE326c5QI1RZSxau%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAV086Oj5yCsavWzIbRMCVuF6jq793Tt__rWvCec__viI%253D%26srv%3Drb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22cZwSGoQhyOUulzp7rwCdWQ%3D%3D%22%7D' className='nav-link p-0 d-inline-flex'
href='https://signal.group/#CjQKIEt57YiluJoTW3lZqaqAq6echCekEYFfg7eIua2X91nLEhA__6ALI9pkaY_McQqX0jm1' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
simplex
signal
</a>
</Popover.Body>
</Popover>
@ -147,8 +143,11 @@ export default function Footer ({ links = true }) {
const [animationEnabled, toggleAnimation] = useAnimationEnabled()
const [disableLiveComments, toggleLiveComments] = useLiveCommentsToggle()
const DarkModeIcon = darkMode ? Sun : Moon
const LnIcon = animationEnabled ? No : Bolt
const LiveIcon = disableLiveComments ? Live : NoLive
const version = process.env.NEXT_PUBLIC_COMMIT_HASH
@ -164,6 +163,9 @@ export default function Footer ({ links = true }) {
<ActionTooltip notForm overlayText={`${animationEnabled ? 'disable' : 'enable'} lightning animations`}>
<LnIcon onClick={toggleAnimation} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
</ActionTooltip>
<ActionTooltip notForm overlayText={`${disableLiveComments ? 'enable' : 'disable'} live comments`}>
<LiveIcon onClick={toggleLiveComments} width={20} height={20} className='ms-2 fill-grey theme' suppressHydrationWarning />
</ActionTooltip>
</div>
<div className='mb-0' style={{ fontWeight: 500 }}>
<Rewards />
@ -249,6 +251,9 @@ export default function Footer ({ links = true }) {
<Link href='/ek' className='ms-1'>
@ek
</Link>
<Link href='/sox' className='ms-1'>
@sox
</Link>
<span className='ms-1'>&</span>
<Link href='https://github.com/stackernews/stacker.news/graphs/contributors' className='ms-1' target='_blank' rel='noreferrer'>
more

View File

@ -1451,6 +1451,7 @@ export function MultiInput ({
onChange, autoFocus, hideError, inputType = 'text',
...props
}) {
const formik = useFormikContext()
const [inputs, setInputs] = useState(new Array(length).fill(''))
const inputRefs = useRef(new Array(length).fill(null))
const [, meta, helpers] = useField({ name })
@ -1549,7 +1550,7 @@ export function MultiInput ({
))}
</div>
<div>
{hideError && meta.touched && meta.error && ( // custom error message is showed if hideError is true
{hideError && formik.submitCount > 0 && meta.touched && meta.error && ( // custom error message is showed if hideError is true
<BootstrapForm.Control.Feedback type='invalid' className='d-block'>
{meta.error}
</BootstrapForm.Control.Feedback>

View File

@ -5,7 +5,7 @@ export default function CCInfo (props) {
return (
<Info {...props}>
<h6>Why am I getting cowboy credits?</h6>
<ul className='line-height-md'>
<ul>
<li>to receive sats, you must attach an <Link href='/wallets'>external receiving wallet</Link></li>
<li>zappers may have chosen to send you CCs instead of sats</li>
<li>if the zaps are split on a post, recipients will receive CCs regardless of their configured receiving wallet</li>

View File

@ -5,7 +5,7 @@ export default function RewardSatsInfo (props) {
return (
<Info {...props}>
<h6>Where did my sats come from?</h6>
<ul className='line-height-md'>
<ul>
<li>you may have sats from before <Link href='/items/835465'>SN went not-custodial</Link></li>
<li>sats also come from <Link href='/rewards'>daily rewards</Link> and territory revenue
<ul>

View File

@ -7,7 +7,7 @@ import PayerData from './payer-data'
import Bolt11Info from './bolt11-info'
import { useQuery } from '@apollo/client'
import { INVOICE } from '@/fragments/invoice'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { FAST_POLL_INTERVAL_MS, SSR } from '@/lib/constants'
import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/client/errors'
import ItemJob from './item-job'
import Item from './item'
@ -24,7 +24,7 @@ export default function Invoice ({
const { data, error } = useQuery(query, SSR
? {}
: {
pollInterval: FAST_POLL_INTERVAL,
pollInterval: FAST_POLL_INTERVAL_MS,
variables: { id },
nextFetchPolicy: 'cache-and-network',
skip: !poll

View File

@ -11,7 +11,6 @@ import { useMe } from './me'
import Button from 'react-bootstrap/Button'
import { useEffect } from 'react'
import Poll from './poll'
import { commentsViewed } from '@/lib/new-comments'
import Related from './related'
import PastBounties from './past-bounties'
import Check from '@/svgs/check-double-line.svg'
@ -26,6 +25,7 @@ import { UNKNOWN_LINK_REL } from '@/lib/constants'
import classNames from 'classnames'
import { CarouselProvider } from './carousel'
import Embed from './embed'
import useCommentsView from './use-comments-view'
function BioItem ({ item, handleClick }) {
const { me } = useMe()
@ -161,9 +161,12 @@ function ItemText ({ item }) {
}
export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props }) {
// no cache update here because we need to preserve the initial value
const { markItemViewed } = useCommentsView(item.id, { updateCache: false })
useEffect(() => {
commentsViewed(item)
}, [item.lastCommentAt])
markItemViewed(item)
}, [item.id, markItemViewed])
return (
<>

View File

@ -134,7 +134,7 @@ export default function ItemInfo ({
{showUser &&
<Link href={`/${item.user.name}`}>
<UserPopover name={item.user.name}>@{item.user.name}</UserPopover>
<Badges badgeClassName='fill-grey' spacingClassName='ms-xs' height={12} width={12} user={item.user} />
<Badges badgeClassName='fill-grey' spacingClassName='ms-xs' height={12} width={12} user={item.user} bot={item.apiKey} />
{embellishUser}
</Link>}
<span> </span>
@ -166,9 +166,6 @@ export default function ItemInfo ({
{' '}<Badge className={styles.newComment} bg={null}>freebie</Badge>
</Link>
)}
{(item.apiKey &&
<>{' '}<Badge className={styles.newComment} bg={null}>bot</Badge></>
)}
{extraBadges}
{
showActionDropdown &&

View File

@ -76,10 +76,9 @@ a.title:visited {
.dontLike {
fill: #a5a5a5;
margin-right: .35rem;
margin-left: -.2rem;
flex-shrink: 0;
cursor: pointer;
margin-right: 2px;
}
.case {

View File

@ -114,7 +114,7 @@ export default function JobForm ({ item, sub }) {
label={
<div className='d-flex align-items-center'>boost
<Info>
<ol className='line-height-md'>
<ol>
<li>Boost ranks jobs higher based on the amount</li>
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
<li>Boost must be divisible by {numWithUnits(BOOST_MULT, { abbreviate: false })}</li>

View File

@ -9,7 +9,7 @@ import Qr, { QrSkeleton } from './qr'
import styles from './lightning-auth.module.css'
import BackIcon from '@/svgs/arrow-left-line.svg'
import { useRouter } from 'next/router'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { FAST_POLL_INTERVAL_MS, SSR } from '@/lib/constants'
function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
const query = gql`
@ -19,7 +19,7 @@ function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
k1
}
}`
const { data } = useQuery(query, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL, nextFetchPolicy: 'cache-and-network' })
const { data } = useQuery(query, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL_MS, nextFetchPolicy: 'cache-and-network' })
useEffect(() => {
if (data?.lnAuth?.pubkey) {

View File

@ -75,6 +75,8 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error, signin))
const router = useRouter()
multiAuth = typeof multiAuth === 'string' ? multiAuth === 'true' : !!multiAuth
// signup/signin awareness cookie
useEffect(() => {
// expire cookie if we're on /signup instead of /login

View File

@ -1,14 +1,14 @@
import React, { useContext } from 'react'
import { useQuery } from '@apollo/client'
import { ME } from '@/fragments/users'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { FAST_POLL_INTERVAL_MS, SSR } from '@/lib/constants'
export const MeContext = React.createContext({
me: null
})
export function MeProvider ({ me, children }) {
const { data, refetch } = useQuery(ME, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL, nextFetchPolicy: 'cache-and-network' })
const { data, refetch } = useQuery(ME, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL_MS, nextFetchPolicy: 'cache-and-network' })
// this makes sure that we always use the fetched data if it's null.
// without this, we would always fallback to the `me` object
// which was passed during page load which (visually) breaks switching to anon

View File

@ -75,12 +75,19 @@ const Media = memo(function Media ({
export default function MediaOrLink ({ linkFallback = true, ...props }) {
const media = useMediaHelper(props)
const [error, setError] = useState(false)
const { showCarousel, addMedia, removeMedia } = useCarousel()
const { showCarousel, addMedia, confirmMedia, removeMedia } = useCarousel()
// register placeholder immediately on mount if we have a src
useEffect(() => {
if (!media.bestResSrc) return
addMedia({ src: media.bestResSrc, originalSrc: media.originalSrc, rel: props.rel })
}, [addMedia, media.bestResSrc, media.originalSrc, props.rel])
// confirm media for carousel based on image detection
useEffect(() => {
if (!media.image) return
addMedia({ src: media.bestResSrc, originalSrc: media.originalSrc, rel: props.rel })
}, [media.image])
confirmMedia(media.bestResSrc)
}, [confirmMedia, media.image, media.bestResSrc])
const handleClick = useCallback(() => showCarousel({ src: media.bestResSrc }),
[showCarousel, media.bestResSrc])
@ -137,7 +144,7 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
img.decode().then(() => { // decoding beforehand to prevent wrong image cropping
setIsImage(true)
}).catch((e) => {
console.error('Cannot decode image', e)
console.warn('Cannot decode image:', src, e)
})
}
video.src = src

View File

@ -0,0 +1,224 @@
import { createContext, Fragment, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import classNames from 'classnames'
import { useRouter } from 'next/router'
const MultiStepFormContext = createContext()
export function MultiStepForm ({ children, initial, steps }) {
const [stepIndex, setStepIndex] = useState(0)
const [formState, setFormState] = useState({})
const router = useRouter()
useEffect(() => {
// initial state might not be available on first render so we sync changes
if (initial) setFormState(initial)
}, [initial])
useEffect(() => {
const idx = Math.max(0, steps.indexOf(router.query.step))
setStepIndex(idx)
router.replace({
pathname: router.pathname,
query: { type: router.query.type, step: steps[idx] }
}, null, { shallow: true })
}, [router.query.step, steps])
const next = useCallback(() => {
const idx = Math.min(stepIndex + 1, steps.length - 1)
router.push(
{ pathname: router.pathname, query: { type: router.query.type, step: steps[idx] } },
null,
{ shallow: true }
)
}, [stepIndex, steps, router])
const prev = useCallback(() => router.back(), [router])
const updateFormState = useCallback((id, state) => {
setFormState(formState => {
return id ? { ...formState, [id]: state } : state
})
}, [])
const value = useMemo(
() => ({ stepIndex, steps, next, prev, formState, updateFormState }),
[stepIndex, steps, next, prev, formState, updateFormState])
return (
<MultiStepFormContext.Provider value={value}>
<Progress />
{children[stepIndex]}
</MultiStepFormContext.Provider>
)
}
function Progress () {
const steps = useSteps()
const maxSteps = useMaxSteps()
const stepIndex = useStepIndex()
const style = (index) => {
switch (index) {
case 0: return maxSteps === 2 ? { marginLeft: '-13px', marginRight: '-15px' } : { marginLeft: '-5px', marginRight: '-13px' }
case 1: return { marginLeft: '-13px', marginRight: '-15px' }
default: return {}
}
}
return (
<div className='d-flex my-3 mx-auto'>
{
steps.map((label, i) => {
const last = i === steps.length - 1
return (
<Fragment key={i}>
<ProgressNumber number={i + 1} label={label} active={stepIndex >= i} />
{!last && <ProgressLine style={style(i)} active={stepIndex >= i + 1} />}
</Fragment>
)
})
}
</div>
)
}
function ProgressNumber ({ number, label, active }) {
return (
<div className={classNames('z-1 text-center', { 'text-info': active })}>
<NumberSVG number={number} active={active} />
<div className={classNames('small pt-1', active ? 'text-info' : 'text-muted')}>
{label}
</div>
</div>
)
}
const NUMBER_SVG_WIDTH = 24
const NUMBER_SVG_HEIGHT = 24
function NumberSVG ({ number, active }) {
const width = NUMBER_SVG_WIDTH
const height = NUMBER_SVG_HEIGHT
const Wrapper = ({ children }) => (
<div style={{ position: 'relative', width: `${width}px`, height: `${height}px`, margin: '0 auto' }}>
{children}
</div>
)
const Circle = () => {
const circleProps = {
fill: active ? 'var(--bs-info)' : 'var(--bs-body-bg)',
stroke: active ? 'var(--bs-info)' : 'var(--theme-grey)'
}
return (
<svg
xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'
width={width} height={height}
style={{ position: 'absolute', top: 0, left: 0 }}
>
<circle cx='12' cy='12' r='11' strokeWidth='1' {...circleProps} />
</svg>
)
}
const Number = () => {
const svgProps = {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
// we scale the number down and render it in the center of the circle
width: 0.5 * width,
height: 0.5 * height,
style: { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }
}
const numberColor = active ? 'var(--bs-white)' : 'var(--theme-grey)'
// svgs are from https://remixicon.com/icon/number-1 etc.
switch (number) {
case 1:
return (
<svg {...svgProps}>
<path fill={numberColor} d='M14 1.5V22H12V3.704L7.5 4.91V2.839L12.5 1.5H14Z' />
</svg>
)
case 2:
return (
<svg {...svgProps}>
<path
fill={numberColor}
d='M16.0002 7.5C16.0002 5.29086 14.2094 3.5 12.0002 3.5C9.7911 3.5 8.00024 5.29086 8.00024 7.5H6.00024C6.00024 4.18629 8.68653 1.5 12.0002 1.5C15.314 1.5 18.0002 4.18629 18.0002 7.5C18.0002 8.93092 17.4993 10.2448 16.6633 11.276L9.344 19.9991L18.0002 20V22H6.00024L6 20.8731L15.0642 10.071C15.6485 9.37595 16.0002 8.47905 16.0002 7.5Z'
/>
</svg>
)
case 3:
return (
<svg {...svgProps}>
<path fill={numberColor} d='M18.0001 2V3.36217L12.8087 9.54981C16.0169 9.94792 18.5001 12.684 18.5001 16C18.5001 19.5899 15.5899 22.5 12.0001 22.5C8.95434 22.5 6.39789 20.4052 5.69287 17.5778L7.63351 17.0922C8.12156 19.0497 9.89144 20.5 12.0001 20.5C14.4853 20.5 16.5001 18.4853 16.5001 16C16.5001 13.5147 14.4853 11.5 12.0001 11.5C11.2795 11.5 10.5985 11.6694 9.99465 11.9705L9.76692 12.0923L9.07705 10.8852L14.8551 3.99917L6.50006 4V2H18.0001Z' />
</svg>
)
default:
return null
}
}
return (
<Wrapper>
<Circle />
<Number />
</Wrapper>
)
}
function ProgressLine ({ style, active }) {
const svgStyle = { display: 'block', position: 'relative', top: `${NUMBER_SVG_HEIGHT / 2}px` }
return (
<div style={style}>
<svg style={svgStyle} width='100%' height='1' viewBox='0 0 100 1' preserveAspectRatio='none'>
<path
d='M 0 1 L 100 1'
stroke={active ? 'var(--bs-info)' : 'var(--theme-grey)'}
strokeWidth='1'
fill='none'
/>
</svg>
</div>
)
}
function useSteps () {
const { steps } = useContext(MultiStepFormContext)
return steps
}
export function useStepIndex () {
const { stepIndex } = useContext(MultiStepFormContext)
return stepIndex
}
export function useMaxSteps () {
const steps = useSteps()
return steps.length
}
export function useStep () {
const stepIndex = useStepIndex()
const steps = useSteps()
return steps[stepIndex]
}
export function useNext () {
const { next } = useContext(MultiStepFormContext)
return next
}
export function usePrev () {
const { prev } = useContext(MultiStepFormContext)
return prev
}
export function useFormState (id) {
const { formState, updateFormState } = useContext(MultiStepFormContext)
const setFormState = useCallback(state => updateFormState(id, state), [id, updateFormState])
return useMemo(
() => [
id ? formState[id] : formState,
setFormState
], [formState, id, setFormState])
}

View File

@ -18,12 +18,10 @@ 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/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 (
@ -121,9 +119,6 @@ export function NavNotifications ({ className }) {
return (
<>
<Head>
<link rel='shortcut icon' href={hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
</Head>
<Link href='/notifications' passHref legacyBehavior>
<Nav.Link eventKey='notifications' className={classNames('position-relative', className)}>
<NoteIcon height={28} width={20} className='theme' />
@ -164,23 +159,20 @@ 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>
)
}
export const Indicator = ({ show, children }) => {
return (
<div className='p-1 d-inline-block bg-secondary ms-1'>
<span className='invisible'>{' '}</span>
<div className='w-fit-content position-relative'>
{children}
{show && (
<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>
)}
</div>
)
}
@ -198,8 +190,7 @@ export function MeDropdown ({ me, dropNavKey }) {
<Dropdown.Toggle className='nav-link nav-item fw-normal' id='profile' variant='custom'>
<div className='d-flex align-items-center'>
<Nav.Link eventKey={me.name} as='span' className='p-0 position-relative'>
{`@${me.name}`}
{indicator && <Indicator superscript />}
<Indicator show={indicator}>@{me.name}</Indicator>
</Nav.Link>
<Badges user={me} />
</div>
@ -207,8 +198,7 @@ export function MeDropdown ({ me, dropNavKey }) {
<Dropdown.Menu>
<Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}>
profile
{profileIndicator && <Indicator />}
<Indicator show={profileIndicator}>profile</Indicator>
</Dropdown.Item>
</Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
@ -216,8 +206,7 @@ export function MeDropdown ({ me, dropNavKey }) {
</Link>
<Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallets'>
wallets
{walletIndicator && <Indicator />}
<Indicator show={walletIndicator}>wallets</Indicator>
</Dropdown.Item>
</Link>
<Link href='/credits' passHref legacyBehavior>
@ -293,7 +282,6 @@ export default function LoginButton () {
function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
// const { removeLocalWallets } = useWallets()
const router = useRouter()
return (

View File

@ -2,9 +2,11 @@ import { Nav, Navbar } from 'react-bootstrap'
import styles from '../../header.module.css'
import { AnonCorner, Back, Brand, MeCorner, NavPrice, SearchItem } from '../common'
import { useMe } from '../../me'
import { useCommentsNavigatorContext, CommentsNavigator } from '@/components/use-comments-navigator'
export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
const { me } = useMe()
const { navigator, commentCount } = useCommentsNavigatorContext()
return (
<Navbar>
<Nav
@ -15,6 +17,7 @@ export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
<Brand className='me-1' />
<SearchItem prefix={prefix} className='me-0 ms-2 d-none d-md-flex' />
<NavPrice className='ms-auto me-0 mx-md-auto d-none d-md-flex' />
<CommentsNavigator navigator={navigator} commentCount={commentCount} />
{me
? <MeCorner dropNavKey={dropNavKey} me={me} className='d-none d-md-flex' />
: <AnonCorner path={path} className='d-none d-md-flex' />}

View File

@ -28,11 +28,11 @@ export default function OffCanvas ({ me, dropNavKey }) {
const profileIndicator = me && !me.bioId
const walletIndicator = useWalletIndicator()
const indicator = profileIndicator || walletIndicator
return (
<>
<MeImage onClick={handleShow} />
<Indicator show={indicator}><MeImage onClick={handleShow} /></Indicator>
<Offcanvas className={canvasStyles.offcanvas} show={show} onHide={handleClose} placement='end'>
<Offcanvas.Header closeButton>
<Offcanvas.Title><NavWalletSummary /></Offcanvas.Title>
@ -53,8 +53,7 @@ export default function OffCanvas ({ me, dropNavKey }) {
<>
<Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}>
profile
{profileIndicator && <Indicator />}
<Indicator show={profileIndicator}>profile</Indicator>
</Dropdown.Item>
</Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
@ -62,8 +61,7 @@ export default function OffCanvas ({ me, dropNavKey }) {
</Link>
<Link href='/wallets' passHref legacyBehavior>
<Dropdown.Item eventKey='wallets'>
wallets
{walletIndicator && <Indicator />}
<Indicator show={walletIndicator}>wallets</Indicator>
</Dropdown.Item>
</Link>
<Link href='/credits' passHref legacyBehavior>
@ -93,7 +91,7 @@ export default function OffCanvas ({ me, dropNavKey }) {
<Link href={`/${me?.name || 'anon'}`} className='d-flex flex-row p-2 mt-auto text-muted'>
<MeImage />
<div className='ms-2'>
@{me?.name || 'anon'}
<Indicator show={indicator}>@{me?.name || 'anon'}</Indicator>
</div>
</Link>
</Nav>

View File

@ -2,9 +2,12 @@ import { Nav, Navbar } from 'react-bootstrap'
import styles from '../../header.module.css'
import { Back, NavPrice, NavSelect, NavWalletSummary, SignUpButton, hasNavSelect } from '../common'
import { useMe } from '@/components/me'
import { useCommentsNavigatorContext, CommentsNavigator } from '@/components/use-comments-navigator'
export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey }) {
const { me } = useMe()
const { navigator, commentCount } = useCommentsNavigatorContext()
return (
<Navbar>
<Nav
@ -17,6 +20,7 @@ export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNa
: (
<>
<NavPrice className='flex-shrink-1' />
<CommentsNavigator navigator={navigator} commentCount={commentCount} className='px-2' />
{me ? <NavWalletSummary /> : <SignUpButton width='fit-content' />}
</>)}
</Nav>

View File

@ -4,10 +4,12 @@ import { Container, Nav, Navbar } from 'react-bootstrap'
import { NavPrice, MeCorner, AnonCorner, SearchItem, Back, NavWalletSummary, Brand, SignUpButton } from './common'
import { useMe } from '@/components/me'
import classNames from 'classnames'
import { CommentsNavigator, useCommentsNavigatorContext } from '../use-comments-navigator'
export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
const ref = useRef()
const { me } = useMe()
const { navigator, commentCount } = useCommentsNavigatorContext()
useEffect(() => {
const stick = () => {
@ -37,6 +39,7 @@ export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey })
<Brand className='me-1' />
<SearchItem className='me-0 ms-2' />
<NavPrice />
<CommentsNavigator navigator={navigator} commentCount={commentCount} className='d-flex' />
{me ? <MeCorner dropNavKey={dropNavKey} me={me} className='d-flex' /> : <AnonCorner path={path} className='d-flex' />}
</Nav>
</Navbar>
@ -44,11 +47,12 @@ export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey })
<Container className='px-sm-0 d-block d-md-none'>
<Navbar className='py-0'>
<Nav
className={classNames(styles.navbarNav, 'justify-content-between')}
className={classNames(styles.navbarNav)}
activeKey={topNavKey}
>
<Back />
<NavPrice className='flex-shrink-1 flex-grow-0' />
<NavPrice className='flex-shrink-1' />
<CommentsNavigator navigator={navigator} commentCount={commentCount} className='d-flex' />
{me ? <NavWalletSummary className='px-2' /> : <SignUpButton width='fit-content' />}
</Nav>
</Navbar>

View File

@ -4,8 +4,7 @@ export default function preserveScroll (callback) {
// if the scroll position is at the top, we don't need to preserve it, just call the callback
if (scrollTop <= 0) {
callback()
return
return callback()
}
// get a reference element at the center of the viewport to track if content is added above it
@ -49,5 +48,5 @@ export default function preserveScroll (callback) {
observer.observe(document.body, { childList: true, subtree: true })
callback()
return callback()
}

View File

@ -4,7 +4,7 @@ import { fixedDecimal } from '@/lib/format'
import { useMe } from './me'
import { PRICE } from '@/fragments/price'
import { CURRENCY_SYMBOLS } from '@/lib/currency'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { NORMAL_POLL_INTERVAL_MS, SSR } from '@/lib/constants'
import { useBlockHeight } from './block-height'
import { useChainFee } from './chain-fee'
import { CompactLongCountdown } from './countdown'
@ -27,7 +27,7 @@ export function PriceProvider ({ price, children }) {
...(SSR
? {}
: {
pollInterval: NORMAL_POLL_INTERVAL,
pollInterval: NORMAL_POLL_INTERVAL_MS,
nextFetchPolicy: 'cache-and-network'
})
})

View File

@ -17,6 +17,13 @@ export default function PullToRefresh ({ children, className }) {
setIsPWA(androidPWA || iosPWA)
}
const clearPullDistance = () => {
setPullDistance(0)
document.body.style.marginTop = '0px'
touchStartY.current = 0
touchEndY.current = 0
}
useEffect(checkPWA, [])
const handleTouchStart = useCallback((e) => {
@ -28,6 +35,13 @@ export default function PullToRefresh ({ children, className }) {
const handleTouchMove = useCallback((e) => {
if (touchStartY.current === 0) return
if (!isPWA) return
// if we're not at the top of the page after the touch start, reset the pull distance
if (window.scrollY > 0) {
clearPullDistance()
return
}
touchEndY.current = e.touches[0].clientY
const distance = touchEndY.current - touchStartY.current
setPullDistance(distance)
@ -39,10 +53,7 @@ export default function PullToRefresh ({ children, className }) {
if (touchEndY.current - touchStartY.current > REFRESH_THRESHOLD) {
router.push(router.asPath)
}
setPullDistance(0)
document.body.style.marginTop = '0px'
touchStartY.current = 0
touchEndY.current = 0
clearPullDistance()
}, [router])
useEffect(() => {

View File

@ -1,19 +1,18 @@
import { Form, MarkdownInput } from '@/components/form'
import styles from './reply.module.css'
import { COMMENTS } from '@/fragments/comments'
import { useMe } from './me'
import { forwardRef, useCallback, useEffect, useState, useRef, useMemo } from 'react'
import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
import { commentsViewedAfterComment } from '@/lib/new-comments'
import { commentSchema } from '@/lib/validate'
import { ItemButtonBar } from './post'
import { useShowModal } from './modal'
import { Button } from 'react-bootstrap'
import { useRoot } from './root'
import { CREATE_COMMENT } from '@/fragments/paidAction'
import { injectComment } from '@/lib/comments'
import useItemSubmit from './use-item-submit'
import gql from 'graphql-tag'
import { updateAncestorsCommentCount } from '@/lib/comments'
import useCommentsView from './use-comments-view'
export default forwardRef(function Reply ({
item,
@ -30,6 +29,7 @@ export default forwardRef(function Reply ({
const showModal = useShowModal()
const root = useRoot()
const sub = item?.sub || root?.sub
const { markCommentViewedAt } = useCommentsView(root.id)
useEffect(() => {
if (replyOpen || quote || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) {
@ -51,23 +51,11 @@ export default forwardRef(function Reply ({
update (cache, { data: { upsertComment: { result, invoice } } }) {
if (!result) return
cache.modify({
id: `Item:${parentId}`,
fields: {
comments (existingComments = {}) {
const newCommentRef = cache.writeFragment({
data: result,
fragment: COMMENTS,
fragmentName: 'CommentsRecursive'
})
return {
cursor: existingComments.cursor,
comments: [newCommentRef, ...(existingComments?.comments || [])]
}
}
},
optimistic: true
})
// inject the new comment into the cache
const injected = injectComment(cache, result)
if (injected) {
markCommentViewedAt(result.createdAt, { ncomments: 1 })
}
// no lag for itemRepetition
if (!item.mine && me) {
@ -79,16 +67,6 @@ export default forwardRef(function Reply ({
}
})
}
const ancestors = item.path.split('.')
// update all ancestors
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
const root = ancestors[0]
commentsViewedAfterComment(root, result.createdAt)
}
},
onSuccessfulSubmit: (data, { resetForm }) => {

View File

@ -2,6 +2,7 @@
max-width: 600px;
padding-right: 15px;
padding-bottom: 1rem;
margin-left: 12px;
}
.replyButtons {
@ -12,6 +13,7 @@
align-items: center;
line-height: 1rem;
vertical-align: middle;
margin-left: 12px;
}
.replyButtons > * {
@ -47,4 +49,14 @@
.reply .text {
margin-top: -1px;
height: auto;
}
@media screen and (min-width: 768px) {
.replyButtons {
margin-left: .99px;
}
.reply {
margin-left: .99px;
}
}

View File

@ -15,6 +15,7 @@ import { useRouter } from 'next/router'
import { whenToFrom } from '@/lib/time'
import { useMe } from './me'
import { useField } from 'formik'
import { searchSchema } from '@/lib/validate'
export default function Search ({ sub }) {
const router = useRouter()
@ -65,6 +66,7 @@ export default function Search ({ sub }) {
<Form
initial={{ q, what, sort, when, from: '', to: '' }}
onSubmit={values => search({ ...values })}
schema={searchSchema}
>
<div className={`${styles.active} mb-3`}>
<SearchInput

View File

@ -22,10 +22,6 @@
form>.active {
display: flex;
pointer-events: auto;
flex-flow: row nowrap;
flex-flow: row;
align-items: center;
}
form>.active :global(.input-group) {
flex-flow: nowrap;
}

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { Select } from './form'
import { EXTRA_LONG_POLL_INTERVAL, SSR } from '@/lib/constants'
import { EXTRA_LONG_POLL_INTERVAL_MS, SSR } from '@/lib/constants'
import { SUBS } from '@/fragments/subs'
import { useQuery } from '@apollo/client'
import styles from './sub-select.module.css'
@ -24,7 +24,7 @@ export function useSubs ({ prependSubs = DEFAULT_PREPEND_SUBS, sub, filterSubs =
const { data, refetch } = useQuery(SUBS, SSR
? {}
: {
pollInterval: EXTRA_LONG_POLL_INTERVAL,
pollInterval: EXTRA_LONG_POLL_INTERVAL_MS,
nextFetchPolicy: 'cache-and-network'
})

View File

@ -2,11 +2,8 @@ import React, { useMemo, useState } from 'react'
import Dropdown from 'react-bootstrap/Dropdown'
import FormControl from 'react-bootstrap/FormControl'
import TocIcon from '@/svgs/list-unordered.svg'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { visit } from 'unist-util-visit'
import { toString } from 'mdast-util-to-string'
import { slug } from 'github-slugger'
import { useRouter } from 'next/router'
import { extractHeadings } from '@/lib/toc'
export default function Toc ({ text }) {
const router = useRouter()
@ -14,16 +11,7 @@ export default function Toc ({ text }) {
return null
}
const toc = useMemo(() => {
const tree = fromMarkdown(text)
const toc = []
visit(tree, 'heading', (node, position, parent) => {
const str = toString(node)
toc.push({ heading: str, slug: slug(str.replace(/[^\w\-\s]+/gi, '')), depth: node.depth })
})
return toc
}, [text])
const toc = useMemo(() => extractHeadings(text), [text])
if (toc.length === 0) {
return null

View File

@ -20,6 +20,7 @@ import rehypeSN from '@/lib/rehype-sn'
import remarkUnicode from '@/lib/remark-unicode'
import Embed from './embed'
import remarkMath from 'remark-math'
import remarkToc from '@/lib/remark-toc'
const rehypeSNStyled = () => rehypeSN({
stylers: [{
@ -33,7 +34,11 @@ const rehypeSNStyled = () => rehypeSN({
}]
})
const remarkPlugins = [gfm, remarkUnicode, [remarkMath, { singleDollarTextMath: false }]]
const baseRemarkPlugins = [
gfm,
remarkUnicode,
[remarkMath, { singleDollarTextMath: false }]
]
export function SearchText ({ text }) {
return (
@ -49,6 +54,9 @@ export function SearchText ({ text }) {
// this is one of the slowest components to render
export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, children, tab, itemId, outlawed, topLevel }) {
// include remarkToc if topLevel
const remarkPlugins = topLevel ? [...baseRemarkPlugins, remarkToc] : baseRemarkPlugins
// would the text overflow on the current screen size?
const [overflowing, setOverflowing] = useState(false)
// should we show the full text?
@ -235,9 +243,9 @@ function MediaLink ({
function Table ({ node, ...props }) {
return (
<span className='table-responsive'>
<div className='table-responsive'>
<table className='table table-bordered table-sm' {...props} />
</span>
</div>
)
}

View File

@ -241,6 +241,7 @@
.text table {
width: auto;
white-space: nowrap;
}
.text blockquote {
@ -448,4 +449,4 @@
max-width: 480px;
border-radius: 13px;
overflow: hidden;
}
}

View File

@ -20,7 +20,8 @@ export default function useCanEdit (item) {
const anonEdit = !!invParams && !me && Number(item.user.id) === USER_ID.anon
// anonEdit should not override canEdit, but only allow edits if they aren't already allowed
setCanEdit(canEdit => canEdit || anonEdit)
}, [])
// update when the hmac gets set
}, [item?.invoice?.hmac])
return [canEdit, setCanEdit, editThreshold]
}

View File

@ -0,0 +1,200 @@
import { useCallback, useEffect, useRef, useState, startTransition, createContext, useContext } from 'react'
import styles from './comment.module.css'
import LongPressable from './long-pressable'
import { useFavicon } from './favicon'
const CommentsNavigatorContext = createContext({
navigator: {
trackNewComment: () => {},
untrackNewComment: () => {},
scrollToComment: () => {},
clearCommentRefs: () => {}
},
commentCount: 0
})
export function CommentsNavigatorProvider ({ children }) {
const value = useCommentsNavigator()
return (
<CommentsNavigatorContext.Provider value={value}>
{children}
</CommentsNavigatorContext.Provider>
)
}
export function useCommentsNavigatorContext () {
return useContext(CommentsNavigatorContext)
}
export function useCommentsNavigator () {
const { setHasNewComments } = useFavicon()
const [commentCount, setCommentCount] = useState(0)
// refs in ref to not re-render on tracking
const commentRefs = useRef([])
// ref to track if the comment count is being updated
const frameRef = useRef(null)
const navigatorRef = useRef(null)
// batch updates to the comment count
const throttleCountUpdate = useCallback(() => {
if (frameRef.current) return
// prevent multiple updates in the same frame
frameRef.current = true
window.requestAnimationFrame(() => {
const next = commentRefs.current.length
// transition to the new comment count
startTransition?.(() => setCommentCount(next))
frameRef.current = false
})
}, [])
// clear the list of refs and reset the comment count
const clearCommentRefs = useCallback(() => {
commentRefs.current = []
startTransition?.(() => setCommentCount(0))
setHasNewComments(false)
}, [])
// track a new comment
const trackNewComment = useCallback((commentRef, createdAt) => {
setHasNewComments(true)
try {
window.requestAnimationFrame(() => {
if (!commentRef?.current || !commentRef.current.isConnected) return
// dedupe
const existing = commentRefs.current.some(item => item.ref.current === commentRef.current)
if (existing) return
// find the correct insertion position to maintain sort order
const insertIndex = commentRefs.current.findIndex(item => item.createdAt > createdAt)
const newItem = { ref: commentRef, createdAt }
if (insertIndex === -1) {
// append if no newer comments found
commentRefs.current.push(newItem)
} else {
// insert at the correct position to maintain sort order
commentRefs.current.splice(insertIndex, 0, newItem)
}
throttleCountUpdate()
})
} catch {
// in the rare case of a ref being disconnected during RAF, ignore to avoid blocking UI
}
}, [throttleCountUpdate])
// remove a comment ref from the list
const untrackNewComment = useCallback((commentRef, options = {}) => {
// we just need to read a single comment to clear the favicon
setHasNewComments(false)
const { includeDescendants = false, clearOutline = false } = options
const refNode = commentRef.current
if (!refNode) return
const toRemove = commentRefs.current.filter(item => {
const node = item?.ref?.current
return includeDescendants
? node && refNode.contains(node)
: node === refNode
})
if (clearOutline) {
for (const item of toRemove) {
const node = item.ref.current
if (!node) continue
node.classList.remove(
'outline-it',
'outline-new-comment',
'outline-new-live-comment'
)
node.classList.add('outline-new-comment-unset')
}
}
if (toRemove.length) {
commentRefs.current = commentRefs.current.filter(item => !toRemove.includes(item))
throttleCountUpdate()
}
}, [throttleCountUpdate])
// scroll to the next new comment
const scrollToComment = useCallback(() => {
const list = commentRefs.current
if (!list.length) return
const ref = list[0]?.ref
const node = ref?.current
if (!node) return
// smoothly scroll to the start of the comment
node.scrollIntoView({ behavior: 'smooth', block: 'start' })
// clear the outline class after the animation ends
node.addEventListener('animationend', () => {
node.classList.remove('outline-it')
}, { once: true })
// requestAnimationFrame to ensure untracking is processed before outlining
window.requestAnimationFrame(() => {
node.classList.add('outline-it')
})
// untrack the new comment and clear the outlines
untrackNewComment(ref, { includeDescendants: true, clearOutline: true })
// if we reached the end, reset the navigator
if (list.length === 1) clearCommentRefs()
}, [clearCommentRefs, untrackNewComment])
// create the navigator object once
if (!navigatorRef.current) {
navigatorRef.current = { trackNewComment, untrackNewComment, scrollToComment, clearCommentRefs }
}
// clear the navigator on unmount
useEffect(() => {
return () => clearCommentRefs()
}, [clearCommentRefs])
return { navigator: navigatorRef.current, commentCount }
}
export function CommentsNavigator ({ navigator, commentCount, className }) {
const { scrollToComment, clearCommentRefs } = navigator
const onNext = useCallback((e) => {
// ignore if there are no new comments or if we're focused on a textarea or input
if (!commentCount || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return
// arrow right key scrolls to the next new comment
if (e.key === 'ArrowRight' && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
e.preventDefault()
scrollToComment()
}
// escape key clears the new comments navigator
if (e.key === 'Escape') clearCommentRefs()
}, [commentCount, scrollToComment, clearCommentRefs])
useEffect(() => {
if (!commentCount) return
document.addEventListener('keydown', onNext)
return () => document.removeEventListener('keydown', onNext)
}, [onNext])
return (
<LongPressable onShortPress={scrollToComment} onLongPress={clearCommentRefs}>
<aside
className={`${styles.commentNavigator} fw-bold nav-link ${className}`}
style={{ visibility: commentCount ? 'visible' : 'hidden' }}
>
<span aria-label='next comment' className={styles.navigatorButton}>
<div className={styles.newCommentDot} />
</span>
<span className=''>{commentCount}</span>
</aside>
</LongPressable>
)
}

View File

@ -0,0 +1,46 @@
import { useMutation } from '@apollo/client'
import { useCallback } from 'react'
import { UPDATE_ITEM_USER_VIEW } from '@/fragments/items'
import { commentsViewedAfterComment, commentsViewed, newComments } from '@/lib/new-comments'
import { useMe } from './me'
export default function useCommentsView (itemId, { updateCache = true } = {}) {
const { me } = useMe()
const [updateCommentsViewAt] = useMutation(UPDATE_ITEM_USER_VIEW, {
update (cache, { data: { updateCommentsViewAt } }) {
if (!updateCache || !itemId) return
cache.modify({
id: `Item:${itemId}`,
fields: { meCommentsViewedAt: () => updateCommentsViewAt }
})
}
})
const updateViewedAt = useCallback((latest, anonFallbackFn) => {
if (me?.id) {
updateCommentsViewAt({ variables: { id: Number(itemId), meCommentsViewedAt: latest } })
} else {
anonFallbackFn()
}
}, [me?.id, itemId, updateCommentsViewAt])
// update meCommentsViewedAt on comment injection
const markCommentViewedAt = useCallback((latest, { ncomments } = {}) => {
if (!latest) return
updateViewedAt(latest, () => commentsViewedAfterComment(itemId, latest, ncomments))
}, [itemId, updateViewedAt])
// update meCommentsViewedAt on item view
const markItemViewed = useCallback((item, latest) => {
if (!item || item.parentId || (item?.meCommentsViewedAt && !newComments(item))) return
const lastAt = latest || item?.lastCommentAt || item?.createdAt
const newLatest = new Date(lastAt)
updateViewedAt(newLatest, () => commentsViewed(item))
}, [updateViewedAt])
return { markCommentViewedAt, markItemViewed }
}

View File

@ -1,5 +1,5 @@
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { NORMAL_POLL_INTERVAL_MS, SSR } from '@/lib/constants'
import { useQuery } from '@apollo/client'
import React, { useContext } from 'react'
import { clearNotifications } from '@/components/serviceworker'
@ -11,7 +11,7 @@ export function HasNewNotesProvider ({ me, children }) {
SSR
? {}
: {
pollInterval: NORMAL_POLL_INTERVAL,
pollInterval: NORMAL_POLL_INTERVAL_MS,
nextFetchPolicy: 'cache-and-network',
onCompleted: ({ hasNewNotes }) => {
if (!hasNewNotes) {

View File

@ -1,124 +1,114 @@
import { useEffect, useState, useCallback } from 'react'
import { useQuery, useApolloClient } from '@apollo/client'
import { SSR } from '../lib/constants'
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'
import { injectComment } from '../lib/comments'
import useCommentsView from './use-comments-view'
const POLL_INTERVAL = 1000 * 5 // 5 seconds
// live comments polling interval
const POLL_INTERVAL = 1000 * 5
// live comments toggle keys
const STORAGE_DISABLE_KEY = 'disableLiveComments'
const TOGGLE_EVENT = 'liveComments:toggle'
// 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
const readStoredLatest = (key, latest) => {
const stored = window.sessionStorage.getItem(key)
return stored && stored > latest ? stored : latest
}
function cacheNewComments (cache, rootId, newComments, sort) {
for (const newComment of newComments) {
const { parentId } = newComment
const topLevel = Number(parentId) === Number(rootId)
// cache new comments and return the most recent timestamp between current latest and new comment
// regardless of whether the comments were injected or not
function cacheNewComments (cache, latest, itemId, newComments, markCommentViewedAt) {
let injected = 0
// 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))
}
const injectedLatest = newComments.reduce((latestTimestamp, newComment) => {
const result = injectComment(cache, newComment, { live: true, rootId: itemId })
// if any comment was injected, increment injected
injected = result ? injected + 1 : injected
return new Date(newComment.createdAt) > new Date(latestTimestamp)
? newComment.createdAt
: latestTimestamp
}, latest)
if (injected > 0) {
markCommentViewedAt(injectedLatest, { ncomments: injected })
}
return injectedLatest
}
// 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}`
// fetches comments for an item that are newer than the latest comment createdAt (after),
// injects them into cache, and keeps scroll position stable.
export default function useLiveComments (itemId, after) {
const latestKey = `liveCommentsLatest:${itemId}`
const { cache } = useApolloClient()
const { markCommentViewedAt } = useCommentsView(itemId)
const [disableLiveComments] = useLiveCommentsToggle()
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)
}
}
setLatest(readStoredLatest(latestKey, 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])
}, [itemId, 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'
})
const { data } = useQuery(GET_NEW_COMMENTS, {
pollInterval: POLL_INTERVAL,
// only get comments newer than the passed latest timestamp
variables: { itemId, after: latest },
nextFetchPolicy: 'cache-and-network',
skip: SSR || !initialized || disableLiveComments
})
useEffect(() => {
if (!data?.newComments?.comments?.length) return
const newComments = data?.newComments?.comments
if (!newComments?.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))
const injectedLatest = preserveScroll(() => cacheNewComments(cache, latest, itemId, newComments, markCommentViewedAt))
// if we didn't process any newer comments, bail
if (new Date(injectedLatest).getTime() <= new Date(latest).getTime()) return
// 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])
setLatest(injectedLatest)
window.sessionStorage.setItem(latestKey, injectedLatest)
}, [data, cache, itemId, latest, markCommentViewedAt])
}
export function useLiveCommentsToggle () {
const [disableLiveComments, setDisableLiveComments] = useState(false)
useEffect(() => {
// preference: local storage
const read = () => setDisableLiveComments(window.localStorage.getItem(STORAGE_DISABLE_KEY) === 'true')
read()
// update across tabs
const onStorage = e => { if (e.key === STORAGE_DISABLE_KEY) read() }
// update this tab
const onToggle = () => read()
window.addEventListener('storage', onStorage)
window.addEventListener(TOGGLE_EVENT, onToggle)
return () => {
window.removeEventListener('storage', onStorage)
window.removeEventListener(TOGGLE_EVENT, onToggle)
}
}, [])
const toggle = useCallback(() => {
const current = window.localStorage.getItem(STORAGE_DISABLE_KEY) === 'true'
window.localStorage.setItem(STORAGE_DISABLE_KEY, !current)
// trigger local event to update this tab
window.dispatchEvent(new Event(TOGGLE_EVENT))
}, [])
return [disableLiveComments, toggle]
}

View File

@ -10,8 +10,7 @@ import { useWalletPayment } from '@/wallets/client/hooks'
this is just like useMutation with a few changes:
1. pays an invoice returned by the mutation
2. takes an onPaid and onPayError callback, and additional options for payment behavior
- namely forceWaitForPayment which will always wait for the invoice to be paid
- and persistOnNavigate which will keep the invoice in the cache after navigation
- persistOnNavigate which will keep the invoice in the cache after navigation
3. onCompleted behaves a little differently, but analogously to useMutation, ie clientside side effects
of completion can still rely on it
a. it's called before the invoice is paid for optimistic updates
@ -77,7 +76,7 @@ export function usePaidMutation (mutation,
// use the most inner callbacks/options if they exist
const {
onPaid, onPayError, forceWaitForPayment, persistOnNavigate,
onPaid, onPayError, persistOnNavigate,
update, waitFor = inv => inv?.actionState === 'PAID', updateOnFallback
} = { ...options, ...innerOptions }
const ourOnCompleted = innerOnCompleted || onCompleted
@ -107,7 +106,7 @@ export function usePaidMutation (mutation,
})
// should we wait for the invoice to be paid?
if (response?.paymentMethod === 'OPTIMISTIC' && !forceWaitForPayment) {
if (response?.paymentMethod === 'OPTIMISTIC') {
// onCompleted is called before the invoice is paid for optimistic updates
ourOnCompleted?.(data)
// don't wait to pay the invoice
@ -207,7 +206,8 @@ export const paidActionCacheMods = {
fields: {
actionState: () => 'PAID',
confirmedAt: () => new Date().toISOString(),
satsReceived: () => invoice.satsRequested
satsReceived: () => invoice.satsRequested,
hmac: () => invoice.hmac
}
})
}

View File

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

@ -15,3 +15,4 @@ Scroogey
SimpleStacker
klk
brymut
abhishandy

View File

@ -530,8 +530,9 @@ services:
- '--bitcoin-rpcuser=${RPC_USER}'
- '--bitcoin-rpcpassword=${RPC_PASS}'
- '--large-channels'
- '--rest-port=3010'
- '--rest-host=0.0.0.0'
- '--clnrest-port=3010'
- '--clnrest-host=0.0.0.0'
- '--clnrest-protocol=http'
expose:
- "9735"
ports:
@ -842,6 +843,27 @@ services:
CONNECT: "localhost:${LNBITS_WEB_PORT_V1}"
TORDIR: "/app/.tor"
cpu_shares: "${CPU_SHARES_LOW}"
lnpub:
image: ghcr.io/shocknet/lightning-pub@sha256:cd7bb9298d09a2cdaf1b6456ef6154e3ba24f7b902ad29cda2c08c2a4fa2af6e
container_name: lnpub
profiles:
- wallets
restart: unless-stopped
volumes:
- lnpub:/app/data
- lnd:/app/.lnd
environment:
- LND_ADDRESS=lnd:10009
- LND_CERT_PATH=/app/.lnd/tls.cert
- LND_MACAROON_PATH=/app/.lnd/data/chain/bitcoin/regtest/admin.macaroon
ports:
- ${LNPUB_PORT_1776:-1776}:1776
- ${LNPUB_PORT_1777:-1777}:1777
depends_on:
lnd:
condition: service_healthy
restart: true
cpu_shares: "${CPU_SHARES_LOW}"
dnsmasq:
image: 4km3/dnsmasq:2.90-r3
profiles:
@ -878,6 +900,7 @@ volumes:
tordata:
eclair:
dnsmasq:
lnpub:
networks:
default: {}

View File

@ -1,16 +1,5 @@
FROM polarlightning/clightning:23.08
FROM polarlightning/clightning:24.11
RUN apt-get update -y \
&& apt-get install -y jq wget \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN wget https://raw.githubusercontent.com/ElementsProject/lightning/v23.08/plugins/clnrest/requirements.txt \
&& pip install -r requirements.txt
# make sure that wallet and identity is persisted across rebuilds.
# server certificates contain stacker_lnd as a custom domain.
# see https://docs.corelightning.org/docs/grpc#generating-custom-certificates-optional
# since CLNRest in CLNv23.08 seems to use client certificates, they also contain stacker_lnd as a custom domain.
# see https://github.com/ElementsProject/lightning/tree/v23.08/plugins/clnrest#configuration
# make sure that wallet and identity is persisted across rebuilds
# https://docs.corelightning.org/docs/grpc#generating-custom-certificates-optional
COPY ["./hsm_secret", "./ca-key.pem", "./ca.pem", "./server-key.pem", "./server.pem", "./client-key.pem", "./client.pem", "/home/clightning/.lightning/regtest/"]

View File

@ -1 +1 @@
70A2D30FE991B24B5A6BF85421BE5EF083665E81
70A2D30FE991B24B5A6BF85421BE5EF083665E84

View File

@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDJM3OX9henwgjZ
vBEGmnDEDuG5XXEAzYTvd3Jz8eoexkTXnXRAJdngm0gVsFBFwbGOrn4rJH1Nc5WA
xE6Z+ZIW4DjvQi8hhPQHZpWesuRrBXCdF+3dUWdCYHGSA/c+ndcQx7WZkZpCpOaT
X8B2MhfdgfuoEhuGgL8oCauv/55oDsbQZliCti2SpXDt7azOenM7WiJbzMWemaJa
z3WbOsh2QZELfXWS1KmpN1jBmN7ImcbNV7IX/8zK0mZ2YvOQBY5H06m8q3lXtSFY
tMsZQuwYDNhpKXw9e6ey1GJWNZ4s8UhUG3OeLQuvL7o8XWW2oA9LplJF3dsy10cS
Mb/vrO3JAgMBAAECggEAEaAFN0GijtqLhe9Ymnh6+mHHWkKaPJTEWWngR9HgTXPW
4gB0B46JSIixxtycG5A9Kp+Ug9j9dQR0C0SnEgvvxTs9IZBtqoUID4HoB2/qXIms
dMZ82s9fuUvThxP726teBKVJ/jroBYCGhlBz/qaiuo/BaNa1PJIYrLw0IwXEbZ79
+LqtdoUTOb+ZzI04aZR82OSrOGbz7cE8uM8uNAM+xrbM11L1IUGLoLZoUyvJww4v
F5ZDWBe8+3D50ypCdmrL805YW1scySnA3YNLyXlfl6jE0AeYd+ZOVGhH4MfkdwXF
O/yGTF3UEhTAdtryXQw7JiLA0qTakkLQeQ8ydLZzgQKBgQDqppULwJADJsnI5b3x
HOklQEcmg7ySMLkVmyDEL7vcesHnsUNjCWwqSF4nR4eQJcM3zfvkjZN4sgUw8aCm
W3G+v4mFA24xEVcuoA9EXUZuMc6XRVchV/EdgIbVCNH9+ATKkFMiaCQu11QmjkR+
da4+a6bo+L6Zl9ifVoJTb/0jeQKBgQDbgcSJwDUo6b9n0Dkkwpsj6fj7Wa7jLW9a
8Bz3pJj5AUUWjTQ17tLl7WY8EFYGlUjie6n5lM8vQZrPCnw4ZUKBAIoxIiRwQt1j
tFBjP+ztJ2AUER6hBF0uFUqNNdrlryOBegnxelfb//9+SyzICjRLChiyUxEzSVSY
T2Maz6S40QKBgHBABjbcBEhtqsPfG3EXanS2fhLvnCq3AiNS1WbkitLbKp1ikCD1
ZfgILHpP3orXdb7hW+mmzHBFgPQ78qUCQ7SDPg8SaAkzCWi1ivgiQsn+K5zzv82k
mySI0ndgw8vhdLFOP2bLONvriEb1cdCpDRSxPORf8hXZrPf0U14EyazJAoGAKPkC
/5dyFM303WLfl73/iWeeAwTNgTg05euV7J+7shcLl1cKHNsUYLi8cY+3DwmEjkn5
A05EkhST2fuiDkDQdhXstZki5hWFD5xTuQLwrZ/A7l33sqSG5BgzT0JzNpZHcV6f
RoTIq5cQULmlhT1qX2tmCrs4pbMVaEzBOfryS1ECgYBgY+XR3ggvagkM/5jSeCT4
45M0UChNAFWcSVUyaDSWlu5ec/hJ7hgUsA7Wz9j8upSQZf5fKVt/k+J45WHdTNyH
HTcO37iFjNwRLnIEdiBoQSBEkTBChkcmxpSoMGryZEu1Ng93WIxppvlLzKeviidf
HVqTsNNjnv2JqMKD6rxDlw==
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7LKm+zhlA/q9K
tlmjLsLCSSFla0SsJmde1BQfnIZvjl093Rt1pVpRCENWjYfDub6f1ceLmWpi9fCF
PRPwRUpE/NATIWJlSO7/DHBsXRVMQiM7E2hRe16/z3OK7QV93cIPnNMq+JCeKYby
I8qh+bLV61w611iCGaf2EjKmtHazKES3qGeO4/8IeLAv+Y7zsSfsErz8J8lOuM+M
pMIl7ulSEK4DILJ2lc/f0/Com5JVjpe/tm4u+GuM67NKDt6WtNA5qTn+JZDWSOJ3
rtZ/+FfleAf5VH4eJ/TZKSeA0PlzSl06Suvu+R+MKT0llHl9yDfk7Afg/prABykC
3OoeNLxBAgMBAAECggEAAukH3qKfu+X53cSGEkZ42sJ+VXBcifyy4MOVaIRrhrKE
+qBEfAjNJbNmMKNUuBNcRmnxh1ckU1OVoMy5UaQSKo5vwcxkFkUTCj4sRVMRMLVa
jOGZXqL3by4PktpqmnFnQgzjL6jbvsnQglVSIkCaqj7VmUid5K/3b5kK1pK5wcFY
tYgGsyBKLeiilIxMG5btXZhevnaoKSNo38Msy9rOx7WnOurw5rOmbjwjd2DxsiYl
I5+FBBZ+oTwhob3BETQK8NhtUHAD2dFPZ0THjjU9z7n+U5C1mxpqbEytJuBllZ7g
7QEsYb9a8cJ4PlJUFD9XK+9Wx7vaz+y/3+EJctY2EQKBgQDqQwGyhwCRyND0CSqn
n98rf5hgxMfdzAkTP9KUVarL8JA52lR9wNUSTecuaFkduTWel+gOb23dVpy9OOqt
4Uhc4ORNh/b6cnAomVfWK0gmPmBHzKBgL5On7E+HrM4j2i4HQuOFZY1sWUznWHcK
Gzp1HS33NQ1+Bqa8kK0dPT/7bQKBgQDMixOude2men0noXAGwlnpMwNx3RVmaWum
Zm8OJsmbnjCULEWYCMU6tbUPwAfmpO3g2EDF2EjOF3exg5qQ8G8tiKk/ptZVnZaZ
EEm6/tHafC6ZWGh0ZqkFJVnjROzcoMhJ79eaDuNcfyErpceqh6FAUwuDtxZoZhRa
MyasgJILpQKBgQDFuDxshAl1AjtqXXpE9GiV/CGOO0g96YIXXxzK2etIKghw69e4
M9MimeHvh17/+VxKOQhBdscRs5KjGrDohWZgDehjj8hi5cfIXHSIbQt+S9NThmAu
DrnfV1JgvvdVx0ZmFoyWAHp24oBIGqCORSD3y+MJ7RswLUj5ilqyfQnNmQKBgCD5
Rqn4PuB4nJZ38vxT9nSeka6YamvBEOaZWsvYOuYIYWJxpKJ2v3zQcCji1yM6cVKu
6fo6/LmklNocEh3NdM7NWiN1vNW+etmgA5LOo3vqSwTTeLtTFWpL9CqsINcMYF1y
+bnPLp3prKpBpmd6R+d7u753FHiuBBfqaRCkBWc1AoGBANZY0EBBkEcdodTjT4AP
22BRZwg9duUa6CObeNNjKTUhX/ClVXQDJRG0jEwz4fT5wWMBKZ4NZhaMGIhNciKg
OdeU2Y3tGHXemvJaNiYg88mkXZzlNeN5CRR5BASDfvP9Chs7/48ZCfgQMn25sbB6
sXJd+BDdOkuQADolHWjseeqp
-----END PRIVATE KEY-----

View File

@ -1,16 +1,16 @@
-----BEGIN CERTIFICATE REQUEST-----
MIICmDCCAYACAQAwUzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx
ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEMMAoGA1UEAwwDY2xu
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyTNzl/YXp8II2bwRBppw
xA7huV1xAM2E73dyc/HqHsZE1510QCXZ4JtIFbBQRcGxjq5+KyR9TXOVgMROmfmS
FuA470IvIYT0B2aVnrLkawVwnRft3VFnQmBxkgP3Pp3XEMe1mZGaQqTmk1/AdjIX
3YH7qBIbhoC/KAmrr/+eaA7G0GZYgrYtkqVw7e2sznpzO1oiW8zFnpmiWs91mzrI
dkGRC311ktSpqTdYwZjeyJnGzVeyF//MytJmdmLzkAWOR9OpvKt5V7UhWLTLGULs
GAzYaSl8PXunstRiVjWeLPFIVBtzni0Lry+6PF1ltqAPS6ZSRd3bMtdHEjG/76zt
yQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAIHrxnnPdlZL+ODOIajoqyGWxNju
Tigv0sKW6T+ty6sSJaHlHJynGAKKXRz8NzyUpeAwaBXTSU9Ca3PSCs4vvAvvamGT
nqEUeb9YhLe4pkBBUtpQrml6ixotids1PFf38f6965Z34S3xivucBRrGnB+hGFEz
QKgMnQmUan9AWOqAQEzWU4sVkNzYtH1sjtRLSIVyoWD/8DiBlEL0poKq6/NjnT1z
Q6kZh5uGbeViZ9VzCM1xJVpVP1Z1oVZkMHndKf4I03IRWhW34ddhQJcja9nr2wdU
m1Pv78262l5B0YdXTd3C73i70k7GqSusKCW+HvQAy8DmEDRxJRLGTpYNsrc=
MIICkTCCAXkCAQAwGjEYMBYGA1UEAwwPY2xuIHJlc3Qgc2VydmVyMIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuyypvs4ZQP6vSrZZoy7CwkkhZWtErCZn
XtQUH5yGb45dPd0bdaVaUQhDVo2Hw7m+n9XHi5lqYvXwhT0T8EVKRPzQEyFiZUju
/wxwbF0VTEIjOxNoUXtev89ziu0Ffd3CD5zTKviQnimG8iPKofmy1etcOtdYghmn
9hIyprR2syhEt6hnjuP/CHiwL/mO87En7BK8/CfJTrjPjKTCJe7pUhCuAyCydpXP
39PwqJuSVY6Xv7ZuLvhrjOuzSg7elrTQOak5/iWQ1kjid67Wf/hX5XgH+VR+Hif0
2SkngND5c0pdOkrr7vkfjCk9JZR5fcg35OwH4P6awAcpAtzqHjS8QQIDAQABoDIw
MAYJKoZIhvcNAQkOMSMwITAfBgNVHREEGDAWhwR/AAABgglsb2NhbGhvc3SCA2Ns
bjANBgkqhkiG9w0BAQsFAAOCAQEAjNRTLpPVllB0talokK7HalVAfs/SBL4dUAAB
aCpe9Q8MNlSBORgedRwfzecxpNtJ+Tb5m9nIw6vfnfRZ6mxKLW7wPyOG6t83Pivc
v30rPV/wbW2DvPe8dj8K2Fyh8lWWjJCMSar1ZvFsImwXdEzCOdEU3wUYOPS+hT7+
6walQ/L2jbWtmfUibbXECoekqg/9WSpVik4iq9oBbJF6V6gk2VKskioDTX3eAC07
6/P59CZJdE6vp5kjR868jt5VxTDa5MdJ8d2QFPJqZ9TmduZ36camALXbMbbB+e2+
u1/ZX6DtGOnqkBWBbi3IVZeZks4lzUeXYp3/BzNeCp5F2Xfq0Q==
-----END CERTIFICATE REQUEST-----

View File

@ -1,14 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICLzCCAdUCFHCi0w/pkbJLWmv4VCG+XvCDZl6BMAoGCCqGSM49BAMCMBYxFDAS
BgNVBAMMC2NsbiBSb290IENBMB4XDTI0MTEyMDE4MDgxMVoXDTI1MTEyMDE4MDgx
MVowUzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEMMAoGA1UEAwwDY2xuMIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyTNzl/YXp8II2bwRBppwxA7huV1xAM2E
73dyc/HqHsZE1510QCXZ4JtIFbBQRcGxjq5+KyR9TXOVgMROmfmSFuA470IvIYT0
B2aVnrLkawVwnRft3VFnQmBxkgP3Pp3XEMe1mZGaQqTmk1/AdjIX3YH7qBIbhoC/
KAmrr/+eaA7G0GZYgrYtkqVw7e2sznpzO1oiW8zFnpmiWs91mzrIdkGRC311ktSp
qTdYwZjeyJnGzVeyF//MytJmdmLzkAWOR9OpvKt5V7UhWLTLGULsGAzYaSl8PXun
stRiVjWeLPFIVBtzni0Lry+6PF1ltqAPS6ZSRd3bMtdHEjG/76ztyQIDAQABMAoG
CCqGSM49BAMCA0gAMEUCIDExbaYoitsXHdu8wgVlv7LozbNK9Te6t292ctH0dZCy
AiEAjg/GqpNIv01ACeK/5+HVLtba8TL6vBd9dENR4ADDoj4=
MIICYDCCAgagAwIBAgIUcKLTD+mRsktaa/hUIb5e8INmXoQwCgYIKoZIzj0EAwIw
FjEUMBIGA1UEAwwLY2xuIFJvb3QgQ0EwHhcNMjUwOTAxMjMzOTE5WhcNMjYwOTAx
MjMzOTE5WjAaMRgwFgYDVQQDDA9jbG4gcmVzdCBzZXJ2ZXIwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQC7LKm+zhlA/q9KtlmjLsLCSSFla0SsJmde1BQf
nIZvjl093Rt1pVpRCENWjYfDub6f1ceLmWpi9fCFPRPwRUpE/NATIWJlSO7/DHBs
XRVMQiM7E2hRe16/z3OK7QV93cIPnNMq+JCeKYbyI8qh+bLV61w611iCGaf2EjKm
tHazKES3qGeO4/8IeLAv+Y7zsSfsErz8J8lOuM+MpMIl7ulSEK4DILJ2lc/f0/Co
m5JVjpe/tm4u+GuM67NKDt6WtNA5qTn+JZDWSOJ3rtZ/+FfleAf5VH4eJ/TZKSeA
0PlzSl06Suvu+R+MKT0llHl9yDfk7Afg/prABykC3OoeNLxBAgMBAAGjYzBhMB8G
A1UdEQQYMBaHBH8AAAGCCWxvY2FsaG9zdIIDY2xuMB0GA1UdDgQWBBS2TbrQ7FiH
i01mjkx67gh+nBwX8zAfBgNVHSMEGDAWgBSEcmN/9rzS2hR6G7EIgsX+51N0CjAK
BggqhkjOPQQDAgNIADBFAiAA2ffEzwtVk5VnHbO00VWaEM3G08M8T0WwUmT1Vvy3
IAIhALOJl3d4R/PXiGcw8vRse8iRi+TmN3ZXB0QEfubSsfBP
-----END CERTIFICATE-----

16
docker/cln/csr.conf Normal file
View File

@ -0,0 +1,16 @@
[req]
default_bits = 2048
distinguished_name = req_distinguished_name
req_extensions = req_ext
prompt = no
[req_distinguished_name]
CN = "cln rest server"
[req_ext]
subjectAltName = @alt_names
[alt_names]
IP.1 = 127.0.0.1
DNS.1 = localhost
DNS.2 = cln

View File

@ -1,16 +1,16 @@
-----BEGIN CERTIFICATE REQUEST-----
MIICmDCCAYACAQAwUzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx
ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEMMAoGA1UEAwwDY2xu
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt+KCYNwj+YJNQXlJwuFi
MvJK5nU1DOcp9dvo4gvBClzwOeaRPsB4qnkWLxZqQ8+/q+LMD7P+QUXHqI49b8SF
m7FFEpCL5GEN090F1+GIEUBgEqMPHP3QRfyhnI4hmo0fgGArI7BMhBZrYH7nRKSN
oEgpWd8dtm9NR/jx7j9sWXtFx6LZoM6yZZXVy6DdDGNk7WR0o98fR3NtT+OzUAkC
MJR45aT7SYUNadCVNcFKsJqVMq0HDoa4sW1u184Fg5Bx38+gDXGn4VsDXwVXNMNx
mGNY7ADn+AiRq0MLGDu6B5a+Raqt+LAlvvsqbFww8wtWUHQ2gnvXZBEVS+Wi2U7V
pQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAD1yimwCgeFZwkSyzFYrzf9e68L8
Or8Fo7w5Wa1dtiGLz8hyHL5hephUgR5xA92VJ6zIXFXWYMfOvEFvRPCmXMjnS1Y5
hxfLo+6NJ+U4qX301Dd3NEKCDqxYQIoRVxY+fqZBYn7vbVwimmdf3Epn8CX/tvSq
49YWkq2yRDUrcOzvvt6wfG6Gv1N2Igra2op3A0LywLXkPYNFmvMOvXzA9/Tk5dGb
LNH1aIYbgMgA9XL7g1XVHqOqIBjqTukwVUJ6o/MVx8T9eoqZQRfV1s92BkhfnugS
XQ16AO45gXNnjz0T1OjXLHzYpXIlSneo1BMIh9/eAi3d5PihBk2ss8eo3U4=
MIICkTCCAXkCAQAwGjEYMBYGA1UEAwwPY2xuIHJlc3Qgc2VydmVyMIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt+KCYNwj+YJNQXlJwuFiMvJK5nU1DOcp
9dvo4gvBClzwOeaRPsB4qnkWLxZqQ8+/q+LMD7P+QUXHqI49b8SFm7FFEpCL5GEN
090F1+GIEUBgEqMPHP3QRfyhnI4hmo0fgGArI7BMhBZrYH7nRKSNoEgpWd8dtm9N
R/jx7j9sWXtFx6LZoM6yZZXVy6DdDGNk7WR0o98fR3NtT+OzUAkCMJR45aT7SYUN
adCVNcFKsJqVMq0HDoa4sW1u184Fg5Bx38+gDXGn4VsDXwVXNMNxmGNY7ADn+AiR
q0MLGDu6B5a+Raqt+LAlvvsqbFww8wtWUHQ2gnvXZBEVS+Wi2U7VpQIDAQABoDIw
MAYJKoZIhvcNAQkOMSMwITAfBgNVHREEGDAWhwR/AAABgglsb2NhbGhvc3SCA2Ns
bjANBgkqhkiG9w0BAQsFAAOCAQEAWSvrwxJTRyK9mDwft4qsCCStS3Gunh7OSGeG
da+Lcrk4NABr3SnWwl/k3HEm2ZNt4/k0eDB95ElvUUcXoC5ptlyuVup97GZyO2VG
SqLcMlL3/vuGpkV6LGAbiLbcKKqBWwV5mP+bThMRfLJdiUpjoTrjiaHElfu8Be4e
qKzHYKbmSn7I7eDixsSsySPVL8h+YIamMH68BNi2AEJJH+Yyd/J7qZbP0zu9X2lQ
I09gdw49FEjx38Se8Jol7GRdzJnqFElnRHKXpR+FwtmsFMh+4Syc9Zz1fgOuBA6B
HR+OGRqO2jT+aINPB0x+GgKWvjt/DXxvF0tq42Q8/P4i9in95w==
-----END CERTIFICATE REQUEST-----

View File

@ -1,14 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICLzCCAdUCFHCi0w/pkbJLWmv4VCG+XvCDZl6AMAoGCCqGSM49BAMCMBYxFDAS
BgNVBAMMC2NsbiBSb290IENBMB4XDTI0MTEyMDE4MDE0NloXDTI1MTEyMDE4MDE0
NlowUzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEMMAoGA1UEAwwDY2xuMIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt+KCYNwj+YJNQXlJwuFiMvJK5nU1DOcp
9dvo4gvBClzwOeaRPsB4qnkWLxZqQ8+/q+LMD7P+QUXHqI49b8SFm7FFEpCL5GEN
090F1+GIEUBgEqMPHP3QRfyhnI4hmo0fgGArI7BMhBZrYH7nRKSNoEgpWd8dtm9N
R/jx7j9sWXtFx6LZoM6yZZXVy6DdDGNk7WR0o98fR3NtT+OzUAkCMJR45aT7SYUN
adCVNcFKsJqVMq0HDoa4sW1u184Fg5Bx38+gDXGn4VsDXwVXNMNxmGNY7ADn+AiR
q0MLGDu6B5a+Raqt+LAlvvsqbFww8wtWUHQ2gnvXZBEVS+Wi2U7VpQIDAQABMAoG
CCqGSM49BAMCA0gAMEUCIDcuZHxYHgEr0PHIR6JJF72T7c1JccvIvjl0JIqjjwwq
AiEAtzbdnTMuJP16csHt+RrSsIVGUy5G5byI/M0RtIwyQGQ=
MIICXzCCAgagAwIBAgIUcKLTD+mRsktaa/hUIb5e8INmXoIwCgYIKoZIzj0EAwIw
FjEUMBIGA1UEAwwLY2xuIFJvb3QgQ0EwHhcNMjUwOTAxMjMyNDMxWhcNMjYwOTAx
MjMyNDMxWjAaMRgwFgYDVQQDDA9jbG4gcmVzdCBzZXJ2ZXIwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQC34oJg3CP5gk1BeUnC4WIy8krmdTUM5yn12+ji
C8EKXPA55pE+wHiqeRYvFmpDz7+r4swPs/5BRceojj1vxIWbsUUSkIvkYQ3T3QXX
4YgRQGASow8c/dBF/KGcjiGajR+AYCsjsEyEFmtgfudEpI2gSClZ3x22b01H+PHu
P2xZe0XHotmgzrJlldXLoN0MY2TtZHSj3x9Hc21P47NQCQIwlHjlpPtJhQ1p0JU1
wUqwmpUyrQcOhrixbW7XzgWDkHHfz6ANcafhWwNfBVc0w3GYY1jsAOf4CJGrQwsY
O7oHlr5Fqq34sCW++ypsXDDzC1ZQdDaCe9dkERVL5aLZTtWlAgMBAAGjYzBhMB8G
A1UdEQQYMBaHBH8AAAGCCWxvY2FsaG9zdIIDY2xuMB0GA1UdDgQWBBRJm0s9JXB+
zXHKjB4oTDS31a+h2zAfBgNVHSMEGDAWgBSEcmN/9rzS2hR6G7EIgsX+51N0CjAK
BggqhkjOPQQDAgNHADBEAiBfwGNj4RZBTmIb44Nk1nTD/r7fIkEeDgkgxkOiAiQ9
twIgK+vJ3dxP21cH5ye2ODbMqe0RC/3FoyJegqIUocvTu6s=
-----END CERTIFICATE-----

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -8,7 +8,7 @@ sub: meta
_To quickly browse through this FAQ page, click the chapters icon in the top-right corner. This will let you scroll through all chapters or search for a particular topic within this page._
last updated: July 16, 2025
last updated: August 13, 2025
---
@ -46,7 +46,7 @@ See the [section about territories](#territories) for details.
### Do I need bitcoin to use Stacker News?
No. Every new stacker can post or comment for free (with limited visibility) while they earn their first few CCs or sats. After a stacker has gained a balance, subsequent posts and comments will incur a small fee to prevent spam and to encourage quality contributions. Many stackers earn enough from their posts and comments to continue posting on the site indefinitely without ever buying CCs with sats.
No. You don't need bitcoin to get started. You can create your bio for free (with limited visibility) and earn Cowboy Credits (CCs) from zaps to cover fees. Posts and comments incur a small fee to prevent spam and to encourage quality contributions. Many stackers earn enough from their posts and comments to continue posting on the site indefinitely without ever buying CCs with sats.
[Post and comment fees vary depending on the territory](#why-does-it-cost-more-to-post-in-some-territories).

View File

@ -47,7 +47,7 @@ export const COMMENT_FIELDS = gql`
otsHash
ncomments
nDirectComments
injected @client
live @client
imgproxyUrls
rel
apiKey
@ -55,6 +55,7 @@ export const COMMENT_FIELDS = gql`
id
actionState
confirmedAt
hmac
}
cost
}
@ -94,6 +95,7 @@ export const COMMENT_FIELDS_NO_CHILD_COMMENTS = gql`
commentCredits
mine
otsHash
live @client
imgproxyUrls
rel
apiKey
@ -101,6 +103,7 @@ export const COMMENT_FIELDS_NO_CHILD_COMMENTS = gql`
id
actionState
confirmedAt
hmac
}
cost
}
@ -152,6 +155,16 @@ export const COMMENTS = gql`
comments {
comments {
...CommentFields
comments {
comments {
...CommentFields
comments {
comments {
...CommentFields
}
}
}
}
}
}
}
@ -164,48 +177,19 @@ export const COMMENTS = gql`
}
}`
export const COMMENT_WITH_NEW_RECURSIVE = gql`
${COMMENT_FIELDS}
${COMMENTS}
fragment CommentWithNewRecursive on Item {
...CommentFields
comments {
comments {
...CommentsRecursive
}
}
}
`
export const COMMENT_WITH_NEW_LIMITED = gql`
${COMMENT_FIELDS}
fragment CommentWithNewLimited on Item {
...CommentFields
comments {
comments {
...CommentFields
}
}
}
`
export const COMMENT_WITH_NEW_MINIMAL = gql`
${COMMENT_FIELDS}
fragment CommentWithNewMinimal on Item {
...CommentFields
export const HAS_COMMENTS = gql`
fragment HasComments on Item {
comments
}
`
export const GET_NEW_COMMENTS = gql`
${COMMENTS}
query GetNewComments($rootId: ID, $after: Date) {
newComments(rootId: $rootId, after: $after) {
${COMMENT_FIELDS_NO_CHILD_COMMENTS}
query GetNewComments($itemId: ID, $after: Date) {
newComments(itemId: $itemId, after: $after) {
comments {
...CommentsRecursive
...CommentFieldsNoChildComments
}
}
}

View File

@ -81,6 +81,7 @@ export const ITEM_FIELDS = gql`
confirmedAt
}
cost
meCommentsViewedAt
}`
export const ITEM_FULL_FIELDS = gql`
@ -91,12 +92,15 @@ export const ITEM_FULL_FIELDS = gql`
text
root {
id
createdAt
title
bounty
bountyPaidTo
subName
mine
ncomments
lastCommentAt
meCommentsViewedAt
user {
id
name
@ -211,3 +215,9 @@ export const RELATED_ITEMS_WITH_ITEM = gql`
}
}
`
export const UPDATE_ITEM_USER_VIEW = gql`
mutation updateCommentsViewAt($id: ID!, $meCommentsViewedAt: Date!) {
updateCommentsViewAt(id: $id, meCommentsViewedAt: $meCommentsViewedAt)
}
`

View File

@ -131,11 +131,6 @@ export const SET_SETTINGS = gql`
}
}`
export const DELETE_WALLET = gql`
mutation removeWallet {
removeWallet
}`
export const NAME_QUERY = gql`
query nameAvailable($name: String!) {
nameAvailable(name: $name)

View File

@ -324,9 +324,9 @@ function getClient (uri) {
}
}
},
injected: {
read (injected) {
return injected || false
live: {
read (live) {
return live || false
}
},
meAnonSats: {

View File

@ -196,11 +196,10 @@ export async function multiAuthMiddleware (req, res) {
}
const ok = await checkMultiAuthCookies(req, res)
if (!ok) {
if (ok) {
await refreshMultiAuthCookies(req, res)
} else {
await resetMultiAuthCookies(req, res)
return switchSessionCookie(req)
}
await refreshMultiAuthCookies(req, res)
return switchSessionCookie(req)
}

View File

@ -1,8 +1,8 @@
import fetch from 'cross-fetch'
import crossFetch from 'cross-fetch'
import crypto from 'crypto'
import { getAgent } from '@/lib/proxy'
import { assertContentTypeJson, assertResponseOk } from './url'
import { FetchTimeoutError } from './fetch'
import { fetchWithTimeout, FetchTimeoutError } from './fetch'
import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from './constants'
export const createInvoice = async ({ msats, description, expiry }, { socket, rune, cert }, { signal }) => {
@ -13,15 +13,9 @@ export const createInvoice = async ({ msats, description, expiry }, { socket, ru
const method = 'POST'
let res
try {
res = await fetch(url, {
res = await crossFetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Rune: rune,
// can be any node id, only required for CLN v23.08 and below
// see https://docs.corelightning.org/docs/rest#server
nodeId: '02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490'
},
headers: headers(rune),
agent,
body: JSON.stringify({
// CLN requires a unique label for every invoice
@ -52,6 +46,42 @@ export const createInvoice = async ({ msats, description, expiry }, { socket, ru
return inv
}
export const sendPayment = async (bolt11, { socket, rune }, { signal }) => {
// XXX we don't ask for the CA certificate because the browser's fetch API doesn't support http agents to override it.
// Therefore, CLNRest send will only work with common CA certificates.
// API documentation
// https://docs.corelightning.org/reference/pay
const url = new URL(
'/v1/pay',
process.env.NODE_ENV === 'development' ? `http://${socket}` : `https://${socket}`)
const method = 'POST'
const res = await fetchWithTimeout(url, {
method,
headers: headers(rune),
body: JSON.stringify({ bolt11 }),
signal
})
assertResponseOk(res, { method })
assertContentTypeJson(res, { method })
const result = await res.json()
if (result.error) {
throw new Error(result.error.message)
}
return result.payment_preimage
}
function headers (rune) {
const headers = new Headers()
headers.append('Content-Type', 'application/json')
headers.append('Rune', rune)
// can be any node id, only required for CLN v23.08 and below
// see https://docs.corelightning.org/docs/rest#server
headers.append('nodeId', '02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490')
return headers
}
// https://github.com/clams-tech/rune-decoder/blob/57c2e76d1ef9ab7336f565b99de300da1c7b67ce/src/index.ts
export const decodeRune = (rune) => {
const runeBinary = Base64Binary.decode(rune)

View File

@ -1,91 +1,87 @@
import { COMMENT_WITH_NEW_RECURSIVE, COMMENT_WITH_NEW_LIMITED, COMMENT_WITH_NEW_MINIMAL } from '../fragments/comments'
import { ITEM_FULL } from '../fragments/items'
import { COMMENTS, HAS_COMMENTS } from '../fragments/comments'
// updates the ncomments field of all ancestors of an item/comment in the cache
export function updateAncestorsCommentCount (cache, ancestors, increment) {
// update all ancestors
// adds a comment to the cache, under its parent item
function cacheComment (cache, newComment, { live = false }) {
return cache.modify({
id: `Item:${newComment.parentId}`,
fields: {
comments: (existingComments = {}, { readField }) => {
// if the comment already exists, return
if (existingComments?.comments?.some(c => readField('id', c) === newComment.id)) return existingComments
// we need to make sure we're writing a fragment that matches the comments query (comments and count fields)
const newCommentRef = cache.writeFragment({
data: {
comments: {
comments: []
},
ncomments: 0,
nDirectComments: 0,
...newComment,
live
},
fragment: COMMENTS,
fragmentName: 'CommentsRecursive'
})
return {
cursor: existingComments.cursor,
comments: [newCommentRef, ...(existingComments?.comments || [])]
}
}
},
optimistic: true
})
}
// handles cache injection and side-effects for both live and non-live comments
export function injectComment (cache, newComment, { live = false, rootId } = {}) {
// if live and a reply (not top level), check if the parent has comments
const hasComments = live && !(Number(rootId) === Number(newComment.parentId))
? !!(cache.readFragment({
id: `Item:${newComment.parentId}`,
fragment: HAS_COMMENTS
}))
// if not live, we can assume the parent has the comments field since user replied to it
: true
const updated = hasComments && cacheComment(cache, newComment, { live })
// run side effects if injection succeeded or if injecting live comment into SSR item without comments field
if (updated || (live && !hasComments)) {
// update all ancestors comment count, excluding the comment itself
const ancestors = newComment.path.split('.').slice(0, -1)
updateAncestorsCommentCount(cache, ancestors)
return true
}
return false
}
// updates the ncomments and nDirectComments fields of all ancestors of an item/comment in the cache
function updateAncestorsCommentCount (cache, ancestors, { ncomments = 1, nDirectComments = 1 } = {}) {
// update nDirectComments of immediate parent
cache.modify({
id: `Item:${ancestors[ancestors.length - 1]}`,
fields: {
nDirectComments (existingNDirectComments = 0) {
return existingNDirectComments + nDirectComments
}
},
optimistic: true
})
// update ncomments of all ancestors
ancestors.forEach(id => {
cache.modify({
id: `Item:${id}`,
fields: {
ncomments (existingNComments = 0) {
return existingNComments + increment
return existingNComments + ncomments
}
},
optimistic: true
})
})
}
// updates the item query in the cache
// this is used by live comments to update a top level item's comments field
export function updateItemQuery (cache, id, sort, fn) {
cache.updateQuery({
query: ITEM_FULL,
// updateQuery needs the correct variables to update the correct item
// the Item query might have the router.query.sort in the variables, so we need to pass it in if it exists
variables: sort ? { id, sort } : { id }
}, (data) => {
if (!data) return data
return { item: fn(data.item) }
})
}
// updates a comment fragment in the cache, with fallbacks for comments lacking CommentsRecursive or Comments altogether
export function updateCommentFragment (cache, id, fn) {
let result = cache.updateFragment({
id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_RECURSIVE,
fragmentName: 'CommentWithNewRecursive'
}, (data) => {
if (!data) return data
return fn(data)
})
// sometimes comments can start to reach their depth limit, and lack adherence to the CommentsRecursive fragment
// for this reason, we update the fragment with a limited version that only includes the CommentFields fragment
if (!result) {
result = cache.updateFragment({
id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_LIMITED,
fragmentName: 'CommentWithNewLimited'
}, (data) => {
if (!data) return data
return fn(data)
})
}
// at the deepest level, the comment can't have any children, here we update only the newComments field.
if (!result) {
result = cache.updateFragment({
id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_MINIMAL,
fragmentName: 'CommentWithNewMinimal'
}, (data) => {
if (!data) return data
return fn(data)
})
}
return result
}
export function calculateDepth (path, rootId, parentId) {
// calculate depth by counting path segments from root to parent
const pathSegments = path.split('.')
const rootIndex = pathSegments.indexOf(rootId.toString())
const parentIndex = pathSegments.indexOf(parentId.toString())
// depth is the distance from root to parent in the path
const depth = parentIndex - rootIndex
return depth
}
// finds the most recent createdAt timestamp from an array of comments
export function getLatestCommentCreatedAt (comments, latest) {
return comments.reduce(
(max, { createdAt }) => (createdAt > max ? createdAt : max),
latest
)
}

View File

@ -42,7 +42,7 @@ export const BOUNTY_MAX = 10000000
export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL']
export const TERRITORY_BILLING_TYPES = ['MONTHLY', 'YEARLY', 'ONCE']
export const TERRITORY_GRACE_DAYS = 5
export const COMMENT_DEPTH_LIMIT = 6
export const COMMENT_DEPTH_LIMIT = 8
export const COMMENTS_LIMIT = 50
export const FULL_COMMENTS_THRESHOLD = 200
export const COMMENTS_OF_COMMENT_LIMIT = 2
@ -59,13 +59,14 @@ export const INV_PENDING_LIMIT = 100
export const USER_ID = {
k00b: 616,
ek: 6030,
sox: 26458,
sn: 4502,
anon: 27,
ad: 9,
delete: 106,
saloon: 17226
}
export const SN_ADMIN_IDS = [USER_ID.k00b, USER_ID.ek, USER_ID.sn]
export const SN_ADMIN_IDS = [USER_ID.k00b, USER_ID.ek, USER_ID.sox, USER_ID.sn]
export const SN_NO_REWARDS_IDS = [USER_ID.anon, USER_ID.sn, USER_ID.saloon]
export const MAX_POLL_NUM_CHOICES = 10
export const MIN_POLL_NUM_CHOICES = 2
@ -183,16 +184,18 @@ export const LOST_BLURBS = {
export const ADMIN_ITEMS = [
// FAQ, old privacy policy, changelog, content guidelines, tos, new privacy policy, copyright policy
349, 76894, 78763, 81862, 338393, 338369, 338453
349, 76894, 78763, 81862, 338393, 338369, 338453,
// wallet guides: LNbits, phoenixd, Alby Hub, Coinos
1212223, 1212375, 1215565, 1230105
]
export const INVOICE_RETENTION_DAYS = 7
export const JIT_INVOICE_TIMEOUT_MS = 180_000
export const FAST_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL)
export const NORMAL_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL)
export const LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL)
export const EXTRA_LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL)
export const FAST_POLL_INTERVAL_MS = Number(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL_MS)
export const NORMAL_POLL_INTERVAL_MS = Number(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS)
export const LONG_POLL_INTERVAL_MS = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL_MS)
export const EXTRA_LONG_POLL_INTERVAL_MS = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL_MS)
export const ZAP_UNDO_DELAY_MS = 5_000

View File

@ -22,7 +22,14 @@ export function commentsViewedAfterComment (rootId, createdAt, ncomments = 1) {
}
export function newComments (item) {
if (!item.parentId) {
if (!item.parentId && item.lastCommentAt) {
// if logged, prefer server-tracked view
if (item.meCommentsViewedAt) {
const viewedAt = new Date(item.meCommentsViewedAt).getTime()
return viewedAt < new Date(item.lastCommentAt).getTime()
}
// anon fallback
const viewedAt = commentsViewedAt(item.id)
const viewNum = commentsViewedNum(item.id)

61
lib/remark-toc.js Normal file
View File

@ -0,0 +1,61 @@
import { SKIP, visit } from 'unist-util-visit'
import { extractHeadings } from './toc'
export default function remarkToc () {
return function transformer (tree) {
const headings = extractHeadings(tree)
visit(tree, 'paragraph', (node, index, parent) => {
if (
node.children?.length === 1 &&
node.children[0].type === 'text' &&
node.children[0].value.trim() === '{:toc}'
) {
parent.children.splice(index, 1, buildToc(headings))
return [SKIP, index]
}
})
}
}
function buildToc (headings) {
const root = { type: 'list', ordered: false, spread: false, children: [] }
const stack = [{ depth: 0, node: root }] // holds the current chain of parents
for (const { heading, slug, depth } of headings) {
// walk up the stack to find the parent of the current heading
while (stack.length && depth <= stack[stack.length - 1].depth) {
stack.pop()
}
let parent = stack[stack.length - 1].node
// if the parent is a li, gets its child ul
if (parent.type === 'listItem') {
let ul = parent.children.find(c => c.type === 'list')
if (!ul) {
ul = { type: 'list', ordered: false, spread: false, children: [] }
parent.children.push(ul)
}
parent = ul
}
// build the li from the current heading
const listItem = {
type: 'listItem',
spread: false,
children: [{
type: 'paragraph',
children: [{
type: 'link',
url: `#${slug}`,
children: [{ type: 'text', value: heading }]
}]
}]
}
parent.children.push(listItem)
stack.push({ depth, node: listItem })
}
return root
}

23
lib/toc.js Normal file
View File

@ -0,0 +1,23 @@
import { fromMarkdown } from 'mdast-util-from-markdown'
import { visit } from 'unist-util-visit'
import { toString } from 'mdast-util-to-string'
import { slug } from 'github-slugger'
export function extractHeadings (markdownOrTree) {
const tree = typeof markdownOrTree === 'string'
? fromMarkdown(markdownOrTree)
: markdownOrTree
const headings = []
visit(tree, 'heading', node => {
const str = toString(node)
headings.push({
heading: str,
slug: slug(str.replace(/[^\w\-\s]+/gi, '')),
depth: node.depth
})
})
return headings
}

View File

@ -78,6 +78,17 @@ export function parseInternalLinks (href) {
return {}
}
export function parseYoutubeStart (t) {
// https://stackoverflow.com/questions/17379268/youtube-dropped-t-start-time-support-in-direct-url-and-embed-videos
// https://developers.google.com/youtube/player_parameters#start
if (!t || !t.match(/^([0-9]+[smh])+$/g)) return t
let r = 0
for (const m of t.matchAll(/([0-9]+)([smh])/g)) {
r += parseInt(m[1]) * Math.pow(60, 'smh'.indexOf(m[2]))
}
return r.toString()
}
export function parseEmbedUrl (href) {
if (!href) return null
@ -132,7 +143,7 @@ export function parseEmbedUrl (href) {
id: searchParams.get('v'),
meta: {
href,
start: searchParams.get('t')
start: parseYoutubeStart(searchParams.get('t'))
}
}
}
@ -152,7 +163,7 @@ export function parseEmbedUrl (href) {
id: pathname.slice(1), // remove leading slash
meta: {
href,
start: searchParams.get('t')
start: parseYoutubeStart(searchParams.get('t'))
}
}
}

View File

@ -116,6 +116,10 @@ async function subHasPostType (name, type, { client, models }) {
return !!(sub?.postTypes?.includes(type))
}
export const searchSchema = object({
q: string().trim().max(100, 'must be at most 100 characters')
})
export function advPostSchemaMembers ({ me, existingBoost = 0, ...args }) {
const boostMin = existingBoost || BOOST_MIN
return {

View File

@ -1,6 +1,6 @@
const { withPlausibleProxy } = require('next-plausible')
const { InjectManifest } = require('workbox-webpack-plugin')
const { generatePrecacheManifest } = require('./sw/build.js')
const CopyPlugin = require('copy-webpack-plugin')
const webpack = require('webpack')
let isProd = process.env.NODE_ENV === 'production'
@ -215,15 +215,24 @@ module.exports = withPlausibleProxy()({
},
webpack: (config, { isServer, dev, defaultLoaders }) => {
if (isServer) {
generatePrecacheManifest()
const workboxPlugin = new InjectManifest({
// ignore the precached manifest which includes the webpack assets
// since they are not useful to us
exclude: [/.*/],
// by default, webpack saves service worker at .next/server/
include: [/\/(icons|maskable|splash)\//, /\.(webp|ttf|woff|woff2)$/],
swDest: '../../public/sw.js',
swSrc: './sw/index.js',
webpackCompilationPlugins: [
// we want to precache these static assets so we copy them to include them in the webpack pipeline
// so InjectManifest can inject them into the service worker manifest
new CopyPlugin({
patterns: [
{ from: 'public/icons', to: '../icons' },
{ from: 'public/maskable', to: '../maskable' },
{ from: 'public/splash', to: '../splash' },
{ from: 'public/waiting.webp', to: '../waiting.webp' },
{ from: 'public/Lightningvolt-xoqm.ttf', to: '../Lightningvolt-xoqm.ttf' },
{ from: 'public/Lightningvolt-xoqm.woff', to: '../Lightningvolt-xoqm.woff' },
{ from: 'public/Lightningvolt-xoqm.woff2', to: '../Lightningvolt-xoqm.woff2' }
]
}),
// this is need to allow the service worker to access these environment variables
// from lib/constants.js
new webpack.DefinePlugin({
@ -232,10 +241,10 @@ module.exports = withPlausibleProxy()({
'process.env.NEXT_PUBLIC_MEDIA_URL': JSON.stringify(process.env.NEXT_PUBLIC_MEDIA_URL),
'process.env.NEXT_PUBLIC_MEDIA_DOMAIN': JSON.stringify(process.env.NEXT_PUBLIC_MEDIA_DOMAIN),
'process.env.NEXT_PUBLIC_URL': JSON.stringify(process.env.NEXT_PUBLIC_URL),
'process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL': JSON.stringify(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL),
'process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL': JSON.stringify(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL),
'process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL': JSON.stringify(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL),
'process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL': JSON.stringify(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL),
'process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL_MS': JSON.stringify(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL_MS),
'process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS': JSON.stringify(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL_MS),
'process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL_MS': JSON.stringify(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL_MS),
'process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL_MS': JSON.stringify(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL_MS),
'process.env.SANCTIONED_COUNTRY_CODES': JSON.stringify(process.env.SANCTIONED_COUNTRY_CODES),
'process.env.NEXT_IS_EXPORT_WORKER': 'true'
})

347
package-lock.json generated
View File

@ -20,6 +20,7 @@
"@nostr-dev-kit/ndk-wallet": "^0.5.0",
"@opensearch-project/opensearch": "^2.12.0",
"@prisma/client": "^5.20.0",
"@shocknet/clink-sdk": "^1.4.0",
"@slack/web-api": "^7.6.0",
"@svgr/webpack": "^8.1.0",
"@yudiel/react-qr-scanner": "^2.0.8",
@ -35,6 +36,7 @@
"classnames": "^2.5.1",
"clipboard-copy": "^4.0.1",
"cookie": "^1.0.1",
"copy-webpack-plugin": "^13.0.1",
"cross-fetch": "^4.0.0",
"csv-parser": "^3.0.0",
"domino": "^2.1.6",
@ -3235,11 +3237,31 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
@ -3256,7 +3278,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"engines": {
"node": ">=12"
},
@ -3268,7 +3289,6 @@
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true,
"engines": {
"node": ">=12"
},
@ -3279,14 +3299,12 @@
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
@ -3303,7 +3321,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"dependencies": {
"ansi-regex": "^6.0.1"
},
@ -3318,7 +3335,6 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
@ -5268,6 +5284,137 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@shocknet/clink-sdk": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@shocknet/clink-sdk/-/clink-sdk-1.4.0.tgz",
"integrity": "sha512-J0PWE8CVRJrFF1Zi/UhChhvOrlmDj7LRJTpR6rbHlFPmjC5TGIW6891tVWWv+JmUR0jzez9QHFrHnc8DgIJYCQ==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.8.0",
"@scure/base": "^1.2.5",
"nostr-tools": "^2.13.0",
"rimraf": "^6.0.1",
"typescript": "^5.8.3"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/glob": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
"integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.0.3",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/jackspeak": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/lru-cache": {
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz",
"integrity": "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==",
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/minimatch": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
"license": "ISC",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/path-scurry": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@shocknet/clink-sdk/node_modules/rimraf": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
"integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==",
"license": "ISC",
"dependencies": {
"glob": "^11.0.0",
"package-json-from-dist": "^1.0.0"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@ -6508,6 +6655,23 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
@ -8048,6 +8212,60 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/copy-webpack-plugin": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz",
"integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==",
"license": "MIT",
"dependencies": {
"glob-parent": "^6.0.1",
"normalize-path": "^3.0.0",
"schema-utils": "^4.2.0",
"serialize-javascript": "^6.0.2",
"tinyglobby": "^0.2.12"
},
"engines": {
"node": ">= 18.12.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^5.1.0"
}
},
"node_modules/copy-webpack-plugin/node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
"peerDependencies": {
"ajv": "^8.8.2"
}
},
"node_modules/copy-webpack-plugin/node_modules/schema-utils": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
"ajv-formats": "^2.1.1",
"ajv-keywords": "^5.1.0"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/core-js-compat": {
"version": "3.38.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.1.tgz",
@ -8236,10 +8454,10 @@
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true,
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@ -8921,8 +9139,7 @@
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
},
"node_modules/ecc-jsbn": {
"version": "0.1.2",
@ -10234,6 +10451,23 @@
"bser": "2.1.1"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -10377,12 +10611,12 @@
}
},
"node_modules/foreground-child": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
"integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
"dev": true,
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.0",
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
@ -10396,7 +10630,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"engines": {
"node": ">=14"
},
@ -10720,7 +10953,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.3"
},
@ -11994,8 +12226,7 @@
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/isomorphic-ws": {
"version": "5.0.0",
@ -15786,7 +16017,6 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"engines": {
"node": ">=16 || 14 >=14.17"
}
@ -16055,7 +16285,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -16070,19 +16299,18 @@
}
},
"node_modules/nostr-tools": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.8.0.tgz",
"integrity": "sha512-aumZBa9Ok/cAJLovSBCIA/DkJjLjF/Hs5DpQGEjmyfaUkGBqd5jZjzalcVMyy/9HkkRZfJmbTPtqHTKFNvBSHQ==",
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.16.2.tgz",
"integrity": "sha512-ZxH9EbSt5ypURZj2TGNJxZd0Omb5ag5KZSu8IyJMCdLyg2KKz+2GA0sP/cSawCQEkyviIN4eRT4G2gB/t9lMRw==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1"
},
"optionalDependencies": {
"nostr-wasm": "v0.1.0"
"@scure/bip39": "1.2.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
@ -16118,8 +16346,7 @@
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"optional": true
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="
},
"node_modules/npm-run-path": {
"version": "4.0.1",
@ -16499,6 +16726,12 @@
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/packet-reader": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
@ -16594,7 +16827,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -18612,7 +18844,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@ -18624,7 +18855,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -19281,7 +19511,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@ -19401,7 +19630,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@ -19677,6 +19905,34 @@
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tldts": {
"version": "6.1.51",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.51.tgz",
@ -20205,9 +20461,9 @@
"integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g=="
},
"node_modules/typescript": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -21070,7 +21326,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"dependencies": {
"isexe": "^2.0.0"
},
@ -21423,7 +21678,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
@ -21440,7 +21694,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@ -21455,7 +21708,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
@ -21466,8 +21718,7 @@
"node_modules/wrap-ansi-cjs/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "4.3.0",

View File

@ -25,6 +25,7 @@
"@nostr-dev-kit/ndk-wallet": "^0.5.0",
"@opensearch-project/opensearch": "^2.12.0",
"@prisma/client": "^5.20.0",
"@shocknet/clink-sdk": "^1.4.0",
"@slack/web-api": "^7.6.0",
"@svgr/webpack": "^8.1.0",
"@yudiel/react-qr-scanner": "^2.0.8",
@ -40,6 +41,7 @@
"classnames": "^2.5.1",
"clipboard-copy": "^4.0.1",
"cookie": "^1.0.1",
"copy-webpack-plugin": "^13.0.1",
"cross-fetch": "^4.0.0",
"csv-parser": "^3.0.0",
"domino": "^2.1.6",

View File

@ -20,6 +20,7 @@ import { ChainFeeProvider } from '@/components/chain-fee.js'
import dynamic from 'next/dynamic'
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
import WalletsProvider from '@/wallets/client/context'
import FaviconProvider from '@/components/favicon'
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
@ -59,14 +60,6 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
router.events.on('routeChangeComplete', nprogressDone)
router.events.on('routeChangeError', nprogressDone)
const handleServiceWorkerMessage = (event) => {
if (event.data?.type === 'navigate') {
router.push(event.data.url)
}
}
navigator.serviceWorker?.addEventListener('message', handleServiceWorkerMessage)
if (!props?.apollo) return
// HACK: 'cause there's no way to tell Next to skip SSR
// So every page load, we modify the route in browser history
@ -89,7 +82,6 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
router.events.off('routeChangeStart', nprogressStart)
router.events.off('routeChangeComplete', nprogressDone)
router.events.off('routeChangeError', nprogressDone)
navigator.serviceWorker?.removeEventListener('message', handleServiceWorkerMessage)
}
}, [router.asPath, props?.apollo, shouldShowProgressBar])
@ -121,24 +113,26 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
<MeProvider me={me}>
<WalletsProvider>
<HasNewNotesProvider>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<AnimationProvider>
<ToastProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<ChainFeeProvider chainFee={chainFee}>
<ErrorBoundary>
<Component ssrData={ssrData} {...otherProps} />
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
</ErrorBoundary>
</ChainFeeProvider>
</BlockHeightProvider>
</ShowModalProvider>
</ToastProvider>
</AnimationProvider>
</PriceProvider>
</ServiceWorkerProvider>
<FaviconProvider>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<AnimationProvider>
<ToastProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<ChainFeeProvider chainFee={chainFee}>
<ErrorBoundary>
<Component ssrData={ssrData} {...otherProps} />
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
</ErrorBoundary>
</ChainFeeProvider>
</BlockHeightProvider>
</ShowModalProvider>
</ToastProvider>
</AnimationProvider>
</PriceProvider>
</ServiceWorkerProvider>
</FaviconProvider>
</HasNewNotesProvider>
</WalletsProvider>
</MeProvider>

View File

@ -327,38 +327,43 @@ export const getAuthOptions = (req, res) => ({
return user
},
useVerificationToken: async ({ identifier, token }) => {
// we need to find the most recent verification request for this email/identifier
const verificationRequest = await prisma.verificationToken.findFirst({
where: {
identifier,
attempts: {
lt: 2 // count starts at 0
}
},
orderBy: {
createdAt: 'desc'
return await prisma.$transaction(async (tx) => {
const [verificationRequest] = await tx.$queryRaw`
UPDATE verification_requests
SET attempts = attempts + 1
FROM (
SELECT id FROM verification_requests
WHERE identifier = ${identifier}
AND created_at > NOW() - INTERVAL '5 minutes'
-- we need to find the most recent verification request for this email/identifier
ORDER BY created_at DESC
LIMIT 1
FOR UPDATE
) for_update
WHERE verification_requests.id = for_update.id
RETURNING *
`
if (!verificationRequest) throw new Error('No verification request found')
if (verificationRequest.token === token) {
// correct token was entered, delete the verification request because we no longer need it
await tx.verificationToken.delete({
where: { id: verificationRequest.id }
})
return verificationRequest
}
if (verificationRequest.attempts >= 3) {
// too many attempts, delete the verification request and redirect to error page by throwing an error
await tx.verificationToken.delete({
where: { id: verificationRequest.id }
})
throw new Error('too many attempts')
}
// wrong code but can try again
return null
})
if (!verificationRequest) throw new Error('No verification request found')
if (verificationRequest.token === token) { // if correct delete the token and continue
await prisma.verificationToken.delete({
where: { id: verificationRequest.id }
})
return verificationRequest
}
await prisma.verificationToken.update({
where: { id: verificationRequest.id },
data: { attempts: { increment: 1 } }
})
await prisma.verificationToken.deleteMany({
where: { id: verificationRequest.id, attempts: { gte: 2 } }
})
return null
}
},
session: {
@ -408,7 +413,9 @@ function generateRandomString (length = 6, charset = BECH32_CHARSET) {
const bytes = randomBytes(length)
let result = ''
// Map each byte to a character in the charset
// Even though we're creating biased numbers by mapping each byte to a bech32 character,
// this is still secure because it provides 30 bits of security (32^6 = 2^30)
// and we are limiting the number of attempts.
for (let i = 0; i < length; i++) {
result += charset[bytes[i] % charset.length]
}

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