Compare commits

...

8 Commits

Author SHA1 Message Date
keyan
93e0b3ed6e fix broken lnc localstorage namespace since 5c593ce 2024-05-13 11:08:06 -05:00
keyan
7121317990 more accordian show states 2024-05-13 10:14:35 -05:00
Ben Allen
57fbab31b3
Force post options open when dirty or on errors (#1129)
* wip submit will open options

* fix: options show on error discussions

* lint

* feat: all types check for dirty or errors

* lint

* fix ordering

* dirty and error useEffects

* use formik context

* update dirty checks on forms

* revert dirty logic

* simplify handle error functions

* lint

* add myself to contributors and update awards

* use Formik context in adv-post-form

* move all logic into accordian item

* move logic up to adv-post-form

* lint

* errors open options every time

* refine dirty form accordians

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-05-12 16:17:41 -05:00
SatsAllDay
77c87dae80
honor mutes when sending push notifications (#1145)
* honor mutes when sending push notifications for:
* territory subscriptions
* mentions
* user subscriptions

Also, don't allow you to mute a subscribed user, or vice versa

* refactor mute detection for more code reuse

update mute/subscribe error messages for consistency

* variable rename

* move `isMuted` to shared user lib, reuse in user resolver and webpush

* update awards.csv

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-05-12 13:55:56 -05:00
Abhi Shandilya
512b08997e
fix: deny zaps for deleted items (#1158) 2024-05-11 21:00:08 -05:00
Abhi Shandilya
0fda8907e7
fix: show related items on pinned items (#1157)
* fix: show related items on pinned items

* fix condition

* use subName since sub could be undefined

* Update components/item-full.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Revert "Update components/item-full.js"

This reverts commit d1b785b8490c9356548ef1bfe246ae526f0237c6.

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-05-11 20:41:59 -05:00
Hezron Karani
0c7ff80fb8
add shellcheck workflow (#1147)
* add shellcheck workflow

* add shellcheck workflow

* Shellcheck to fail only with error severity

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-05-11 20:40:44 -05:00
Abhi Shandilya
6ed0e53fc1
fix: custom calendar dark theme (#1123)
* fix: custom calendar dark theme

* refine custom date picker

* color the triangle

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-05-11 20:32:44 -05:00
24 changed files with 294 additions and 63 deletions

17
.github/workflows/shell-check.yml vendored Normal file
View 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

View File

@ -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 })
}
})
}

View File

@ -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

View File

@ -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,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
80 itsrealfake pr #1135 #1016 good-first-issue nonideal solution 10k itsrealfake2@stacker.news 2024-05-06
81 SatsAllDay issue #1135 #1016 good-first-issue 1k weareallsatoshi@getalby.com 2024-05-04
82 s373nZ issue #1136 #1107 medium high 50k se7enz@minibits.cash 2024-05-05
83 benalleng pr #1129 #1045 good-first-issue paid for advice out of band 20k benalleng@mutiny.plus ???
84 benalleng pr #1129 #491 good-first-issue 20k benalleng@mutiny.plus ???

View File

@ -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>

View File

@ -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}

View File

@ -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>
)

View File

@ -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'}`}>

View File

@ -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>

View File

@ -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={
<>

View File

@ -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'

View File

@ -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}`)
}
}}
>

View File

@ -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'

View File

@ -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() })
}}

View File

@ -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'))
}
}}
>

View File

@ -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() })
}}

View File

@ -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'

View File

@ -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
}

View File

@ -9,3 +9,4 @@ stargut
mz
btcbagehot
felipe
benalleng

12
lib/user.js Normal file
View 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
}

View File

@ -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 {
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)
}

View File

@ -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() })
}}

View File

@ -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() })
}}

View File

@ -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;
}