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"
|
||||
WHERE id = $1`, Number(id))
|
||||
|
||||
if (item.deletedAt) {
|
||||
throw new GraphQLError('item is deleted', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
// disallow self tips except anons
|
||||
if (me) {
|
||||
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
|
||||
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 assertApiKeyNotPermitted from './apiKey'
|
||||
import { hashEmail } from '@/lib/crypto'
|
||||
import { isMuted } from '@/lib/user'
|
||||
|
||||
const contributors = new Set()
|
||||
|
||||
@ -701,9 +702,16 @@ export default {
|
||||
subscribeUserPosts: async (parent, { id }, { me, models }) => {
|
||||
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
|
||||
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
|
||||
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
|
||||
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() } })
|
||||
} 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() } })
|
||||
}
|
||||
return { id }
|
||||
@ -711,9 +719,16 @@ export default {
|
||||
subscribeUserComments: async (parent, { id }, { me, models }) => {
|
||||
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
|
||||
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
|
||||
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
|
||||
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() } })
|
||||
} 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() } })
|
||||
}
|
||||
return { id }
|
||||
@ -725,6 +740,18 @@ export default {
|
||||
if (existing) {
|
||||
await models.mute.delete({ where })
|
||||
} 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 } })
|
||||
}
|
||||
return { id }
|
||||
@ -782,16 +809,7 @@ export default {
|
||||
if (!me) return false
|
||||
if (typeof user.meMute !== 'undefined') return user.meMute
|
||||
|
||||
const mute = await models.mute.findUnique({
|
||||
where: {
|
||||
muterId_mutedId: {
|
||||
muterId: Number(me.id),
|
||||
mutedId: Number(user.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return !!mute
|
||||
return await isMuted({ models, muterId: me.id, mutedId: user.id })
|
||||
},
|
||||
since: async (user, args, { models }) => {
|
||||
// get the user's first item
|
||||
|
@ -80,3 +80,5 @@ benalleng,helpfulness,#1127,#927,good-first-issue,,,,2k,benalleng@mutiny.plus,20
|
||||
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
|
||||
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 ArrowRight from '@/svgs/arrow-right-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 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
|
||||
|
||||
return (
|
||||
@ -24,7 +31,7 @@ function ContextAwareToggle ({ children, headerColor = 'var(--theme-grey)', even
|
||||
export default function AccordianItem ({ header, body, headerColor = 'var(--theme-grey)', show }) {
|
||||
return (
|
||||
<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'>
|
||||
<div>{body}</div>
|
||||
</Accordion.Collapse>
|
||||
|
@ -10,6 +10,7 @@ import styles from './adv-post-form.module.css'
|
||||
import { useMe } from './me'
|
||||
import { useFeeButton } from './fee-button'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useFormikContext } from 'formik'
|
||||
|
||||
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 { merge } = useFeeButton()
|
||||
const router = useRouter()
|
||||
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(() => {
|
||||
const determineItemType = () => {
|
||||
@ -69,6 +107,7 @@ export default function AdvPostForm ({ children, item }) {
|
||||
return (
|
||||
<AccordianItem
|
||||
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
|
||||
show={show}
|
||||
body={
|
||||
<>
|
||||
{children}
|
||||
|
@ -93,6 +93,8 @@ export function BountyForm ({
|
||||
}, [upsertBounty, router]
|
||||
)
|
||||
|
||||
const storageKeyPrefix = item ? undefined : 'bounty'
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
@ -109,7 +111,7 @@ export function BountyForm ({
|
||||
handleSubmit ||
|
||||
onSubmit
|
||||
}
|
||||
storageKeyPrefix={item ? undefined : 'bounty'}
|
||||
storageKeyPrefix={storageKeyPrefix}
|
||||
>
|
||||
{children}
|
||||
<Input
|
||||
@ -143,7 +145,7 @@ export function BountyForm ({
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<AdvPostForm edit={!!item} item={item} />
|
||||
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} />
|
||||
<ItemButtonBar itemId={item?.id} canDelete={false} />
|
||||
</Form>
|
||||
)
|
||||
|
@ -84,7 +84,7 @@ export function DiscussionForm ({
|
||||
}`)
|
||||
|
||||
const related = relatedData?.related?.items || []
|
||||
|
||||
const storageKeyPrefix = item ? undefined : 'discussion'
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
@ -97,7 +97,7 @@ export function DiscussionForm ({
|
||||
schema={schema}
|
||||
invoiceable
|
||||
onSubmit={handleSubmit || onSubmit}
|
||||
storageKeyPrefix={item ? undefined : 'discussion'}
|
||||
storageKeyPrefix={storageKeyPrefix}
|
||||
>
|
||||
{children}
|
||||
<Input
|
||||
@ -124,7 +124,7 @@ export function DiscussionForm ({
|
||||
? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div>
|
||||
: null}
|
||||
/>
|
||||
<AdvPostForm edit={!!item} item={item} />
|
||||
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} />
|
||||
<ItemButtonBar itemId={item?.id} />
|
||||
{!item &&
|
||||
<div className={`mt-3 ${related.length > 0 ? '' : 'invisible'}`}>
|
||||
|
@ -161,7 +161,19 @@ function TopLevelItem ({ item, noReply, ...props }) {
|
||||
{!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} />
|
||||
{!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} />}
|
||||
</>}
|
||||
</ItemComponent>
|
||||
|
@ -19,6 +19,7 @@ import { MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants'
|
||||
import { useToast } from './toast'
|
||||
import { toastDeleteScheduled } from '@/lib/form'
|
||||
import { ItemButtonBar } from './post'
|
||||
import { useFormikContext } from 'formik'
|
||||
|
||||
function satsMin2Mo (minute) {
|
||||
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 }) {
|
||||
const formik = useFormikContext()
|
||||
const [show, setShow] = useState(false)
|
||||
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0))
|
||||
const [getAuctionPosition, { data }] = useLazyQuery(gql`
|
||||
query AuctionPosition($id: ID, $bid: Int!) {
|
||||
@ -180,9 +188,21 @@ function PromoteJob ({ item, sub }) {
|
||||
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 (
|
||||
<AccordianItem
|
||||
show={item?.maxBid > 0}
|
||||
show={show}
|
||||
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>promote</div>}
|
||||
body={
|
||||
<>
|
||||
|
@ -129,6 +129,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
||||
const [postDisabled, setPostDisabled] = useState(false)
|
||||
const [titleOverride, setTitleOverride] = useState()
|
||||
|
||||
const storageKeyPrefix = item ? undefined : 'link'
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
@ -142,7 +144,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
||||
schema={schema}
|
||||
invoiceable
|
||||
onSubmit={onSubmit}
|
||||
storageKeyPrefix={item ? undefined : 'link'}
|
||||
storageKeyPrefix={storageKeyPrefix}
|
||||
>
|
||||
{children}
|
||||
<Input
|
||||
@ -196,7 +198,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AdvPostForm edit={!!item} item={item}>
|
||||
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item}>
|
||||
<MarkdownInput
|
||||
label='context'
|
||||
name='text'
|
||||
|
@ -47,7 +47,7 @@ export default function MuteDropdownItem ({ user: { name, id, meMute } }) {
|
||||
toaster.success(`${meMute ? 'un' : ''}muted ${name}`)
|
||||
} catch (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 storageKeyPrefix = item ? undefined : 'poll'
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
@ -85,7 +87,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
||||
schema={schema}
|
||||
invoiceable
|
||||
onSubmit={onSubmit}
|
||||
storageKeyPrefix={item ? undefined : 'poll'}
|
||||
storageKeyPrefix={storageKeyPrefix}
|
||||
>
|
||||
{children}
|
||||
<Input
|
||||
@ -111,7 +113,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
||||
: null}
|
||||
maxLength={MAX_POLL_CHOICE_LENGTH}
|
||||
/>
|
||||
<AdvPostForm edit={!!item} item={item}>
|
||||
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item}>
|
||||
<DateTimeInput
|
||||
isClearable
|
||||
label='poll expiration'
|
||||
|
@ -81,8 +81,8 @@ export default function Search ({ sub }) {
|
||||
</SubmitButton>
|
||||
</div>
|
||||
{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'>
|
||||
<div className='text-muted fw-bold d-flex align-items-center flex-wrap'>
|
||||
<div className='text-muted fw-bold d-flex align-items-center mb-2'>
|
||||
<Select
|
||||
groupClassName='me-2 mb-0'
|
||||
onChange={(formik, e) => search({ ...formik?.values, what: e.target.value })}
|
||||
@ -120,7 +120,7 @@ export default function Search ({ sub }) {
|
||||
<DatePicker
|
||||
fromName='from'
|
||||
toName='to'
|
||||
className='p-0 px-2 mb-2'
|
||||
className='p-0 px-2'
|
||||
onChange={(formik, [from, to], e) => {
|
||||
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')
|
||||
} catch (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
|
||||
fromName='from'
|
||||
toName='to'
|
||||
className='p-0 px-2 my-2'
|
||||
className='p-0 px-2'
|
||||
onChange={(formik, [from, to], e) => {
|
||||
top({ ...formik?.values, from: from.getTime(), to: to.getTime() })
|
||||
}}
|
||||
|
@ -25,7 +25,7 @@ export function UsageHeader ({ pathname = null }) {
|
||||
|
||||
return (
|
||||
<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
|
||||
<Select
|
||||
groupClassName='mb-0 mx-2'
|
||||
|
@ -16,8 +16,8 @@ const mutex = new Mutex()
|
||||
async function getLNC ({ me }) {
|
||||
if (window.lnc) return window.lnc
|
||||
// backwards compatibility: migrate to new storage key
|
||||
if (me) migrateLocalStorage('lnc-web:default', `lnc-web:${me.id}`)
|
||||
window.lnc = new LNC({ namespace: me?.id })
|
||||
if (me) migrateLocalStorage('lnc-web:default', `lnc-web:stacker:${me.id}`)
|
||||
window.lnc = new LNC({ namespace: me?.id ? `stacker:${me.id}` : undefined })
|
||||
return window.lnc
|
||||
}
|
||||
|
||||
|
@ -9,3 +9,4 @@ stargut
|
||||
mz
|
||||
btcbagehot
|
||||
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 { msatsToSats, numWithUnits } from './format'
|
||||
import models from '@/api/models'
|
||||
import { isMuted } from '@/lib/user'
|
||||
|
||||
const webPushEnabled = process.env.NODE_ENV === 'production' ||
|
||||
(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 }) => {
|
||||
try {
|
||||
const isPost = !!item.title
|
||||
const userSubs = await models.userSubscription.findMany({
|
||||
where: {
|
||||
followeeId: Number(item.userId),
|
||||
[isPost ? 'postsSubscribedAt' : 'commentsSubscribedAt']: { not: null }
|
||||
},
|
||||
include: {
|
||||
followee: true
|
||||
}
|
||||
})
|
||||
const userSubsExcludingMutes = await models.$queryRawUnsafe(`
|
||||
SELECT "UserSubscription"."followerId", "UserSubscription"."followeeId", users.name as "followeeName"
|
||||
FROM "UserSubscription"
|
||||
INNER JOIN users ON users.id = "UserSubscription"."followeeId"
|
||||
WHERE "followeeId" = $1 AND ${isPost ? '"postsSubscribedAt"' : '"commentsSubscribedAt"'} IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = "UserSubscription"."followerId" AND "Mute"."mutedId" = $1)
|
||||
`, Number(item.userId))
|
||||
const subType = isPost ? 'POST' : 'COMMENT'
|
||||
const tag = `FOLLOW-${item.userId}-${subType}`
|
||||
await Promise.allSettled(userSubs.map(({ followerId, followee }) => sendUserNotification(followerId, {
|
||||
title: `@${followee.name} ${isPost ? 'created a post' : 'replied to a post'}`,
|
||||
await Promise.allSettled(userSubsExcludingMutes.map(({ followerId, followeeName }) => sendUserNotification(followerId, {
|
||||
title: `@${followeeName} ${isPost ? 'created a post' : 'replied to a post'}`,
|
||||
body: isPost ? item.title : item.text,
|
||||
item,
|
||||
data: { followeeName: followee.name, subType },
|
||||
data: { followeeName, subType },
|
||||
tag
|
||||
})))
|
||||
} catch (err) {
|
||||
@ -152,17 +151,17 @@ export const notifyTerritorySubscribers = async ({ models, item }) => {
|
||||
// only notify on posts in subs
|
||||
if (!isPost || !subName) return
|
||||
|
||||
const territorySubs = await models.subSubscription.findMany({
|
||||
where: {
|
||||
subName
|
||||
}
|
||||
})
|
||||
const territorySubsExcludingMuted = await models.$queryRawUnsafe(`
|
||||
SELECT "userId" FROM "SubSubscription"
|
||||
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 tag = `TERRITORY_POST-${subName}`
|
||||
await Promise.allSettled(
|
||||
territorySubs
|
||||
territorySubsExcludingMuted
|
||||
// don't send push notification to author itself
|
||||
.filter(({ userId }) => userId !== author.id)
|
||||
.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 {
|
||||
await sendUserNotification(userId, {
|
||||
title: 'you were mentioned',
|
||||
body: item.text,
|
||||
item,
|
||||
tag: 'MENTION'
|
||||
})
|
||||
const muted = await isMuted({ models, muterId: userId, mutedId: item.userId })
|
||||
if (!muted) {
|
||||
await sendUserNotification(userId, {
|
||||
title: 'you were mentioned',
|
||||
body: item.text,
|
||||
item,
|
||||
tag: 'MENTION'
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
|
@ -104,7 +104,7 @@ function UserItemsHeader ({ type, name }) {
|
||||
{when === 'custom' &&
|
||||
<DatePicker
|
||||
fromName='from' toName='to'
|
||||
className='p-0 px-2 mb-2'
|
||||
className='p-0 px-2'
|
||||
onChange={(formik, [from, to], e) => {
|
||||
select({ ...formik?.values, from: from.getTime(), to: to.getTime() })
|
||||
}}
|
||||
|
@ -82,7 +82,7 @@ export default function Referrals ({ ssrData }) {
|
||||
noForm
|
||||
fromName='from'
|
||||
toName='to'
|
||||
className='p-0 px-2 mb-2'
|
||||
className='p-0 px-2'
|
||||
onChange={(formik, [from, to], e) => {
|
||||
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.
|
||||
.react-datepicker__navigation-icon {
|
||||
line-height: normal;
|
||||
@ -942,3 +949,87 @@ div[contenteditable]:focus,
|
||||
.react-datepicker__navigation-icon::before, .react-datepicker__navigation-icon::after {
|
||||
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