Compare commits
8 Commits
aacf7c6170
...
93e0b3ed6e
Author | SHA1 | Date | |
---|---|---|---|
|
93e0b3ed6e | ||
|
7121317990 | ||
|
57fbab31b3 | ||
|
77c87dae80 | ||
|
512b08997e | ||
|
0fda8907e7 | ||
|
0c7ff80fb8 | ||
|
6ed0e53fc1 |
17
.github/workflows/shell-check.yml
vendored
Normal file
17
.github/workflows/shell-check.yml
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
name: ShellCheck
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
shellcheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Shellcheck
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Run ShellCheck
|
||||||
|
uses: ludeeus/action-shellcheck@master
|
||||||
|
with:
|
||||||
|
severity: error
|
||||||
|
scandir: ./sndev
|
@ -866,6 +866,10 @@ export default {
|
|||||||
FROM "Item"
|
FROM "Item"
|
||||||
WHERE id = $1`, Number(id))
|
WHERE id = $1`, Number(id))
|
||||||
|
|
||||||
|
if (item.deletedAt) {
|
||||||
|
throw new GraphQLError('item is deleted', { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
|
|
||||||
// disallow self tips except anons
|
// disallow self tips except anons
|
||||||
if (me) {
|
if (me) {
|
||||||
if (Number(item.userId) === Number(me.id)) {
|
if (Number(item.userId) === Number(me.id)) {
|
||||||
@ -1227,7 +1231,7 @@ export const createMentions = async (item, models) => {
|
|||||||
|
|
||||||
// only send if mention is new to avoid duplicates
|
// only send if mention is new to avoid duplicates
|
||||||
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
|
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
|
||||||
notifyMention(user.id, item)
|
notifyMention({ models, userId: user.id, item })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import { viewGroup } from './growth'
|
|||||||
import { timeUnitForRange, whenRange } from '@/lib/time'
|
import { timeUnitForRange, whenRange } from '@/lib/time'
|
||||||
import assertApiKeyNotPermitted from './apiKey'
|
import assertApiKeyNotPermitted from './apiKey'
|
||||||
import { hashEmail } from '@/lib/crypto'
|
import { hashEmail } from '@/lib/crypto'
|
||||||
|
import { isMuted } from '@/lib/user'
|
||||||
|
|
||||||
const contributors = new Set()
|
const contributors = new Set()
|
||||||
|
|
||||||
@ -701,9 +702,16 @@ export default {
|
|||||||
subscribeUserPosts: async (parent, { id }, { me, models }) => {
|
subscribeUserPosts: async (parent, { id }, { me, models }) => {
|
||||||
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
|
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
|
||||||
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
|
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
|
||||||
|
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (muted && !existing.postsSubscribedAt) {
|
||||||
|
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { postsSubscribedAt: existing.postsSubscribedAt ? null : new Date() } })
|
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { postsSubscribedAt: existing.postsSubscribedAt ? null : new Date() } })
|
||||||
} else {
|
} else {
|
||||||
|
if (muted) {
|
||||||
|
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
await models.userSubscription.create({ data: { ...lookupData, postsSubscribedAt: new Date() } })
|
await models.userSubscription.create({ data: { ...lookupData, postsSubscribedAt: new Date() } })
|
||||||
}
|
}
|
||||||
return { id }
|
return { id }
|
||||||
@ -711,9 +719,16 @@ export default {
|
|||||||
subscribeUserComments: async (parent, { id }, { me, models }) => {
|
subscribeUserComments: async (parent, { id }, { me, models }) => {
|
||||||
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
|
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
|
||||||
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
|
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
|
||||||
|
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (muted && !existing.commentsSubscribedAt) {
|
||||||
|
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { commentsSubscribedAt: existing.commentsSubscribedAt ? null : new Date() } })
|
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { commentsSubscribedAt: existing.commentsSubscribedAt ? null : new Date() } })
|
||||||
} else {
|
} else {
|
||||||
|
if (muted) {
|
||||||
|
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
await models.userSubscription.create({ data: { ...lookupData, commentsSubscribedAt: new Date() } })
|
await models.userSubscription.create({ data: { ...lookupData, commentsSubscribedAt: new Date() } })
|
||||||
}
|
}
|
||||||
return { id }
|
return { id }
|
||||||
@ -725,6 +740,18 @@ export default {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
await models.mute.delete({ where })
|
await models.mute.delete({ where })
|
||||||
} else {
|
} else {
|
||||||
|
// check to see if current user is subscribed to the target user, and disallow mute if so
|
||||||
|
const subscription = await models.userSubscription.findUnique({
|
||||||
|
where: {
|
||||||
|
followerId_followeeId: {
|
||||||
|
followerId: Number(me.id),
|
||||||
|
followeeId: Number(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (subscription.postsSubscribedAt || subscription.commentsSubscribedAt) {
|
||||||
|
throw new GraphQLError("you can't mute a stacker to whom you've subscribed", { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
await models.mute.create({ data: { ...lookupData } })
|
await models.mute.create({ data: { ...lookupData } })
|
||||||
}
|
}
|
||||||
return { id }
|
return { id }
|
||||||
@ -782,16 +809,7 @@ export default {
|
|||||||
if (!me) return false
|
if (!me) return false
|
||||||
if (typeof user.meMute !== 'undefined') return user.meMute
|
if (typeof user.meMute !== 'undefined') return user.meMute
|
||||||
|
|
||||||
const mute = await models.mute.findUnique({
|
return await isMuted({ models, muterId: me.id, mutedId: user.id })
|
||||||
where: {
|
|
||||||
muterId_mutedId: {
|
|
||||||
muterId: Number(me.id),
|
|
||||||
mutedId: Number(user.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return !!mute
|
|
||||||
},
|
},
|
||||||
since: async (user, args, { models }) => {
|
since: async (user, args, { models }) => {
|
||||||
// get the user's first item
|
// get the user's first item
|
||||||
|
@ -79,4 +79,6 @@ felipebueno,pr,#1094,,,,2,,80k,felipebueno@getalby.com,2024-05-06
|
|||||||
benalleng,helpfulness,#1127,#927,good-first-issue,,,,2k,benalleng@mutiny.plus,2024-05-04
|
benalleng,helpfulness,#1127,#927,good-first-issue,,,,2k,benalleng@mutiny.plus,2024-05-04
|
||||||
itsrealfake,pr,#1135,#1016,good-first-issue,,,nonideal solution,10k,itsrealfake2@stacker.news,2024-05-06
|
itsrealfake,pr,#1135,#1016,good-first-issue,,,nonideal solution,10k,itsrealfake2@stacker.news,2024-05-06
|
||||||
SatsAllDay,issue,#1135,#1016,good-first-issue,,,,1k,weareallsatoshi@getalby.com,2024-05-04
|
SatsAllDay,issue,#1135,#1016,good-first-issue,,,,1k,weareallsatoshi@getalby.com,2024-05-04
|
||||||
s373nZ,issue,#1136,#1107,medium,high,,,50k,se7enz@minibits.cash,2024-05-05
|
s373nZ,issue,#1136,#1107,medium,high,,,50k,se7enz@minibits.cash,2024-05-05
|
||||||
|
benalleng,pr,#1129,#1045,good-first-issue,,,paid for advice out of band,20k,benalleng@mutiny.plus,???
|
||||||
|
benalleng,pr,#1129,#491,good-first-issue,,,,20k,benalleng@mutiny.plus,???
|
||||||
|
|
@ -3,12 +3,19 @@ import AccordionContext from 'react-bootstrap/AccordionContext'
|
|||||||
import { useAccordionButton } from 'react-bootstrap/AccordionButton'
|
import { useAccordionButton } from 'react-bootstrap/AccordionButton'
|
||||||
import ArrowRight from '@/svgs/arrow-right-s-fill.svg'
|
import ArrowRight from '@/svgs/arrow-right-s-fill.svg'
|
||||||
import ArrowDown from '@/svgs/arrow-down-s-fill.svg'
|
import ArrowDown from '@/svgs/arrow-down-s-fill.svg'
|
||||||
import { useContext } from 'react'
|
import { useContext, useEffect } from 'react'
|
||||||
|
|
||||||
function ContextAwareToggle ({ children, headerColor = 'var(--theme-grey)', eventKey }) {
|
function ContextAwareToggle ({ children, headerColor = 'var(--theme-grey)', eventKey, show }) {
|
||||||
const { activeEventKey } = useContext(AccordionContext)
|
const { activeEventKey } = useContext(AccordionContext)
|
||||||
const decoratedOnClick = useAccordionButton(eventKey)
|
const decoratedOnClick = useAccordionButton(eventKey)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// if we want to show the accordian and it's not open, open it
|
||||||
|
if (show && activeEventKey !== eventKey) {
|
||||||
|
decoratedOnClick()
|
||||||
|
}
|
||||||
|
}, [show])
|
||||||
|
|
||||||
const isCurrentEventKey = activeEventKey === eventKey
|
const isCurrentEventKey = activeEventKey === eventKey
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -24,7 +31,7 @@ function ContextAwareToggle ({ children, headerColor = 'var(--theme-grey)', even
|
|||||||
export default function AccordianItem ({ header, body, headerColor = 'var(--theme-grey)', show }) {
|
export default function AccordianItem ({ header, body, headerColor = 'var(--theme-grey)', show }) {
|
||||||
return (
|
return (
|
||||||
<Accordion defaultActiveKey={show ? '0' : undefined}>
|
<Accordion defaultActiveKey={show ? '0' : undefined}>
|
||||||
<ContextAwareToggle eventKey='0'><div style={{ color: headerColor }}>{header}</div></ContextAwareToggle>
|
<ContextAwareToggle show={show} eventKey='0'><div style={{ color: headerColor }}>{header}</div></ContextAwareToggle>
|
||||||
<Accordion.Collapse eventKey='0' className='mt-2'>
|
<Accordion.Collapse eventKey='0' className='mt-2'>
|
||||||
<div>{body}</div>
|
<div>{body}</div>
|
||||||
</Accordion.Collapse>
|
</Accordion.Collapse>
|
||||||
|
@ -10,6 +10,7 @@ import styles from './adv-post-form.module.css'
|
|||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { useFeeButton } from './fee-button'
|
import { useFeeButton } from './fee-button'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
import { useFormikContext } from 'formik'
|
||||||
|
|
||||||
const EMPTY_FORWARD = { nym: '', pct: '' }
|
const EMPTY_FORWARD = { nym: '', pct: '' }
|
||||||
|
|
||||||
@ -20,11 +21,48 @@ export function AdvPostInitial ({ forward, boost }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdvPostForm ({ children, item }) {
|
const FormStatus = {
|
||||||
|
DIRTY: 'dirty',
|
||||||
|
ERROR: 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdvPostForm ({ children, item, storageKeyPrefix }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const { merge } = useFeeButton()
|
const { merge } = useFeeButton()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [itemType, setItemType] = useState()
|
const [itemType, setItemType] = useState()
|
||||||
|
const formik = useFormikContext()
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isDirty = formik?.values.forward?.[0].nym !== '' || formik?.values.forward?.[0].pct !== '' ||
|
||||||
|
formik?.values.boost !== '' || (router.query?.type === 'link' && formik?.values.text !== '')
|
||||||
|
|
||||||
|
// if the adv post form is dirty on first render, show the accordian
|
||||||
|
if (isDirty) {
|
||||||
|
setShow(FormStatus.DIRTY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HACK ... TODO: we should generically handle this kind of local storage stuff
|
||||||
|
// in the form component, overriding the initial values
|
||||||
|
if (storageKeyPrefix) {
|
||||||
|
for (let i = 0; i < MAX_FORWARDS; i++) {
|
||||||
|
['nym', 'pct'].forEach(key => {
|
||||||
|
const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`)
|
||||||
|
if (value) {
|
||||||
|
formik?.setFieldValue(`forward[${i}].${key}`, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [formik?.values, storageKeyPrefix])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// force show the accordian if there is an error and the form is submitting
|
||||||
|
const hasError = !!formik?.errors?.boost || formik?.errors?.forward?.length > 0
|
||||||
|
// if it's open we don't want to collapse on submit
|
||||||
|
setShow(show => hasError && formik?.isSubmitting ? FormStatus.ERROR : show)
|
||||||
|
}, [formik?.isSubmitting])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const determineItemType = () => {
|
const determineItemType = () => {
|
||||||
@ -69,6 +107,7 @@ export default function AdvPostForm ({ children, item }) {
|
|||||||
return (
|
return (
|
||||||
<AccordianItem
|
<AccordianItem
|
||||||
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
|
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
|
||||||
|
show={show}
|
||||||
body={
|
body={
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
|
@ -93,6 +93,8 @@ export function BountyForm ({
|
|||||||
}, [upsertBounty, router]
|
}, [upsertBounty, router]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const storageKeyPrefix = item ? undefined : 'bounty'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
@ -109,7 +111,7 @@ export function BountyForm ({
|
|||||||
handleSubmit ||
|
handleSubmit ||
|
||||||
onSubmit
|
onSubmit
|
||||||
}
|
}
|
||||||
storageKeyPrefix={item ? undefined : 'bounty'}
|
storageKeyPrefix={storageKeyPrefix}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<Input
|
<Input
|
||||||
@ -143,7 +145,7 @@ export function BountyForm ({
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AdvPostForm edit={!!item} item={item} />
|
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} />
|
||||||
<ItemButtonBar itemId={item?.id} canDelete={false} />
|
<ItemButtonBar itemId={item?.id} canDelete={false} />
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
|
@ -84,7 +84,7 @@ export function DiscussionForm ({
|
|||||||
}`)
|
}`)
|
||||||
|
|
||||||
const related = relatedData?.related?.items || []
|
const related = relatedData?.related?.items || []
|
||||||
|
const storageKeyPrefix = item ? undefined : 'discussion'
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
@ -97,7 +97,7 @@ export function DiscussionForm ({
|
|||||||
schema={schema}
|
schema={schema}
|
||||||
invoiceable
|
invoiceable
|
||||||
onSubmit={handleSubmit || onSubmit}
|
onSubmit={handleSubmit || onSubmit}
|
||||||
storageKeyPrefix={item ? undefined : 'discussion'}
|
storageKeyPrefix={storageKeyPrefix}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<Input
|
<Input
|
||||||
@ -124,7 +124,7 @@ export function DiscussionForm ({
|
|||||||
? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div>
|
? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div>
|
||||||
: null}
|
: null}
|
||||||
/>
|
/>
|
||||||
<AdvPostForm edit={!!item} item={item} />
|
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} />
|
||||||
<ItemButtonBar itemId={item?.id} />
|
<ItemButtonBar itemId={item?.id} />
|
||||||
{!item &&
|
{!item &&
|
||||||
<div className={`mt-3 ${related.length > 0 ? '' : 'invisible'}`}>
|
<div className={`mt-3 ${related.length > 0 ? '' : 'invisible'}`}>
|
||||||
|
@ -161,7 +161,19 @@ function TopLevelItem ({ item, noReply, ...props }) {
|
|||||||
{!noReply &&
|
{!noReply &&
|
||||||
<>
|
<>
|
||||||
<Reply item={item} replyOpen placeholder={item.ncomments > 3 ? 'fractions of a penny for your thoughts?' : 'early comments get more zaps'} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote} />
|
<Reply item={item} replyOpen placeholder={item.ncomments > 3 ? 'fractions of a penny for your thoughts?' : 'early comments get more zaps'} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote} />
|
||||||
{!item.position && !item.isJob && !item.parentId && !item.deletedAt && !(item.bounty > 0) && <Related title={item.title} itemId={item.id} show={item.ncomments === 0} />}
|
{
|
||||||
|
// Don't show related items for Saloon items (position is set but no subName)
|
||||||
|
(!item.position && item.subName) &&
|
||||||
|
// Don't show related items for jobs
|
||||||
|
!item.isJob &&
|
||||||
|
// Don't show related items for child items
|
||||||
|
!item.parentId &&
|
||||||
|
// Don't show related items for deleted items
|
||||||
|
!item.deletedAt &&
|
||||||
|
// Don't show related items for items with bounties, show past bounties instead
|
||||||
|
!(item.bounty > 0) &&
|
||||||
|
<Related title={item.title} itemId={item.id} show={item.ncomments === 0} />
|
||||||
|
}
|
||||||
{item.bounty > 0 && <PastBounties item={item} />}
|
{item.bounty > 0 && <PastBounties item={item} />}
|
||||||
</>}
|
</>}
|
||||||
</ItemComponent>
|
</ItemComponent>
|
||||||
|
@ -19,6 +19,7 @@ import { MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants'
|
|||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import { toastDeleteScheduled } from '@/lib/form'
|
import { toastDeleteScheduled } from '@/lib/form'
|
||||||
import { ItemButtonBar } from './post'
|
import { ItemButtonBar } from './post'
|
||||||
|
import { useFormikContext } from 'formik'
|
||||||
|
|
||||||
function satsMin2Mo (minute) {
|
function satsMin2Mo (minute) {
|
||||||
return minute * 30 * 24 * 60
|
return minute * 30 * 24 * 60
|
||||||
@ -165,7 +166,14 @@ export default function JobForm ({ item, sub }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FormStatus = {
|
||||||
|
DIRTY: 'dirty',
|
||||||
|
ERROR: 'error'
|
||||||
|
}
|
||||||
|
|
||||||
function PromoteJob ({ item, sub }) {
|
function PromoteJob ({ item, sub }) {
|
||||||
|
const formik = useFormikContext()
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0))
|
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0))
|
||||||
const [getAuctionPosition, { data }] = useLazyQuery(gql`
|
const [getAuctionPosition, { data }] = useLazyQuery(gql`
|
||||||
query AuctionPosition($id: ID, $bid: Int!) {
|
query AuctionPosition($id: ID, $bid: Int!) {
|
||||||
@ -180,9 +188,21 @@ function PromoteJob ({ item, sub }) {
|
|||||||
setMonthly(satsMin2Mo(initialMaxBid))
|
setMonthly(satsMin2Mo(initialMaxBid))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formik?.values?.maxBid !== 0) {
|
||||||
|
setShow(FormStatus.DIRTY)
|
||||||
|
}
|
||||||
|
}, [formik?.values])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasMaxBidError = !!formik?.errors?.maxBid
|
||||||
|
// if it's open we don't want to collapse on submit
|
||||||
|
setShow(show => show || (hasMaxBidError && formik?.isSubmitting && FormStatus.ERROR))
|
||||||
|
}, [formik?.isSubmitting])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordianItem
|
<AccordianItem
|
||||||
show={item?.maxBid > 0}
|
show={show}
|
||||||
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>promote</div>}
|
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>promote</div>}
|
||||||
body={
|
body={
|
||||||
<>
|
<>
|
||||||
|
@ -129,6 +129,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
const [postDisabled, setPostDisabled] = useState(false)
|
const [postDisabled, setPostDisabled] = useState(false)
|
||||||
const [titleOverride, setTitleOverride] = useState()
|
const [titleOverride, setTitleOverride] = useState()
|
||||||
|
|
||||||
|
const storageKeyPrefix = item ? undefined : 'link'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
@ -142,7 +144,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
schema={schema}
|
schema={schema}
|
||||||
invoiceable
|
invoiceable
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
storageKeyPrefix={item ? undefined : 'link'}
|
storageKeyPrefix={storageKeyPrefix}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<Input
|
<Input
|
||||||
@ -196,7 +198,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<AdvPostForm edit={!!item} item={item}>
|
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item}>
|
||||||
<MarkdownInput
|
<MarkdownInput
|
||||||
label='context'
|
label='context'
|
||||||
name='text'
|
name='text'
|
||||||
|
@ -47,7 +47,7 @@ export default function MuteDropdownItem ({ user: { name, id, meMute } }) {
|
|||||||
toaster.success(`${meMute ? 'un' : ''}muted ${name}`)
|
toaster.success(`${meMute ? 'un' : ''}muted ${name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
toaster.danger(`failed to ${meMute ? 'un' : ''}mute ${name}`)
|
toaster.danger(err.message ?? `failed to ${meMute ? 'un' : ''}mute ${name}`)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -71,6 +71,8 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
|
|
||||||
const initialOptions = item?.poll?.options.map(i => i.option)
|
const initialOptions = item?.poll?.options.map(i => i.option)
|
||||||
|
|
||||||
|
const storageKeyPrefix = item ? undefined : 'poll'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
@ -85,7 +87,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
schema={schema}
|
schema={schema}
|
||||||
invoiceable
|
invoiceable
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
storageKeyPrefix={item ? undefined : 'poll'}
|
storageKeyPrefix={storageKeyPrefix}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<Input
|
<Input
|
||||||
@ -111,7 +113,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
: null}
|
: null}
|
||||||
maxLength={MAX_POLL_CHOICE_LENGTH}
|
maxLength={MAX_POLL_CHOICE_LENGTH}
|
||||||
/>
|
/>
|
||||||
<AdvPostForm edit={!!item} item={item}>
|
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item}>
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
isClearable
|
isClearable
|
||||||
label='poll expiration'
|
label='poll expiration'
|
||||||
|
@ -81,8 +81,8 @@ export default function Search ({ sub }) {
|
|||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</div>
|
</div>
|
||||||
{filter && router.query.q &&
|
{filter && router.query.q &&
|
||||||
<div className='text-muted fw-bold d-flex align-items-center flex-wrap pb-2'>
|
<div className='text-muted fw-bold d-flex align-items-center flex-wrap'>
|
||||||
<div className='text-muted fw-bold d-flex align-items-center'>
|
<div className='text-muted fw-bold d-flex align-items-center mb-2'>
|
||||||
<Select
|
<Select
|
||||||
groupClassName='me-2 mb-0'
|
groupClassName='me-2 mb-0'
|
||||||
onChange={(formik, e) => search({ ...formik?.values, what: e.target.value })}
|
onChange={(formik, e) => search({ ...formik?.values, what: e.target.value })}
|
||||||
@ -120,7 +120,7 @@ export default function Search ({ sub }) {
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
fromName='from'
|
fromName='from'
|
||||||
toName='to'
|
toName='to'
|
||||||
className='p-0 px-2 mb-2'
|
className='p-0 px-2'
|
||||||
onChange={(formik, [from, to], e) => {
|
onChange={(formik, [from, to], e) => {
|
||||||
search({ ...formik?.values, from: from.getTime(), to: to.getTime() })
|
search({ ...formik?.values, from: from.getTime(), to: to.getTime() })
|
||||||
}}
|
}}
|
||||||
|
@ -51,7 +51,7 @@ export default function SubscribeUserDropdownItem ({ user, target = 'posts' }) {
|
|||||||
toaster.success(meSubscription ? 'unsubscribed' : 'subscribed')
|
toaster.success(meSubscription ? 'unsubscribed' : 'subscribed')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
toaster.danger(meSubscription ? 'failed to unsubscribe' : 'failed to subscribe')
|
toaster.danger(err.message ?? (meSubscription ? 'failed to unsubscribe' : 'failed to subscribe'))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -86,7 +86,7 @@ export default function TopHeader ({ sub, cat }) {
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
fromName='from'
|
fromName='from'
|
||||||
toName='to'
|
toName='to'
|
||||||
className='p-0 px-2 my-2'
|
className='p-0 px-2'
|
||||||
onChange={(formik, [from, to], e) => {
|
onChange={(formik, [from, to], e) => {
|
||||||
top({ ...formik?.values, from: from.getTime(), to: to.getTime() })
|
top({ ...formik?.values, from: from.getTime(), to: to.getTime() })
|
||||||
}}
|
}}
|
||||||
|
@ -25,7 +25,7 @@ export function UsageHeader ({ pathname = null }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='text-muted fw-bold my-0 d-flex align-items-center flex-wrap'>
|
<div className='text-muted fw-bold my-0 d-flex align-items-center flex-wrap'>
|
||||||
<div className='text-muted fw-bold my-2 d-flex align-items-center'>
|
<div className='text-muted fw-bold mb-2 d-flex align-items-center'>
|
||||||
stacker analytics for
|
stacker analytics for
|
||||||
<Select
|
<Select
|
||||||
groupClassName='mb-0 mx-2'
|
groupClassName='mb-0 mx-2'
|
||||||
|
@ -16,8 +16,8 @@ const mutex = new Mutex()
|
|||||||
async function getLNC ({ me }) {
|
async function getLNC ({ me }) {
|
||||||
if (window.lnc) return window.lnc
|
if (window.lnc) return window.lnc
|
||||||
// backwards compatibility: migrate to new storage key
|
// backwards compatibility: migrate to new storage key
|
||||||
if (me) migrateLocalStorage('lnc-web:default', `lnc-web:${me.id}`)
|
if (me) migrateLocalStorage('lnc-web:default', `lnc-web:stacker:${me.id}`)
|
||||||
window.lnc = new LNC({ namespace: me?.id })
|
window.lnc = new LNC({ namespace: me?.id ? `stacker:${me.id}` : undefined })
|
||||||
return window.lnc
|
return window.lnc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,3 +9,4 @@ stargut
|
|||||||
mz
|
mz
|
||||||
btcbagehot
|
btcbagehot
|
||||||
felipe
|
felipe
|
||||||
|
benalleng
|
||||||
|
12
lib/user.js
Normal file
12
lib/user.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export const isMuted = async ({ models, muterId, mutedId }) => {
|
||||||
|
const mute = await models.mute.findUnique({
|
||||||
|
where: {
|
||||||
|
muterId_mutedId: {
|
||||||
|
muterId: Number(muterId),
|
||||||
|
mutedId: Number(mutedId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return !!mute
|
||||||
|
}
|
@ -3,6 +3,7 @@ import removeMd from 'remove-markdown'
|
|||||||
import { ANON_USER_ID, COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants'
|
import { ANON_USER_ID, COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants'
|
||||||
import { msatsToSats, numWithUnits } from './format'
|
import { msatsToSats, numWithUnits } from './format'
|
||||||
import models from '@/api/models'
|
import models from '@/api/models'
|
||||||
|
import { isMuted } from '@/lib/user'
|
||||||
|
|
||||||
const webPushEnabled = process.env.NODE_ENV === 'production' ||
|
const webPushEnabled = process.env.NODE_ENV === 'production' ||
|
||||||
(process.env.VAPID_MAILTO && process.env.NEXT_PUBLIC_VAPID_PUBKEY && process.env.VAPID_PRIVKEY)
|
(process.env.VAPID_MAILTO && process.env.NEXT_PUBLIC_VAPID_PUBKEY && process.env.VAPID_PRIVKEY)
|
||||||
@ -121,22 +122,20 @@ export async function replyToSubscription (subscriptionId, notification) {
|
|||||||
export const notifyUserSubscribers = async ({ models, item }) => {
|
export const notifyUserSubscribers = async ({ models, item }) => {
|
||||||
try {
|
try {
|
||||||
const isPost = !!item.title
|
const isPost = !!item.title
|
||||||
const userSubs = await models.userSubscription.findMany({
|
const userSubsExcludingMutes = await models.$queryRawUnsafe(`
|
||||||
where: {
|
SELECT "UserSubscription"."followerId", "UserSubscription"."followeeId", users.name as "followeeName"
|
||||||
followeeId: Number(item.userId),
|
FROM "UserSubscription"
|
||||||
[isPost ? 'postsSubscribedAt' : 'commentsSubscribedAt']: { not: null }
|
INNER JOIN users ON users.id = "UserSubscription"."followeeId"
|
||||||
},
|
WHERE "followeeId" = $1 AND ${isPost ? '"postsSubscribedAt"' : '"commentsSubscribedAt"'} IS NOT NULL
|
||||||
include: {
|
AND NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = "UserSubscription"."followerId" AND "Mute"."mutedId" = $1)
|
||||||
followee: true
|
`, Number(item.userId))
|
||||||
}
|
|
||||||
})
|
|
||||||
const subType = isPost ? 'POST' : 'COMMENT'
|
const subType = isPost ? 'POST' : 'COMMENT'
|
||||||
const tag = `FOLLOW-${item.userId}-${subType}`
|
const tag = `FOLLOW-${item.userId}-${subType}`
|
||||||
await Promise.allSettled(userSubs.map(({ followerId, followee }) => sendUserNotification(followerId, {
|
await Promise.allSettled(userSubsExcludingMutes.map(({ followerId, followeeName }) => sendUserNotification(followerId, {
|
||||||
title: `@${followee.name} ${isPost ? 'created a post' : 'replied to a post'}`,
|
title: `@${followeeName} ${isPost ? 'created a post' : 'replied to a post'}`,
|
||||||
body: isPost ? item.title : item.text,
|
body: isPost ? item.title : item.text,
|
||||||
item,
|
item,
|
||||||
data: { followeeName: followee.name, subType },
|
data: { followeeName, subType },
|
||||||
tag
|
tag
|
||||||
})))
|
})))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -152,17 +151,17 @@ export const notifyTerritorySubscribers = async ({ models, item }) => {
|
|||||||
// only notify on posts in subs
|
// only notify on posts in subs
|
||||||
if (!isPost || !subName) return
|
if (!isPost || !subName) return
|
||||||
|
|
||||||
const territorySubs = await models.subSubscription.findMany({
|
const territorySubsExcludingMuted = await models.$queryRawUnsafe(`
|
||||||
where: {
|
SELECT "userId" FROM "SubSubscription"
|
||||||
subName
|
WHERE "subName" = $1
|
||||||
}
|
AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = "SubSubscription"."userId" AND m."mutedId" = $2)
|
||||||
})
|
`, subName, Number(item.userId))
|
||||||
|
|
||||||
const author = await models.user.findUnique({ where: { id: item.userId } })
|
const author = await models.user.findUnique({ where: { id: item.userId } })
|
||||||
|
|
||||||
const tag = `TERRITORY_POST-${subName}`
|
const tag = `TERRITORY_POST-${subName}`
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
territorySubs
|
territorySubsExcludingMuted
|
||||||
// don't send push notification to author itself
|
// don't send push notification to author itself
|
||||||
.filter(({ userId }) => userId !== author.id)
|
.filter(({ userId }) => userId !== author.id)
|
||||||
.map(({ userId }) =>
|
.map(({ userId }) =>
|
||||||
@ -247,14 +246,17 @@ export const notifyZapped = async ({ models, id }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const notifyMention = async (userId, item) => {
|
export const notifyMention = async ({ models, userId, item }) => {
|
||||||
try {
|
try {
|
||||||
await sendUserNotification(userId, {
|
const muted = await isMuted({ models, muterId: userId, mutedId: item.userId })
|
||||||
title: 'you were mentioned',
|
if (!muted) {
|
||||||
body: item.text,
|
await sendUserNotification(userId, {
|
||||||
item,
|
title: 'you were mentioned',
|
||||||
tag: 'MENTION'
|
body: item.text,
|
||||||
})
|
item,
|
||||||
|
tag: 'MENTION'
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
|
@ -104,7 +104,7 @@ function UserItemsHeader ({ type, name }) {
|
|||||||
{when === 'custom' &&
|
{when === 'custom' &&
|
||||||
<DatePicker
|
<DatePicker
|
||||||
fromName='from' toName='to'
|
fromName='from' toName='to'
|
||||||
className='p-0 px-2 mb-2'
|
className='p-0 px-2'
|
||||||
onChange={(formik, [from, to], e) => {
|
onChange={(formik, [from, to], e) => {
|
||||||
select({ ...formik?.values, from: from.getTime(), to: to.getTime() })
|
select({ ...formik?.values, from: from.getTime(), to: to.getTime() })
|
||||||
}}
|
}}
|
||||||
|
@ -82,7 +82,7 @@ export default function Referrals ({ ssrData }) {
|
|||||||
noForm
|
noForm
|
||||||
fromName='from'
|
fromName='from'
|
||||||
toName='to'
|
toName='to'
|
||||||
className='p-0 px-2 mb-2'
|
className='p-0 px-2'
|
||||||
onChange={(formik, [from, to], e) => {
|
onChange={(formik, [from, to], e) => {
|
||||||
select({ when, from: from.getTime(), to: to.getTime() })
|
select({ when, from: from.getTime(), to: to.getTime() })
|
||||||
}}
|
}}
|
||||||
|
@ -935,6 +935,13 @@ div[contenteditable]:focus,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-datepicker-wrapper {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
// To satisfy assumptions of the date picker component.
|
// To satisfy assumptions of the date picker component.
|
||||||
.react-datepicker__navigation-icon {
|
.react-datepicker__navigation-icon {
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
@ -942,3 +949,87 @@ div[contenteditable]:focus,
|
|||||||
.react-datepicker__navigation-icon::before, .react-datepicker__navigation-icon::after {
|
.react-datepicker__navigation-icon::before, .react-datepicker__navigation-icon::after {
|
||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-datepicker {
|
||||||
|
background-color: var(--theme-inputBg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__header {
|
||||||
|
background-color: var(--theme-inputDisabledBg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__day,
|
||||||
|
.react-datepicker__current-month,
|
||||||
|
.react-datepicker__day-name {
|
||||||
|
color: var(--bs-body-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__day--outside-month {
|
||||||
|
color: var(--theme-inputDisabledBg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::before, .react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::after {
|
||||||
|
border-top: none;
|
||||||
|
border-bottom-color: var(--theme-inputDisabledBg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__day--selected, .react-datepicker__day--in-selecting-range, .react-datepicker__day--in-range,
|
||||||
|
.react-datepicker__month-text--selected,
|
||||||
|
.react-datepicker__month-text--in-selecting-range,
|
||||||
|
.react-datepicker__month-text--in-range,
|
||||||
|
.react-datepicker__quarter-text--selected,
|
||||||
|
.react-datepicker__quarter-text--in-selecting-range,
|
||||||
|
.react-datepicker__quarter-text--in-range,
|
||||||
|
.react-datepicker__year-text--selected,
|
||||||
|
.react-datepicker__year-text--in-selecting-range,
|
||||||
|
.react-datepicker__year-text--in-range {
|
||||||
|
border-radius: 0.4rem !important;
|
||||||
|
background-color: var(--bs-info) !important;
|
||||||
|
color: var(--theme-inputDisabledBg) !important;
|
||||||
|
}
|
||||||
|
.react-datepicker__day--selected:hover, .react-datepicker__day--in-selecting-range:hover, .react-datepicker__day--in-range:hover,
|
||||||
|
.react-datepicker__month-text--selected:hover,
|
||||||
|
.react-datepicker__month-text--in-selecting-range:hover,
|
||||||
|
.react-datepicker__month-text--in-range:hover,
|
||||||
|
.react-datepicker__quarter-text--selected:hover,
|
||||||
|
.react-datepicker__quarter-text--in-selecting-range:hover,
|
||||||
|
.react-datepicker__quarter-text--in-range:hover,
|
||||||
|
.react-datepicker__year-text--selected:hover,
|
||||||
|
.react-datepicker__year-text--in-selecting-range:hover,
|
||||||
|
.react-datepicker__year-text--in-range:hover {
|
||||||
|
background-color: var(--bs-info) !important;
|
||||||
|
}
|
||||||
|
.react-datepicker__day--keyboard-selected,
|
||||||
|
.react-datepicker__month-text--keyboard-selected,
|
||||||
|
.react-datepicker__quarter-text--keyboard-selected,
|
||||||
|
.react-datepicker__year-text--keyboard-selected {
|
||||||
|
border-radius: 0.4rem !important;
|
||||||
|
background-color: var(--theme-inputBg) !important;
|
||||||
|
color: var(--bs-body-color) !important;
|
||||||
|
}
|
||||||
|
.react-datepicker__day:hover, .react-datepicker__month-text:hover,
|
||||||
|
.react-datepicker__quarter-text:hover, .react-datepicker__year-text:hover,
|
||||||
|
.react-datepicker__day--keyboard-selected:hover,
|
||||||
|
.react-datepicker__month-text--keyboard-selected:hover,
|
||||||
|
.react-datepicker__quarter-text--keyboard-selected:hover,
|
||||||
|
.react-datepicker__year-text--keyboard-selected:hover {
|
||||||
|
background-color: var(--theme-clickToContextColor) !important;
|
||||||
|
}
|
||||||
|
.react-datepicker__day--in-selecting-range:not(.react-datepicker__day--in-range,
|
||||||
|
.react-datepicker__month-text--in-range,
|
||||||
|
.react-datepicker__quarter-text--in-range,
|
||||||
|
.react-datepicker__year-text--in-range),
|
||||||
|
.react-datepicker__month-text--in-selecting-range:not(.react-datepicker__day--in-range,
|
||||||
|
.react-datepicker__month-text--in-range,
|
||||||
|
.react-datepicker__quarter-text--in-range,
|
||||||
|
.react-datepicker__year-text--in-range),
|
||||||
|
.react-datepicker__quarter-text--in-selecting-range:not(.react-datepicker__day--in-range,
|
||||||
|
.react-datepicker__month-text--in-range,
|
||||||
|
.react-datepicker__quarter-text--in-range,
|
||||||
|
.react-datepicker__year-text--in-range),
|
||||||
|
.react-datepicker__year-text--in-selecting-range:not(.react-datepicker__day--in-range,
|
||||||
|
.react-datepicker__month-text--in-range,
|
||||||
|
.react-datepicker__quarter-text--in-range,
|
||||||
|
.react-datepicker__year-text--in-range) {
|
||||||
|
background-color: var(--bs-info) !important;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user