Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Disincentivizing the use of all caps in post titles #590

Draft
wants to merge 12 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
.DS_Store
*.pem
/*.sql
/*.patch
huumn marked this conversation as resolved.
Show resolved Hide resolved

# debug
npm-debug.log*
Expand Down
47 changes: 38 additions & 9 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import {
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
ANON_COMMENT_FEE, ANON_USER_ID, ANON_POST_FEE, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
ITEM_ALLOW_EDITS, GLOBAL_SEED
ITEM_ALLOW_EDITS, GLOBAL_SEED, UPPER_CHARS_TITLE_FEE_MULT
} from '../../lib/constants'
import { msatsToSats } from '../../lib/format'
import { parse } from 'tldts'
import uu from 'url-unshort'
import { advSchema, amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
import { sendUserNotification } from '../webPush'
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item'
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand, titleExceedsFreeUppercase } from '../../lib/item'
import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications'
import { datePivot, whenRange } from '../../lib/time'
import { imageFeesInfo, uploadIdsFromText } from './image'
Expand Down Expand Up @@ -1092,10 +1092,35 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
const uploadIds = uploadIdsFromText(item.text, { models })
const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me })

const hasPaidUpperTitleFee = old.upperTitleFeePaid
const titleUpperMult = !hasPaidUpperTitleFee && titleExceedsFreeUppercase(item) ? UPPER_CHARS_TITLE_FEE_MULT : 1
let additionalFeeMsats = 0
if (titleUpperMult > 1) {
const { _sum: { msats: paidMsats } } = await models.itemAct.aggregate({
_sum: {
msats: true
},
where: {
itemId: Number(item.id),
userId: me.id,
act: 'FEE'
}
})
const { baseCost } = await models.sub.findUnique({ where: { name: item.subName || item.root?.subName } })
// If the original post was a freebie, that doesn't mean this edit should be free
if (Number(paidMsats) === 0) {
additionalFeeMsats = titleUpperMult * baseCost * 1000
item.freebie = false
} else {
additionalFeeMsats = (titleUpperMult - 1) * baseCost * 1000
}
item.upperTitleFeePaid = true
}

item = await serializeInvoicable(
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[]) AS "Item"`,
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
{ models, lnd, hash, hmac, me, enforceFee: imgFees }
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[], $5::BIGINT) AS "Item"`,
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds, additionalFeeMsats),
{ models, lnd, hash, hmac, me, enforceFee: imgFees + additionalFeeMsats }
)

await createMentions(item, models)
Expand Down Expand Up @@ -1128,11 +1153,14 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
const uploadIds = uploadIdsFromText(item.text, { models })
const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me })

const enforceFee = (me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0)))) + imgFees
const titleUpperMult = titleExceedsFreeUppercase(item) ? UPPER_CHARS_TITLE_FEE_MULT : 1
item.upperTitleFeePaid = titleUpperMult > 1

const enforceFee = (me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE * titleUpperMult + (item.boost || 0)))) + imgFees
item = await serializeInvoicable(
models.$queryRawUnsafe(
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[]) AS "Item"`,
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[], $5::INTEGER) AS "Item"`,
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds, titleUpperMult),
{ models, lnd, hash, hmac, me, enforceFee }
)

Expand Down Expand Up @@ -1184,7 +1212,8 @@ export const SELECT =
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats,
"Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",
"Item"."weightedDownVotes", "Item".freebie, "Item".bio, "Item"."otsHash", "Item"."bountyPaidTo",
ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls"`
ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls",
"Item"."upperTitleFeePaid"`

function topOrderByWeightedSats (me, models) {
return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC`
Expand Down
1 change: 1 addition & 0 deletions api/typeDefs/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export default gql`
parentOtsHash: String
forwards: [ItemForward]
imgproxyUrls: JSONObject
upperTitleFeePaid: Boolean
}

input ItemForwardInput {
Expand Down
5 changes: 3 additions & 2 deletions components/adv-post-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ export default function AdvPostForm ({ children }) {
name='boost'
onChange={(_, e) => merge({
boost: {
term: `+ ${e.target.value}`,
term: `+ ${numWithUnits(e.target.value, { abbreviate: false, format: true })}`,
label: 'boost',
modifier: cost => cost + Number(e.target.value)
modifier: cost => cost + Number(e.target.value),
omit: !Number(e.target.value)
}
})}
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
Expand Down
7 changes: 7 additions & 0 deletions components/bounty-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useRouter } from 'next/router'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import { useFeeButton, uppercaseTitleFeeHandler } from './fee-button'
import InputGroup from 'react-bootstrap/InputGroup'
import { bountySchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form'
Expand Down Expand Up @@ -84,6 +85,7 @@ export function BountyForm ({
toastDeleteScheduled(toaster, data, !!item, values.text)
}, [upsertBounty, router]
)
const feeButton = useFeeButton()

return (
<Form
Expand All @@ -110,6 +112,11 @@ export function BountyForm ({
autoFocus
clear
maxLength={MAX_TITLE_LENGTH}
onChange={async (formik, e) => {
if (e.target.value) {
uppercaseTitleFeeHandler(feeButton, e.target.value, item)
}
}}
/>
<Input
label={bountyLabel} name='bounty' required
Expand Down
3 changes: 3 additions & 0 deletions components/discussion-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useRouter } from 'next/router'
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import { useFeeButton, uppercaseTitleFeeHandler } from './fee-button'
import { ITEM_FIELDS } from '../fragments/items'
import AccordianItem from './accordian-item'
import Item from './item'
Expand Down Expand Up @@ -93,6 +94,7 @@ export function DiscussionForm ({
}`)

const related = relatedData?.related?.items || []
const feeButton = useFeeButton()

return (
<Form
Expand Down Expand Up @@ -120,6 +122,7 @@ export function DiscussionForm ({
getRelated({
variables: { title: e.target.value }
})
uppercaseTitleFeeHandler(feeButton, e.target.value, item)
}
}}
maxLength={MAX_TITLE_LENGTH}
Expand Down
46 changes: 44 additions & 2 deletions components/fee-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import ActionTooltip from './action-tooltip'
import Info from './info'
import styles from './fee-button.module.css'
import { gql, useQuery } from '@apollo/client'
import { FREEBIE_BASE_COST_THRESHOLD, SSR } from '../lib/constants'
import { SSR, UPPER_CHARS_TITLE_FEE_MULT, FREEBIE_BASE_COST_THRESHOLD } from '../lib/constants'
import { numWithUnits } from '../lib/format'
import { useMe } from './me'
import AnonIcon from '../svgs/spy-fill.svg'
import { useShowModal } from './modal'
import Link from 'next/link'
import { SubmitButton } from './form'
import { titleExceedsFreeUppercase } from '../lib/item'

const FeeButtonContext = createContext()

Expand Down Expand Up @@ -80,7 +81,19 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = ()
return {
lines,
merge: mergeLineItems,
total: Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0),
total: Object.entries(lines)
.sort(([keyA], [keyB]) => {
// boost and image uploads comes last, so they don't get multiplied by any multipliers
if (['boost', 'imageFee'].includes(keyB)) {
return -1
}
if (['boost', 'imageFee'].includes(keyA)) {
return 1
}
return 0
})
.map(entry => entry[1])
.reduce((acc, { modifier }) => modifier(acc), 0),
disabled,
setDisabled
}
Expand Down Expand Up @@ -134,6 +147,35 @@ export default function FeeButton ({ ChildButton = SubmitButton, variant, text,
)
}

export const uppercaseTitleFeeHandler = (feeButtonHook, title, item) => {
const { freebie, sub } = item ?? {}
const tooManyUppercase = !item?.hasPaidUpperTitleFee && titleExceedsFreeUppercase({ title })
feeButtonHook.merge({
uppercaseTitle: {
term: (() => {
if (freebie) {
return `+ ${numWithUnits(UPPER_CHARS_TITLE_FEE_MULT * sub.baseCost)}`
}
if (item) {
return `+ ${numWithUnits((UPPER_CHARS_TITLE_FEE_MULT - 1) * sub.baseCost)}`
}
return `x ${UPPER_CHARS_TITLE_FEE_MULT}`
})(),
label: 'uppercase title mult',
modifier: cost => {
if (freebie) {
return cost + (tooManyUppercase ? UPPER_CHARS_TITLE_FEE_MULT * sub.baseCost : 0)
}
if (item) {
return cost + (tooManyUppercase ? (UPPER_CHARS_TITLE_FEE_MULT - 1) * sub.baseCost : 0)
}
return cost * (tooManyUppercase ? UPPER_CHARS_TITLE_FEE_MULT : 1)
},
omit: !tooManyUppercase
}
})
}

function Receipt ({ lines, total }) {
return (
<Table className={styles.receipt} borderless size='sm'>
Expand Down
7 changes: 7 additions & 0 deletions components/job-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { usePrice } from './price'
import Avatar from './avatar'
import { jobSchema } from '../lib/validate'
import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useFeeButton, uppercaseTitleFeeHandler } from './fee-button'
import { useToast } from './toast'
import { toastDeleteScheduled } from '../lib/form'
import { ItemButtonBar } from './post'
Expand Down Expand Up @@ -85,6 +86,7 @@ export default function JobForm ({ item, sub }) {
toastDeleteScheduled(toaster, data, !!item, values.text)
}, [upsertJob, router, logoId]
)
const feeButton = useFeeButton()

return (
<>
Expand Down Expand Up @@ -122,6 +124,11 @@ export default function JobForm ({ item, sub }) {
autoFocus
clear
maxLength={MAX_TITLE_LENGTH}
onChange={async (formik, e) => {
if (e.target.value) {
uppercaseTitleFeeHandler(feeButton, e.target.value, item)
}
}}
/>
<Input
label='company'
Expand Down
7 changes: 3 additions & 4 deletions components/link-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import { ITEM_FIELDS } from '../fragments/items'
import Item from './item'
import AccordianItem from './accordian-item'
import { useFeeButton, uppercaseTitleFeeHandler } from './fee-button'
import { linkSchema } from '../lib/validate'
import Moon from '../svgs/moon-fill.svg'
import { SubSelectInitial } from './sub-select-form'
Expand Down Expand Up @@ -118,6 +119,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {

const [postDisabled, setPostDisabled] = useState(false)
const [titleOverride, setTitleOverride] = useState()
const feeButton = useFeeButton()

return (
<Form
Expand Down Expand Up @@ -145,10 +147,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
getRelated({
variables: { title: e.target.value }
})
}
if (e.target.value === e.target.value.toUpperCase()) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove existing all-caps treatment

setTitleOverride(e.target.value.replace(/\w\S*/g, txt =>
txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()))
uppercaseTitleFeeHandler(feeButton, e.target.value, item)
}
}}
maxLength={MAX_TITLE_LENGTH}
Expand Down
7 changes: 7 additions & 0 deletions components/poll-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { gql, useApolloClient, useMutation } from '@apollo/client'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import { MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '../lib/constants'
import { useFeeButton, uppercaseTitleFeeHandler } from './fee-button'
import { pollSchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form'
import { useCallback } from 'react'
Expand Down Expand Up @@ -59,6 +60,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
)

const initialOptions = item?.poll?.options.map(i => i.option)
const feeButton = useFeeButton()

return (
<Form
Expand All @@ -80,6 +82,11 @@ export function PollForm ({ item, sub, editThreshold, children }) {
name='title'
required
maxLength={MAX_TITLE_LENGTH}
onChange={async (formik, e) => {
if (e.target.value) {
uppercaseTitleFeeHandler(feeButton, e.target.value, item)
}
}}
/>
<MarkdownInput
topLevel
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ services:
db:
condition: service_started
app:
condition: service_healthy
condition: service_started
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found my worker service always had to be started manually locally, so I made this change to fix that.

Copy link
Member

@huumn huumn Nov 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this was originally implemented because if app wasn't running worker occasionally crashed on start becauese the migrations weren't applied yet. idk though. If it works for you maybe that's not true.

With the new nextjs startup of the web server is slower. I wonder if this check timesout before going healthy.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could have been a false positive for me if I had already applied migrations locally, then. I just noticed that my worker never started on it's own, I always had to go start it manually, so I figured this was the cause. ¯\_(ツ)_/¯

env_file:
- ./.env.sample
ports:
Expand Down
4 changes: 4 additions & 0 deletions fragments/items.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const ITEM_FIELDS = gql`
uploadId
mine
imgproxyUrls
upperTitleFeePaid
}`

export const ITEM_FULL_FIELDS = gql`
Expand All @@ -70,6 +71,9 @@ export const ITEM_FULL_FIELDS = gql`
}
}
}
sub {
baseCost
}
forwards {
userId
pct
Expand Down
2 changes: 2 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export const MAX_FORWARDS = 5
export const LNURLP_COMMENT_MAX_LENGTH = 1000
export const RESERVED_MAX_USER_ID = 615
export const GLOBAL_SEED = 616
export const MAX_FREE_UPPER_CHARS_TITLE = 12
export const UPPER_CHARS_TITLE_FEE_MULT = 100
export const FREEBIE_BASE_COST_THRESHOLD = 10

export const FOUND_BLURBS = [
Expand Down
6 changes: 5 additions & 1 deletion lib/item.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { OLD_ITEM_DAYS } from './constants'
import { OLD_ITEM_DAYS, MAX_FREE_UPPER_CHARS_TITLE } from './constants'
import { datePivot } from './time'

export const defaultCommentSort = (pinned, bio, createdAt) => {
Expand Down Expand Up @@ -52,3 +52,7 @@ export const deleteItemByAuthor = async ({ models, id, item }) => {

return await models.item.update({ where: { id: Number(id) }, data: updateData })
}

export const getUppercaseCharCountInTitle = item => (item?.title?.match(/[A-Z]/g) || []).length

export const titleExceedsFreeUppercase = item => getUppercaseCharCountInTitle(item) > MAX_FREE_UPPER_CHARS_TITLE
Loading