import { Checkbox, Form, Input, SubmitButton, Select, VariableInput, CopyInput } from '@/components/form'
import Alert from 'react-bootstrap/Alert'
import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import Nav from 'react-bootstrap/Nav'
import Layout from '@/components/layout'
import { useState, useMemo } from 'react'
import { gql, useMutation, useQuery } from '@apollo/client'
import { getGetServerSideProps } from '@/api/ssrApollo'
import LoginButton from '@/components/login-button'
import { signIn } from 'next-auth/react'
import { LightningAuth } from '@/components/lightning-auth'
import { SETTINGS, SET_SETTINGS } from '@/fragments/users'
import { useRouter } from 'next/router'
import Info from '@/components/info'
import Link from 'next/link'
import AccordianItem from '@/components/accordian-item'
import { bech32 } from 'bech32'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '@/lib/validate'
import { SUPPORTED_CURRENCIES } from '@/lib/currency'
import PageLoading from '@/components/page-loading'
import { useShowModal } from '@/components/modal'
import { authErrorMessage } from '@/components/login'
import { NostrAuth } from '@/components/nostr-auth'
import { useToast } from '@/components/toast'
import { useServiceWorkerLogger } from '@/components/logger'
import { useMe } from '@/components/me'
import { INVOICE_RETENTION_DAYS, ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import { useField } from 'formik'
import styles from './settings.module.css'
import { AuthBanner } from '@/components/banners'
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
function bech32encode (hexString) {
return bech32.encode('npub', bech32.toWords(Buffer.from(hexString, 'hex')))
}
// sort to prevent hydration mismatch
const getProviders = (authMethods) =>
Object.keys(authMethods).filter(k => k !== '__typename' && k !== 'apiKey').sort()
// Show alert message if user only has one auth method activated
// as users are losing access to their accounts
const hasOnlyOneAuthMethod = (authMethods) => {
const activatedAuths = getProviders(authMethods)
.filter(provider => !!authMethods[provider])
return activatedAuths.length === 1
}
export function SettingsHeader () {
const router = useRouter()
const pathParts = router.asPath.split('/').filter(segment => !!segment)
const activeKey = pathParts[1] ?? 'general'
return (
<>
settings
general
subscriptions
muted stackers
device sync
>
)
}
export default function Settings ({ ssrData }) {
const toaster = useToast()
const { me } = useMe()
const [setSettings] = useMutation(SET_SETTINGS, {
update (cache, { data: { setSettings } }) {
cache.modify({
id: 'ROOT_QUERY',
fields: {
settings () {
return setSettings
}
}
})
}
})
const logger = useServiceWorkerLogger()
const { data } = useQuery(SETTINGS)
const { settings: { privates: settings } } = useMemo(() => data ?? ssrData, [data, ssrData])
// if we switched to anon, me is null before the page is reloaded
if ((!data && !ssrData) || !me) return
return (
{hasOnlyOneAuthMethod(settings?.authMethods) &&
}
}
groupClassName='mb-0'
/>
>
}
/>
sats}
/>
disable freebies
Some comments can be created without paying. However, those comments have limited visibility.
If you disable freebies, you will always pay for your comments and get standard visibility.
If you attach a sending wallet, we disable freebies for you unless you have checked/unchecked this value already.
}
name='disableFreebies'
/>
notify me when ...
wallet
proxy deposits to attached wallets
Forward deposits directly to your attached wallets if they will cause your balance to exceed your auto-withdraw threshold
Payments will be wrapped by the SN node to preserve your wallet's privacy
This will incur in a 10% fee
}
name='proxyReceive'
groupClassName='mb-0'
/>
hide invoice descriptions
Use this if you don't want funding sources to be linkable to your SN identity.
It makes your invoice descriptions blank.
This only applies to invoices you create
lnurl-pay and lightning addresses still reference your nym
}
name='hideInvoiceDesc'
groupClassName='mb-0'
/>
autodelete withdrawal invoices
use this to protect receiver privacy
applies retroactively, cannot be reversed
withdrawal invoices are kept at least {INVOICE_RETENTION_DAYS} days for security and debugging purposes
autodeletions are run on a daily basis at night
}
name='autoDropBolt11s'
/>
hide my wallet balance>}
name='hideWalletBalance'
/>
privacy
hide me from top stackers>}
name='hideFromTopUsers'
groupClassName='mb-0'
/>
hide my cowboy essentials (e.g. cowboy hat)>}
name='hideCowboyHat'
groupClassName='mb-0'
/>
hide my bookmarks from other stackers>}
name='hideBookmarks'
groupClassName='mb-0'
/>
hide my linked github profile
Linked accounts are hidden from your profile by default
uncheck this to display your github on your profile
{!settings?.authMethods?.github &&
You don't seem to have a linked github account
If this is wrong, try unlinking/relinking
}
}
name='hideGithub'
groupClassName='mb-0'
/>
hide my linked nostr profile
Linked accounts are hidden from your profile by default
Uncheck this to display your npub on your profile
{!settings?.authMethods?.nostr &&
You don't seem to have a linked nostr account
If this is wrong, try unlinking/relinking
}
}
name='hideNostr'
groupClassName='mb-0'
/>
hide my linked twitter profile
Linked accounts are hidden from your profile by default
Uncheck this to display your twitter on your profile
{!settings?.authMethods?.twitter &&
You don't seem to have a linked twitter account
If this is wrong, try unlinking/relinking
}
}
name='hideTwitter'
groupClassName='mb-0'
/>
{me.optional?.isContributor &&
hide that I'm a stacker.news contributor>}
name='hideIsContributor'
groupClassName='mb-0'
/>}
do not load images, videos, or content from external sites
only load images and videos when we can proxy them
this prevents IP address leaks to arbitrary sites
if we can't, the raw link will be shown instead
}
name='imgproxyOnly'
groupClassName='mb-0'
/>
allow anonymous diagnostics
collect and send back anonymous diagnostics data
this information is used to fix bugs
this information includes:
a randomly generated fancy name
this information can not be traced back to you without your fancy name
fancy names are generated in your browser
your fancy name: {logger.name}
}
name='diagnostics'
groupClassName='mb-0'
/>
don't create referral links on copy>}
name='noReferralLinks'
/>
content
filter by sats
hide the post if the sum of these is less than your setting:
posting cost
total sats from zaps
boost
set to zero to be a greeter, with the tradeoff of seeing more spam
}
name='satsFilter'
required
append={sats }
/>
show images, video, and 3rd party embeds
if checked and a link is an image, video or can be embedded in another way, we will do it
we support embeds from following sites:
njump.me
youtube.com
twitter.com
spotify.com
rumble.com
wavlake.com
bitcointv.com
peertube.tv
}
name='showImagesAndVideos'
groupClassName='mb-0'
/>
wild west mode
don't hide flagged content
don't down rank flagged content
}
name='wildWestMode'
groupClassName='mb-0'
/>
nsfw mode
see posts from nsfw territories
}
name='nsfwMode'
/>
nostr
crosspost to nostr
crosspost your items to nostr
requires NIP-07 extension for signing
we use your NIP-05 relays if set
we use these relays by default:
{DEFAULT_CROSSPOSTING_RELAYS.map((relay, i) => (
{relay}
))}
}
name='nostrCrossposting'
/>
pubkey optional >}
name='nostrPubkey'
clear
hint={used for NIP-05 }
/>
relays optional >}
name='nostrRelays'
clear
min={0}
max={NOSTR_MAX_RELAY_NUM}
hint={used for NIP-05 and crossposting }
/>
save
saturday newsletter
(re)subscribe
{settings?.authMethods &&
}
)
}
const DropBolt11sCheckbox = ({ ssrData, ...props }) => {
const showModal = useShowModal()
const { data } = useQuery(gql`{ numBolt11s }`)
const { numBolt11s } = data || ssrData
return (
{
if (e.target.checked) {
showModal(onClose => {
return (
<>
{numBolt11s} withdrawal invoices will be deleted with this setting.
You sure? This is a gone forever kind of delete.
{
await onClose()
}}
>I am sure
>
)
})
}
}}
{...props}
/>
)
}
function QRLinkButton ({ provider, unlink, status }) {
const showModal = useShowModal()
const text = status ? 'Unlink' : 'Link'
const onClick = status
? unlink
: () => showModal(onClose =>
)
return (
)
}
function NostrLinkButton ({ unlink, status }) {
const showModal = useShowModal()
const text = status ? 'Unlink' : 'Link'
const onClick = status
? unlink
: () => showModal(onClose =>
)
return (
)
}
function UnlinkObstacle ({ onClose, type, unlinkAuth }) {
const router = useRouter()
const toaster = useToast()
return (
You are removing your last auth method. It is recommended you link another auth method before removing
your last auth method. If you'd like to proceed anyway, type the following below
If I logout, even accidentally, I will never be able to access my account again
)
}
function AuthMethods ({ methods, apiKeyEnabled }) {
const showModal = useShowModal()
const router = useRouter()
const toaster = useToast()
const [err, setErr] = useState(authErrorMessage(router.query.error))
const [unlinkAuth] = useMutation(
gql`
mutation unlinkAuth($authType: String!) {
unlinkAuth(authType: $authType) {
lightning
email
twitter
github
nostr
}
}`, {
update (cache, { data: { unlinkAuth } }) {
cache.modify({
id: 'ROOT_QUERY',
fields: {
settings (existing) {
return {
...existing,
privates: {
...existing.privates,
authMethods: { ...unlinkAuth }
}
}
}
}
})
}
}
)
const providers = getProviders(methods)
const unlink = async type => {
// if there's only one auth method left
const links = providers.reduce((t, p) => t + (methods[p] ? 1 : 0), 0)
if (links === 1) {
showModal(onClose => ( ))
} else {
try {
await unlinkAuth({ variables: { authType: type } })
toaster.success('unlinked auth method')
} catch (err) {
console.error(err)
toaster.danger('failed to unlink auth method')
}
}
}
return (
<>
auth methods
{err && (
{
const { pathname, query: { error, nodata, ...rest } } = router
router.replace({
pathname,
query: { nodata, ...rest }
}, { pathname, query: { ...rest } }, { shallow: true })
setErr(undefined)
}} dismissible
>{err}
)}
{providers?.map(provider => {
if (provider === 'email') {
return methods.email
? (
{
await unlink('email')
}
}
>Unlink Email
)
:
} else if (provider === 'lightning') {
return (
await unlink(provider)}
/>
)
} else if (provider === 'nostr') {
return await unlink(provider)} />
} else {
return (
{
if (methods[provider]) {
await unlink(provider)
} else {
signIn(provider)
}
}}
text={methods[provider] ? 'Unlink' : 'Link'}
/>
)
}
})}
>
)
}
export function EmailLinkForm ({ callbackUrl }) {
const [linkUnverifiedEmail] = useMutation(
gql`
mutation linkUnverifiedEmail($email: String!) {
linkUnverifiedEmail(email: $email)
}`
)
return (
)
}
function ApiKey ({ enabled, apiKey }) {
const showModal = useShowModal()
const { me } = useMe()
const [generateApiKey] = useMutation(
gql`
mutation generateApiKey($id: ID!) {
generateApiKey(id: $id)
}`,
{
update (cache, { data: { generateApiKey } }) {
cache.modify({
id: 'ROOT_QUERY',
fields: {
settings (existing) {
return {
...existing,
privates: {
...existing.privates,
apiKey: generateApiKey,
authMethods: { ...existing.privates.authMethods, apiKey: true }
}
}
}
}
})
}
}
)
const toaster = useToast()
const subject = '[API Key Request] '
const body =
encodeURI(`**[API Key Request]**
Hi, I would like to use API keys with the [Stacker News GraphQL API](/api/graphql) for the following reasons:
...
I expect to call the following GraphQL queries or mutations:
... (you can leave empty if unknown)
I estimate that I will call the GraphQL API this many times (rough estimate is fine):
... (you can leave empty if unknown)
`)
const metaLink = encodeURI(`/~meta/post?type=discussion&title=${subject}&text=${body}`)
const mailto = `mailto:hello@stacker.news?subject=${subject}&body=${body}`
// link to DM with k00b on Telegram
const telegramLink = 'https://t.me/k00bideh'
// link to DM with ek on SimpleX
const simplexLink = 'https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2F6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE%3D%40smp10.simplex.im%2FxNnPk9DkTbQJ6NckWom9mi5vheo_VPLm%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAnFUiU0M8jS1JY34LxUoPr7mdJlFZwf3pFkjRrhprdQs%253D%26srv%3Drb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion'
return (
<>
api key
{apiKey ? 'you can have only one API key at a time' : 'request access to API keys in ~meta'} : <>>}
trigger={['hover', 'focus']}
>
{
if (apiKey) {
showModal((onClose) => )
return
}
try {
const { data } = await generateApiKey({ variables: { id: me.id } })
const { generateApiKey: apiKey } = data
showModal(() => , { keepOpen: true })
} catch (err) {
console.error(err)
toaster.danger('error generating api key')
}
}}
>{apiKey ? 'Delete' : 'Generate'} API key
use API keys with our GraphQL API for authentication
you need to add the API key to the X-API-Key header of your requests
you can currently only generate API keys if we enabled it for your account
you can{' '}
create a post in ~meta to request access
or reach out to us via
please include following information in your request:
your nym on SN
what you want to achieve with authenticated API access
which GraphQL queries or mutations you expect to call
your (rough) estimate how often you will call the GraphQL API
>
)
}
function ApiKeyModal ({ apiKey }) {
return (
<>
Make sure to copy your API key now.
This is the only time we will show it to you.
use the X-API-Key header to include this key in your requests>} />
>
)
}
function ApiKeyDeleteObstacle ({ onClose }) {
const { me } = useMe()
const [deleteApiKey] = useMutation(
gql`
mutation deleteApiKey($id: ID!) {
deleteApiKey(id: $id) {
id
}
}`,
{
update (cache, { data: { deleteApiKey } }) {
cache.modify({
id: 'ROOT_QUERY',
fields: {
settings (existing) {
return {
...existing,
privates: {
...existing.privates,
authMethods: { ...existing.privates.authMethods, apiKey: false }
}
}
}
}
})
}
}
)
const toaster = useToast()
return (
Do you really want to delete your API key?
{
try {
await deleteApiKey({ variables: { id: me.id } })
onClose()
} catch (err) {
console.error(err)
toaster.danger('error deleting api key')
}
}}
>do it
)
}
const ZapUndosField = () => {
const [checkboxField] = useField({ name: 'zapUndosEnabled' })
return (
<>
zap undos
After every zap that exceeds or is equal to the threshold, the bolt will pulse
You can undo the zap if you click the bolt while it's pulsing
The bolt will pulse for {ZAP_UNDO_DELAY_MS / 1000} seconds
}
/>
{checkboxField.value &&
sats}
hint={threshold at which undos will be possible }
groupClassName='mt-1'
/>}
>
)
}
const TipRandomField = () => {
const [tipRandomField] = useField({ name: 'tipRandom' })
const [tipRandomMinField] = useField({ name: 'tipRandomMin' })
const [tipRandomMaxField] = useField({ name: 'tipRandomMax' })
return (
<>
random zaps
Set a minimum and maximum zap amount
Each time you zap something, a random amount of sats between your minimum and maximum will be zapped
If this setting is enabled, it will ignore your default zap amount
}
/>
{tipRandomField.value &&
<>
sats}
/>
sats}
/>
>}
>
)
}