Make poll expiration configurable (#860)

* add poll expires at column to Item table

* update upsertPoll mutation for pollExpiresAt param

* use pollExpiresAt to show time left for poll

* correctly pluralize days for timeLeft

* correctly update pollExpiresAt when item is updated to remove poll expiration

* add DateTimePicker and DateTimeInput components to select datetimes

* update pollExpiresAt to be nullable and more than 1 day in the future

* hide time left text if poll has no expiration

* initialize pollExpiresAt with current value or default of 25 hours in the future

we add a one hour time buffer so that the user doesn't get a validation error
for pollExpiresAt if they post their poll within an hour from creation. there's
still a chance they'll hit the validation error but they should see the error
message toast

* add DateTimeInput into the options part of the poll form

add right padding to make room for the "clear" button.

allow field to be cleared (i.e. null pollExpiresAt) to allow
non-ending polls.

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
mzivil 2024-02-21 13:18:43 -05:00 committed by GitHub
parent 843471e5dc
commit 46a0af19eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 170 additions and 11 deletions

View File

@ -1384,7 +1384,8 @@ export const SELECT =
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats, "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats,
"Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes", "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",
"Item"."weightedDownVotes", "Item".freebie, "Item".bio, "Item"."otsHash", "Item"."bountyPaidTo", "Item"."weightedDownVotes", "Item".freebie, "Item".bio, "Item"."otsHash", "Item"."bountyPaidTo",
ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls", "Item".outlawed` ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls", "Item".outlawed,
"Item"."pollExpiresAt"`
function topOrderByWeightedSats (me, models) { function topOrderByWeightedSats (me, models) {
return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC` return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC`

View File

@ -34,7 +34,7 @@ export default gql`
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, hash: String, hmac: String, boost: Int, forward: [ItemForwardInput]): Item! upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, hash: String, hmac: String, boost: Int, forward: [ItemForwardInput]): Item!
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): Item! text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): Item!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item! upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String, pollExpiresAt: Date): Item!
updateNoteId(id: ID!, noteId: String!): Item! updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item! upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item!
act(id: ID!, sats: Int, act: String, idempotent: Boolean, hash: String, hmac: String): ItemActResult! act(id: ID!, sats: Int, act: String, idempotent: Boolean, hash: String, hmac: String): ItemActResult!
@ -111,6 +111,7 @@ export default gql`
isJob: Boolean! isJob: Boolean!
pollCost: Int pollCost: Int
poll: Poll poll: Poll
pollExpiresAt: Date
company: String company: String
location: String location: String
remote: Boolean remote: Boolean

View File

@ -963,6 +963,38 @@ export function DatePicker ({ fromName, toName, noForm, onChange, when, from, to
) )
} }
export function DateTimeInput ({ label, groupClassName, name, ...props }) {
const [, meta] = useField({ ...props, name })
return (
<FormGroup label={label} className={groupClassName}>
<div>
<DateTimePicker name={name} {...props} />
<BootstrapForm.Control.Feedback type='invalid' className='d-block'>
{meta.error}
</BootstrapForm.Control.Feedback>
</div>
</FormGroup>
)
}
function DateTimePicker ({ name, className, ...props }) {
const [field, , helpers] = useField({ ...props, name })
return (
<ReactDatePicker
{...field}
{...props}
showTimeSelect
dateFormat='Pp'
className={`form-control ${className}`}
selected={(field.value && new Date(field.value)) || null}
value={(field.value && new Date(field.value)) || null}
onChange={(val) => {
helpers.setValue(val)
}}
/>
)
}
function Client (Component) { function Client (Component) {
return ({ initialValue, ...props }) => { return ({ initialValue, ...props }) => {
// This component can be used for Formik fields // This component can be used for Formik fields

View File

@ -1,9 +1,10 @@
import { Form, Input, MarkdownInput, VariableInput } from '../components/form' import { DateTimeInput, Form, Input, MarkdownInput, VariableInput } from '../components/form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { gql, useApolloClient, useMutation } from '@apollo/client' import { gql, useApolloClient, useMutation } from '@apollo/client'
import Countdown from './countdown' import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form' import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import { MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '../lib/constants' import { MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '../lib/constants'
import { datePivot } from '../lib/time'
import { pollSchema } from '../lib/validate' import { pollSchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select' import { SubSelectInitial } from './sub-select'
import { useCallback } from 'react' import { useCallback } from 'react'
@ -22,9 +23,9 @@ export function PollForm ({ item, sub, editThreshold, children }) {
const [upsertPoll] = useMutation( const [upsertPoll] = useMutation(
gql` gql`
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String, mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
$options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) { $options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String, $pollExpiresAt: Date) {
upsertPoll(sub: $sub, id: $id, title: $title, text: $text, upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
options: $options, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) { options: $options, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac, pollExpiresAt: $pollExpiresAt) {
id id
deleteScheduledAt deleteScheduledAt
} }
@ -66,6 +67,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
title: item?.title || '', title: item?.title || '',
text: item?.text || '', text: item?.text || '',
options: initialOptions || ['', ''], options: initialOptions || ['', ''],
pollExpiresAt: item ? item.pollExpiresAt : datePivot(new Date(), { hours: 25 }),
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }), ...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
@ -98,7 +100,14 @@ export function PollForm ({ item, sub, editThreshold, children }) {
: null} : null}
maxLength={MAX_POLL_CHOICE_LENGTH} maxLength={MAX_POLL_CHOICE_LENGTH}
/> />
<AdvPostForm edit={!!item} /> <AdvPostForm edit={!!item}>
<DateTimeInput
isClearable
label='poll expiration'
name='pollExpiresAt'
className='pr-4'
/>
</AdvPostForm>
<ItemButtonBar itemId={item?.id} /> <ItemButtonBar itemId={item?.id} />
</Form> </Form>
) )

View File

@ -80,18 +80,23 @@ export default function Poll ({ item }) {
) )
} }
const expiresIn = timeLeft(new Date(+new Date(item.createdAt) + 864e5)) const hasExpiration = !!item.pollExpiresAt
const timeRemaining = timeLeft(new Date(item.pollExpiresAt))
const mine = item.user.id === me?.id const mine = item.user.id === me?.id
const showPollButton = (!hasExpiration || timeRemaining) && !item.poll.meVoted && !mine
return ( return (
<div className={styles.pollBox}> <div className={styles.pollBox}>
{item.poll.options.map(v => {item.poll.options.map(v =>
expiresIn && !item.poll.meVoted && !mine showPollButton
? <PollButton key={v.id} v={v} /> ? <PollButton key={v.id} v={v} />
: <PollResult : <PollResult
key={v.id} v={v} key={v.id} v={v}
progress={item.poll.count ? fixedDecimal(v.count * 100 / item.poll.count, 1) : 0} progress={item.poll.count ? fixedDecimal(v.count * 100 / item.poll.count, 1) : 0}
/>)} />)}
<div className='text-muted mt-1'>{numWithUnits(item.poll.count, { unitSingular: 'vote', unitPlural: 'votes' })} \ {expiresIn ? `${expiresIn} left` : 'poll ended'}</div> <div className='text-muted mt-1'>
{numWithUnits(item.poll.count, { unitSingular: 'vote', unitPlural: 'votes' })}
{hasExpiration && ` \\ ${timeRemaining ? `${timeRemaining} left` : 'poll ended'}`}
</div>
</div> </div>
) )
} }

View File

@ -52,6 +52,7 @@ export const ITEM_FIELDS = gql`
remote remote
subName subName
pollCost pollCost
pollExpiresAt
status status
uploadId uploadId
mine mine

