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 } from '@/lib/constants'
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import DeleteIcon from '@/svgs/delete-bin-line.svg'
import { useField } from 'formik'
import styles from './settings.module.css'
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
function bech32encode (hexString) {
return bech32.encode('npub', bech32.toWords(Buffer.from(hexString, 'hex')))
}
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
>
)
}
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 (!data && !ssrData) return
return (
}
groupClassName='mb-0'
/>
>
}
/>
sats}
/>
notify me when ...
privacy
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 a daily basis at night
}
name='autoDropBolt11s'
groupClassName='mb-0'
/>
hide me from top stackers>}
name='hideFromTopUsers'
groupClassName='mb-0'
/>
hide my cowboy hat>}
name='hideCowboyHat'
groupClassName='mb-0'
/>
hide my wallet balance>}
name='hideWalletBalance'
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
{me.optional.githubId === null &&
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
{me.optional.nostrAuthPubkey === null &&
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
{me.optional.twitterId === null &&
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'
/>}
only load images from proxy
only load images from our image proxy automatically
this prevents IP address leaks to arbitrary sites
if we fail to load an image, the raw link will be shown
}
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
wild west mode
don't hide flagged content
don't down rank flagged content
}
name='wildWestMode'
groupClassName='mb-0'
/>
greeter mode
see and screen free posts and comments
help onboard new stackers to SN and Lightning
you might be subject to more spam
}
name='greeterMode'
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 }
}
}
}
}
})
}
}
)
// sort to prevent hydration mismatch
const providers = Object.keys(methods).filter(k => k !== '__typename' && k !== 'apiKey').sort()
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'
const disabled = !enabled || apiKey
return (
<>
api key
{apiKey ? 'you can have only one API key at a time' : 'request access to API keys in ~meta'} : <>>}
trigger={['hover', 'focus']}
>
{
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')
}
}}
>Generate API key
{apiKey &&
{
showModal((onClose) => )
}}
/>}
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 (
}
/>
}
append={sats }
hint={threshold at which undo button is shown }
/>
)
}