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:
parent
843471e5dc
commit
46a0af19eb
@ -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`
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -52,6 +52,7 @@ export const ITEM_FIELDS = gql`
|
|||||||
remote
|
remote
|
||||||
subName
|
subName
|
||||||
pollCost
|
pollCost
|
||||||
|
pollExpiresAt
|
||||||
status
|
status
|
||||||
uploadId
|
uploadId
|
||||||
mine
|
mine
|
||||||
|
@ -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' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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({
|
||||||
|
@ -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;
|
@ -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;
|
||||||
|
$$;
|
@ -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")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user