View File

@ -1,3 +1,5 @@
import { numWithUnits } from './format'
export function timeSince (timeStamp) { export function timeSince (timeStamp) {
const now = new Date() const now = new Date()
const secondsPast = Math.abs(now.getTime() - timeStamp) / 1000 const secondsPast = Math.abs(now.getTime() - timeStamp) / 1000
@ -62,7 +64,8 @@ export function timeLeft (timeStamp) {
return parseInt(secondsPast / 3600) + 'h' return parseInt(secondsPast / 3600) + 'h'
} }
if (secondsPast > 86400) { if (secondsPast > 86400) {
return parseInt(secondsPast / (3600 * 24)) + ' days' const days = parseInt(secondsPast / (3600 * 24))
return numWithUnits(days, { unitSingular: 'day', unitPlural: 'days' })
} }
} }

View File

@ -1,4 +1,4 @@
import { string, ValidationError, number, object, array, addMethod, boolean } from 'yup' import { string, ValidationError, number, object, array, addMethod, boolean, date } from 'yup'
import { import {
BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES, BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES,
MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES, MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES,
@ -11,6 +11,7 @@ import * as usersFragments from '../fragments/users'
import * as subsFragments from '../fragments/subs' import * as subsFragments from '../fragments/subs'
import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon' import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon'
import { parseNwcUrl } from './url' import { parseNwcUrl } from './url'
import { datePivot } from './time'
const { SUB } = subsFragments const { SUB } = subsFragments
const { NAME_QUERY } = usersFragments const { NAME_QUERY } = usersFragments
@ -396,6 +397,7 @@ export function pollSchema ({ numExistingChoices = 0, ...args }) {
message: `at least ${MIN_POLL_NUM_CHOICES} choices required`, message: `at least ${MIN_POLL_NUM_CHOICES} choices required`,
test: arr => arr.length >= MIN_POLL_NUM_CHOICES - numExistingChoices test: arr => arr.length >= MIN_POLL_NUM_CHOICES - numExistingChoices
}), }),
pollExpiresAt: date().nullable().min(datePivot(new Date(), { days: 1 }), 'Expiration must be at least 1 day in the future'),
...advPostSchemaMembers(args), ...advPostSchemaMembers(args),
...subSelectSchemaMembers(args) ...subSelectSchemaMembers(args)
}).test({ }).test({

View File

@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "pollExpiresAt" TIMESTAMP(3);
UPDATE "Item"
SET "pollExpiresAt" = "created_at" + interval '1 day'
WHERE "pollCost" IS NOT NULL;

View File

@ -0,0 +1,98 @@
CREATE OR REPLACE FUNCTION update_item(
jitem JSONB, forward JSONB, poll_options JSONB, upload_ids INTEGER[])
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
cost_msats BIGINT;
item "Item";
select_clause TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := jsonb_populate_record(NULL::"Item", jitem);
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
cost_msats := 0;
-- add image fees
IF upload_ids IS NOT NULL THEN
cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids));
UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids);
-- delete any old uploads that are no longer attached
DELETE FROM "ItemUpload" WHERE "itemId" = item.id AND "uploadId" <> ANY(upload_ids);
-- insert any new uploads that are not already attached
INSERT INTO "ItemUpload" ("itemId", "uploadId")
SELECT item.id, * FROM UNNEST(upload_ids) ON CONFLICT DO NOTHING;
END IF;
IF cost_msats > 0 AND cost_msats > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
ELSE
UPDATE users SET msats = msats - cost_msats WHERE id = item."userId";
INSERT INTO "ItemAct" (msats, "itemId", "userId", act)
VALUES (cost_msats, item.id, item."userId", 'FEE');
END IF;
IF item.boost > 0 THEN
UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id;
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
END IF;
IF item.status IS NOT NULL THEN
UPDATE "Item" SET "statusUpdatedAt" = now_utc()
WHERE id = item.id AND status <> item.status;
END IF;
IF item."pollExpiresAt" IS NULL THEN
UPDATE "Item" SET "pollExpiresAt" = NULL
WHERE id = item.id;
END IF;
SELECT string_agg(quote_ident(key), ',') INTO select_clause
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key)
WHERE key <> 'boost';
EXECUTE format($fmt$
UPDATE "Item" SET (%s) = (
SELECT %1$s
FROM jsonb_populate_record(NULL::"Item", %L)
) WHERE id = %L RETURNING *
$fmt$, select_clause, jitem, item.id) INTO item;
-- Delete any old thread subs if the user is no longer a fwd recipient
DELETE FROM "ThreadSubscription"
WHERE "itemId" = item.id
-- they aren't in the new forward list
AND NOT EXISTS (SELECT 1 FROM jsonb_populate_recordset(NULL::"ItemForward", forward) as nf WHERE "ThreadSubscription"."userId" = nf."userId")
-- and they are in the old forward list
AND EXISTS (SELECT 1 FROM "ItemForward" WHERE "ItemForward"."itemId" = item.id AND "ItemForward"."userId" = "ThreadSubscription"."userId" );
-- Automatically subscribe any new forward recipients to the post
INSERT INTO "ThreadSubscription" ("itemId", "userId")
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward)
EXCEPT
SELECT item.id, "userId" FROM "ItemForward" WHERE "itemId" = item.id;
-- Delete all old forward entries, to recreate in next command
DELETE FROM "ItemForward" WHERE "itemId" = item.id;
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
INSERT INTO "PollOption" ("itemId", "option")
SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option");
-- if this is a job
IF item."maxBid" IS NOT NULL THEN
PERFORM run_auction(item.id);
END IF;
-- schedule imgproxy job
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds');
RETURN item;
END;
$$;

View File

@ -380,6 +380,7 @@ model Item {
ItemUpload ItemUpload[] ItemUpload ItemUpload[]
uploadId Int? uploadId Int?
outlawed Boolean @default(false) outlawed Boolean @default(false)
pollExpiresAt DateTime?
@@index([uploadId]) @@index([uploadId])
@@index([bio], map: "Item.bio_index") @@index([bio], map: "Item.bio_index")