From b7132cc962bbee140b43908f87a0ffdba23e7e77 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 6 Sep 2022 16:10:13 -0500 Subject: [PATCH 01/22] use correct callbackUrl for login with tor --- components/header.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/header.js b/components/header.js index 30316f75..aa7c353f 100644 --- a/components/header.js +++ b/components/header.js @@ -125,7 +125,12 @@ export default function Header ({ sub }) { setFired(true) }, [router.asPath]) } - return path !== '/login' && !path.startsWith('/invites') && + return path !== '/login' && !path.startsWith('/invites') && + } } From 48990d5987d8e4a0a3cfbdf6b48de1eb29e6cb58 Mon Sep 17 00:00:00 2001 From: keyan Date: Mon, 12 Sep 2022 14:10:15 -0500 Subject: [PATCH 02/22] when linking email store as lowercase --- api/resolvers/user.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 222e9df0..18b38270 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -245,7 +245,10 @@ export default { } try { - await models.user.update({ where: { id: me.id }, data: { email } }) + await models.user.update({ + where: { id: me.id }, + data: { email: email.toLowerCase() } + }) } catch (error) { if (error.code === 'P2002') { throw new UserInputError('email taken') From 1b6a7e7f95d43b4bf5d6512c46e0cd433f1c4746 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 8 Sep 2022 14:52:32 -0500 Subject: [PATCH 03/22] improve trust --- worker/trust.js | 92 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 62 insertions(+), 30 deletions(-) diff --git a/worker/trust.js b/worker/trust.js index eb2d0e07..a10f9ba4 100644 --- a/worker/trust.js +++ b/worker/trust.js @@ -12,6 +12,7 @@ function trust ({ boss, models }) { // only explore a path up to this depth from start const MAX_DEPTH = 6 const MAX_TRUST = 0.9 +const MIN_SUCCESS = 5 // https://en.wikipedia.org/wiki/Normal_distribution#Quantile_function const Z_CONFIDENCE = 2.326347874041 // 98% confidence @@ -162,39 +163,70 @@ function trustGivenGraph (graph, start) { // return graph // } -// upvote confidence graph +// old upvote confidence graph +// async function getGraph (models) { +// const [{ graph }] = await models.$queryRaw` +// select json_object_agg(id, hops) as graph +// from ( +// select id, json_agg(json_build_object('node', oid, 'trust', trust)) as hops +// from ( +// select s.id, s.oid, confidence(s.shared, count(*), ${Z_CONFIDENCE}) as trust +// from ( +// select a."userId" as id, b."userId" as oid, count(*) as shared +// from "ItemAct" b +// join users bu on bu.id = b."userId" +// join "ItemAct" a on b."itemId" = a."itemId" +// join users au on au.id = a."userId" +// join "Item" on "Item".id = b."itemId" +// where b.act = 'VOTE' +// and a.act = 'VOTE' +// and "Item"."parentId" is null +// and "Item"."userId" <> b."userId" +// and "Item"."userId" <> a."userId" +// and b."userId" <> a."userId" +// and "Item".created_at >= au.created_at and "Item".created_at >= bu.created_at +// group by b."userId", a."userId") s +// join users u on s.id = u.id +// join users ou on s.oid = ou.id +// join "ItemAct" on "ItemAct"."userId" = s.oid +// join "Item" on "Item".id = "ItemAct"."itemId" +// where "ItemAct".act = 'VOTE' and "Item"."parentId" is null +// and "Item"."userId" <> s.oid and "Item"."userId" <> s.id +// and "Item".created_at >= u.created_at and "Item".created_at >= ou.created_at +// group by s.id, s.oid, s.shared +// ) a +// group by id +// ) b` +// return graph +// } + async function getGraph (models) { const [{ graph }] = await models.$queryRaw` - select json_object_agg(id, hops) as graph - from ( - select id, json_agg(json_build_object('node', oid, 'trust', trust)) as hops - from ( - select s.id, s.oid, confidence(s.shared, count(*), ${Z_CONFIDENCE}) as trust - from ( - select a."userId" as id, b."userId" as oid, count(*) as shared - from "ItemAct" b - join users bu on bu.id = b."userId" - join "ItemAct" a on b."itemId" = a."itemId" - join users au on au.id = a."userId" - join "Item" on "Item".id = b."itemId" - where b.act = 'VOTE' - and a.act = 'VOTE' - and "Item"."parentId" is null - and "Item"."userId" <> b."userId" - and "Item"."userId" <> a."userId" - and b."userId" <> a."userId" - and "Item".created_at >= au.created_at and "Item".created_at >= bu.created_at - group by b."userId", a."userId") s - join users u on s.id = u.id - join users ou on s.oid = ou.id - join "ItemAct" on "ItemAct"."userId" = s.oid - join "Item" on "Item".id = "ItemAct"."itemId" - where "ItemAct".act = 'VOTE' and "Item"."parentId" is null - and "Item"."userId" <> s.oid and "Item"."userId" <> s.id - and "Item".created_at >= u.created_at and "Item".created_at >= ou.created_at - group by s.id, s.oid, s.shared + SELECT json_object_agg(id, hops) AS graph + FROM ( + SELECT id, json_agg(json_build_object('node', oid, 'trust', trust)) AS hops + FROM ( + WITH user_votes AS ( + SELECT "ItemAct"."userId" AS user_id, users.name AS name, "ItemAct"."itemId" AS item_id, "ItemAct".created_at AS act_at, + users.created_at AS user_at, "Item".created_at AS item_at, count(*) OVER (partition by "ItemAct"."userId") AS user_vote_count + FROM "ItemAct" + JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act = 'VOTE' AND "Item"."parentId" IS NULL + JOIN users ON "ItemAct"."userId" = users.id + ), + user_pair AS ( + SELECT a.user_id AS a_id, a.name AS a_name, b.user_id AS b_id, b.name AS b_name, + count(*) FILTER(WHERE a.act_at > b.act_at) AS before, + count(*) FILTER(WHERE b.act_at > a.act_at) AS after, + CASE WHEN b.user_at > a.user_at THEN b.user_vote_count ELSE a.user_vote_count END AS total + FROM user_votes a + JOIN user_votes b ON a.item_id = b.item_id + GROUP BY a.user_id, a.name, a.user_at, a.user_vote_count, b.user_id, b.name, b.user_at, b.user_vote_count + ) + SELECT a_id AS id, a_name, b_id AS oid, b_name, confidence(before, total - after, ${Z_CONFIDENCE}) AS trust, before, after, total + FROM user_pair + WHERE before >= ${MIN_SUCCESS} ) a - group by id + GROUP BY a.id ) b` return graph } From 08893d020ca975d228cc1595a1ae19c5e34bc5a3 Mon Sep 17 00:00:00 2001 From: keyan Date: Mon, 12 Sep 2022 13:55:34 -0500 Subject: [PATCH 04/22] improved earning: more detail, longer top tail --- = | 0 api/resolvers/notifications.js | 28 +++++- api/resolvers/wallet.js | 7 +- api/typeDefs/notifications.js | 7 ++ components/notifications.js | 8 +- fragments/notifications.js | 5 ++ .../20220913173806_earn_columns/migration.sql | 10 +++ .../migration.sql | 16 ++++ prisma/schema.prisma | 12 +++ worker/earn.js | 87 ++++++++++++------- 10 files changed, 140 insertions(+), 40 deletions(-) create mode 100644 = create mode 100644 prisma/migrations/20220913173806_earn_columns/migration.sql create mode 100644 prisma/migrations/20220913173826_earn_function/migration.sql diff --git a/= b/= new file mode 100644 index 00000000..e69de29b diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 9f2f3cb7..9a5a8c2c 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -162,18 +162,20 @@ export default { if (meFull.noteEarning) { queries.push( - `SELECT id::text, created_at AS "sortTime", FLOOR(msats / 1000) as "earnedSats", + `SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats", 'Earn' AS type FROM "Earn" WHERE "userId" = $1 - AND created_at <= $2` + AND created_at <= $2 + GROUP BY "userId", created_at` ) } } // we do all this crazy subquery stuff to make 'reward' islands const notifications = await models.$queryRaw( - `SELECT MAX(id) AS id, MAX("sortTime") AS "sortTime", sum("earnedSats") AS "earnedSats", type + `SELECT MAX(id) AS id, MAX("sortTime") AS "sortTime", sum("earnedSats") AS "earnedSats", type, + MIN("sortTime") AS "minSortTime" FROM (SELECT *, CASE @@ -214,6 +216,26 @@ export default { JobChanged: { item: async (n, args, { models }) => getItem(n, { id: n.id }, { models }) }, + Earn: { + sources: async (n, args, { me, models }) => { + const [sources] = await models.$queryRaw(` + SELECT + FLOOR(sum(msats) FILTER(WHERE type = 'POST') / 1000) AS posts, + FLOOR(sum(msats) FILTER(WHERE type = 'COMMENT') / 1000) AS comments, + FLOOR(sum(msats) FILTER(WHERE type = 'TIP_POST' OR type = 'TIP_COMMENT') / 1000) AS tips + FROM "Earn" + WHERE "userId" = $1 AND created_at <= $2 AND created_at >= $3 + `, Number(me.id), new Date(n.sortTime), new Date(n.minSortTime)) + sources.posts ||= 0 + sources.comments ||= 0 + sources.tips ||= 0 + if (sources.posts + sources.comments + sources.tips > 0) { + return sources + } + + return null + } + }, Mention: { mention: async (n, args, { models }) => true, item: async (n, args, { models }) => getItem(n, { id: n.id }, { models }) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 18f1725b..c9724cee 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -103,11 +103,12 @@ export default { AND "ItemAct".created_at <= $2 GROUP BY "Item".id)`) queries.push( - `(SELECT ('earn' || "Earn".id) as id, "Earn".id as "factId", NULL as bolt11, - created_at as "createdAt", msats, + `(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11, + created_at as "createdAt", sum(msats), 0 as "msatsFee", NULL as status, 'earn' as type FROM "Earn" - WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2)`) + WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2 + GROUP BY "userId", created_at)`) } if (include.has('spent')) { diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 89261359..d880ba98 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -32,9 +32,16 @@ export default gql` sortTime: String! } + type EarnSources { + posts: Int! + comments: Int! + tips: Int! + } + type Earn { earnedSats: Int! sortTime: String! + sources: EarnSources } type InvoicePaid { diff --git a/components/notifications.js b/components/notifications.js index c55da53f..79d853f2 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -74,8 +74,14 @@ function Notification ({ n }) {
- you stacked {n.earnedSats} sats {timeSince(new Date(n.sortTime))} + you stacked {n.earnedSats} sats in rewards{timeSince(new Date(n.sortTime))}
+ {n.sources && +
+ {n.sources.posts > 0 && {n.sources.posts} sats for top posts} + {n.sources.comments > 0 && {n.sources.posts > 0 && ' \\ '}{n.sources.comments} sats for top comments} + {n.sources.tips > 0 && {(n.sources.comments > 0 || n.sources.posts > 0) && ' \\ '}{n.sources.tips} sats for tipping top content early} +
}
SN distributes the sats it earns back to its best users daily. These sats come from jobs, boost, and posting fees.
diff --git a/fragments/notifications.js b/fragments/notifications.js index 2a11d3a6..c7f43172 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -31,6 +31,11 @@ export const NOTIFICATIONS = gql` ... on Earn { sortTime earnedSats + sources { + posts + comments + tips + } } ... on Reply { sortTime diff --git a/prisma/migrations/20220913173806_earn_columns/migration.sql b/prisma/migrations/20220913173806_earn_columns/migration.sql new file mode 100644 index 00000000..27074470 --- /dev/null +++ b/prisma/migrations/20220913173806_earn_columns/migration.sql @@ -0,0 +1,10 @@ +-- CreateEnum +CREATE TYPE "EarnType" AS ENUM ('POST', 'COMMENT', 'TIP_COMMENT', 'TIP_POST'); + +-- AlterTable +ALTER TABLE "Earn" ADD COLUMN "rank" INTEGER, +ADD COLUMN "type" "EarnType", +ADD COLUMN "typeId" INTEGER; + +-- CreateIndex +CREATE INDEX "Earn.created_at_userId_index" ON "Earn"("created_at", "userId"); diff --git a/prisma/migrations/20220913173826_earn_function/migration.sql b/prisma/migrations/20220913173826_earn_function/migration.sql new file mode 100644 index 00000000..7aacc4b6 --- /dev/null +++ b/prisma/migrations/20220913173826_earn_function/migration.sql @@ -0,0 +1,16 @@ +CREATE OR REPLACE FUNCTION earn(user_id INTEGER, earn_msats INTEGER, created_at TIMESTAMP(3), + type "EarnType", type_id INTEGER, rank INTEGER) +RETURNS void AS $$ +DECLARE +BEGIN + PERFORM ASSERT_SERIALIZED(); + -- insert into earn + INSERT INTO "Earn" (msats, "userId", created_at, type, "typeId", rank) + VALUES (earn_msats, user_id, created_at, type, type_id, rank); + + -- give the user the sats + UPDATE users + SET msats = msats + earn_msats, "stackedMsats" = "stackedMsats" + earn_msats + WHERE id = user_id; +END; +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9b37d95f..bb0350a1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -88,6 +88,13 @@ model Upload { @@index([userId]) } +enum EarnType { + POST + COMMENT + TIP_COMMENT + TIP_POST +} + model Earn { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map(name: "created_at") @@ -97,8 +104,13 @@ model Earn { user User @relation(fields: [userId], references: [id]) userId Int + type EarnType? + typeId Int? + rank Int? + @@index([createdAt]) @@index([userId]) + @@index([createdAt, userId]) } model LnAuth { diff --git a/worker/earn.js b/worker/earn.js index 871a95f0..95e9895a 100644 --- a/worker/earn.js +++ b/worker/earn.js @@ -2,8 +2,7 @@ const serialize = require('../api/resolvers/serial') const ITEM_EACH_REWARD = 3.0 const UPVOTE_EACH_REWARD = 6.0 -const TOP_ITEMS = 21 -const EARLY_MULTIPLIER_MAX = 100.0 +const TOP_PERCENTILE = 21 // TODO: use a weekly trust measure or make trust decay function earn ({ models }) { @@ -11,7 +10,7 @@ function earn ({ models }) { console.log('running', name) // compute how much sn earned today - const [{ sum }] = await models.$queryRaw` + let [{ sum }] = await models.$queryRaw` SELECT sum("ItemAct".sats) FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id @@ -19,10 +18,13 @@ function earn ({ models }) { OR ("ItemAct".act IN ('VOTE','POLL') AND "Item"."userId" = "ItemAct"."userId")) AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'` + // convert to msats + sum = sum * 1000 + /* How earnings work: - 1/3: top 21 posts over last 36 hours, scored on a relative basis - 1/3: top 21 comments over last 36 hours, scored on a relative basis + 1/3: top 21% posts over last 36 hours, scored on a relative basis + 1/3: top 21% comments over last 36 hours, scored on a relative basis 1/3: top upvoters of top posts/comments, scored on: - their trust - how much they tipped @@ -30,20 +32,28 @@ function earn ({ models }) { - how the post/comment scored */ - // get earners { id, earnings } + if (sum <= 0) { + console.log('done', name, 'no earning') + return + } + + // get earners { userId, id, type, rank, proportion } const earners = await models.$queryRaw(` WITH item_ratios AS ( - SELECT *, - "weightedVotes"/coalesce(NULLIF(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL),0), ${TOP_ITEMS}) AS ratio - FROM ( - SELECT *, - ROW_NUMBER() OVER (PARTITION BY "parentId" IS NULL ORDER BY "weightedVotes" desc) AS r - FROM - "Item" - WHERE created_at >= now_utc() - interval '36 hours' - ) x - WHERE x.r <= ${TOP_ITEMS} - ), + SELECT *, + CASE WHEN "parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type, + CASE WHEN "weightedVotes" > 0 THEN "weightedVotes"/(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL)) ELSE 0 END AS ratio + FROM ( + SELECT *, + NTILE(100) OVER (PARTITION BY "parentId" IS NULL ORDER BY "weightedVotes" desc) AS percentile, + ROW_NUMBER() OVER (PARTITION BY "parentId" IS NULL ORDER BY "weightedVotes" desc) AS rank + FROM + "Item" + WHERE created_at >= now_utc() - interval '36 hours' + AND "weightedVotes" > 0 + ) x + WHERE x.percentile <= ${TOP_PERCENTILE} + ), upvoters AS ( SELECT "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId", sum("ItemAct".sats) as tipped, min("ItemAct".created_at) as acted_at @@ -54,36 +64,47 @@ function earn ({ models }) { GROUP BY "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId" ), upvoter_ratios AS ( - SELECT "userId", sum(early_multiplier*tipped_ratio*ratio*users.trust) as upvoting_score, - "parentId" IS NULL as "isPost" + SELECT "userId", sum(early_multiplier*tipped_ratio*ratio*users.trust) as upvoter_ratio, + "parentId" IS NULL as "isPost", CASE WHEN "parentId" IS NULL THEN 'TIP_POST' ELSE 'TIP_COMMENT' END as type FROM ( SELECT *, - ${EARLY_MULTIPLIER_MAX}/(ROW_NUMBER() OVER (partition by id order by acted_at asc)) AS early_multiplier, + 1/(ROW_NUMBER() OVER (partition by id order by acted_at asc)) AS early_multiplier, tipped::float/(sum(tipped) OVER (partition by id)) tipped_ratio FROM upvoters ) u JOIN users on "userId" = users.id GROUP BY "userId", "parentId" IS NULL ) - SELECT "userId" as id, FLOOR(sum(proportion)*${sum}*1000) as earnings - FROM ( - SELECT "userId", - upvoting_score/(sum(upvoting_score) OVER (PARTITION BY "isPost"))/${UPVOTE_EACH_REWARD} as proportion - FROM upvoter_ratios - UNION ALL - SELECT "userId", ratio/${ITEM_EACH_REWARD} as proportion - FROM item_ratios - ) a - GROUP BY "userId" - HAVING FLOOR(sum(proportion)*${sum}) >= 1`) + SELECT "userId", NULL as id, type, ROW_NUMBER() OVER (PARTITION BY "isPost" ORDER BY upvoter_ratio DESC) as rank, + upvoter_ratio/(sum(upvoter_ratio) OVER (PARTITION BY "isPost"))/${UPVOTE_EACH_REWARD} as proportion + FROM upvoter_ratios + WHERE upvoter_ratio > 0 + UNION ALL + SELECT "userId", id, type, rank, ratio/${ITEM_EACH_REWARD} as proportion + FROM item_ratios`) + + // in order to group earnings for users we use the same createdAt time for + // all earnings + const now = new Date(new Date().getTime()) + + // this is just a sanity check because it seems like a good idea + let total = 0 // for each earner, serialize earnings // we do this for each earner because we don't need to serialize // all earner updates together earners.forEach(async earner => { - if (earner.earnings > 0) { + const earnings = Math.floor(earner.proportion * sum) + total += earnings + if (total > sum) { + console.log('total exceeds sum', name) + return + } + + if (earnings > 0) { await serialize(models, - models.$executeRaw`SELECT earn(${earner.id}, ${earner.earnings})`) + models.$executeRaw`SELECT earn(${earner.userId}, ${earnings}, + ${now}, ${earner.type}, ${earner.id}, ${earner.rank})`) } }) From 2346f617dde7c7c449e54cae9ea680eb47a11377 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 18 Sep 2022 05:19:28 +0200 Subject: [PATCH 05/22] Delete file '=' --- = | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 = diff --git a/= b/= deleted file mode 100644 index e69de29b..00000000 From 94346d252bff869cd8c7a6d205d615e2ae905dae Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 18 Sep 2022 04:05:21 +0200 Subject: [PATCH 06/22] Fix '{' expected --- components/adv-post-form.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/adv-post-form.js b/components/adv-post-form.js index 2b36ab1f..99626863 100644 --- a/components/adv-post-form.js +++ b/components/adv-post-form.js @@ -73,7 +73,7 @@ export default function AdvPostForm ({ edit }) { label={<>forward sats to} name='forward' hint={100% of sats will be sent to this user} - prepend=@ + prepend={@} showValid /> From 7faae425b3fa1035637564a73b3e5369ec164227 Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 21 Sep 2022 14:57:36 -0500 Subject: [PATCH 07/22] wild west mode --- api/resolvers/item.js | 120 +++++++++++++++--- api/resolvers/notifications.js | 7 +- api/resolvers/user.js | 3 +- api/typeDefs/item.js | 2 + api/typeDefs/user.js | 3 +- components/comment.js | 7 +- components/comment.module.css | 8 ++ components/dont-link-this.js | 54 ++++++++ components/item.js | 9 +- components/item.module.css | 9 +- fragments/comments.js | 1 + fragments/items.js | 1 + fragments/users.js | 7 +- lib/constants.js | 2 + pages/settings.js | 21 ++- .../20220920152500_downvotes/migration.sql | 8 ++ .../migration.sql | 74 +++++++++++ prisma/schema.prisma | 9 +- svgs/cloud-fill.svg | 1 + svgs/error-warning-fill.svg | 1 + svgs/flag-2-fill.svg | 1 + svgs/flag-fill.svg | 1 + svgs/more-fill.svg | 1 + svgs/more-line.svg | 1 + 24 files changed, 316 insertions(+), 35 deletions(-) create mode 100644 components/dont-link-this.js create mode 100644 prisma/migrations/20220920152500_downvotes/migration.sql create mode 100644 prisma/migrations/20220920195257_dont_like_this/migration.sql create mode 100644 svgs/cloud-fill.svg create mode 100644 svgs/error-warning-fill.svg create mode 100644 svgs/flag-2-fill.svg create mode 100644 svgs/flag-fill.svg create mode 100644 svgs/more-fill.svg create mode 100644 svgs/more-line.svg diff --git a/api/resolvers/item.js b/api/resolvers/item.js index c4b01830..fd6716b9 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -4,20 +4,23 @@ import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import domino from 'domino' -import { BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '../../lib/constants' +import { + BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES, + MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST +} from '../../lib/constants' import { mdHas } from '../../lib/md' -async function comments (models, id, sort) { +async function comments (me, models, id, sort) { let orderBy switch (sort) { case 'top': - orderBy = 'ORDER BY "Item"."weightedVotes" DESC, "Item".id DESC' + orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".id DESC` break case 'recent': orderBy = 'ORDER BY "Item".created_at DESC, "Item".id DESC' break default: - orderBy = COMMENTS_ORDER_BY_SATS + orderBy = `ORDER BY ${await orderByNumerator(me, models)}/POWER(EXTRACT(EPOCH FROM ((NOW() AT TIME ZONE 'UTC') - "Item".created_at))/3600+2, 1.3) DESC NULLS LAST, "Item".id DESC` break } @@ -26,18 +29,18 @@ async function comments (models, id, sort) { ${SELECT}, ARRAY[row_number() OVER (${orderBy}, "Item".path)] AS sort_path FROM "Item" WHERE "parentId" = $1 + ${await filterClause(me, models)} UNION ALL ${SELECT}, p.sort_path || row_number() OVER (${orderBy}, "Item".path) FROM base p - JOIN "Item" ON "Item"."parentId" = p.id) + JOIN "Item" ON "Item"."parentId" = p.id + WHERE true + ${await filterClause(me, models)}) SELECT * FROM base ORDER BY sort_path`, Number(id)) return nestComments(flat, id)[0] } -const COMMENTS_ORDER_BY_SATS = - 'ORDER BY POWER("Item"."weightedVotes", 1.2)/POWER(EXTRACT(EPOCH FROM ((NOW() AT TIME ZONE \'UTC\') - "Item".created_at))/3600+2, 1.3) DESC NULLS LAST, "Item".id DESC' - -export async function getItem (parent, { id }, { models }) { +export async function getItem (parent, { id }, { me, models }) { const [item] = await models.$queryRaw(` ${SELECT} FROM "Item" @@ -67,6 +70,38 @@ function topClause (within) { return interval } +export async function orderByNumerator (me, models) { + if (me) { + const user = await models.user.findUnique({ where: { id: me.id } }) + if (user.wildWestMode) { + return 'GREATEST("Item"."weightedVotes", POWER("Item"."weightedVotes", 1.2))' + } + } + + return `(CASE WHEN "Item"."weightedVotes" > "Item"."weightedDownVotes" + THEN 1 + ELSE -1 END + * GREATEST(ABS("Item"."weightedVotes" - "Item"."weightedDownVotes"), POWER(ABS("Item"."weightedVotes" - "Item"."weightedDownVotes"), 1.2)))` +} + +export async function filterClause (me, models) { + if (me) { + const user = await models.user.findUnique({ where: { id: me.id } }) + if (user.wildWestMode) { + return '' + } + } + + // if the item is above the threshold or is mine + let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` + if (me) { + clause += ` OR "Item"."userId" = ${me.id}` + } + clause += ')' + + return clause +} + export default { Query: { itemRepetition: async (parent, { parentId }, { me, models }) => { @@ -106,6 +141,7 @@ export default { WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2 AND "pinId" IS NULL ${activeOrMine()} + ${await filterClause(me, models)} ORDER BY created_at DESC OFFSET $3 LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset) @@ -117,6 +153,7 @@ export default { WHERE "parentId" IS NULL AND created_at <= $1 ${subClause(3)} ${activeOrMine()} + ${await filterClause(me, models)} ORDER BY created_at DESC OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL') @@ -128,7 +165,8 @@ export default { WHERE "parentId" IS NULL AND "Item".created_at <= $1 AND "pinId" IS NULL ${topClause(within)} - ${TOP_ORDER_BY_SATS} + ${await filterClause(me, models)} + ${await topOrderByWeightedSats(me, models)} OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) break @@ -179,7 +217,8 @@ export default { WHERE "parentId" IS NULL AND "Item".created_at <= $1 AND "Item".created_at > $3 AND "pinId" IS NULL ${subClause(4)} - ${newTimedOrderByWeightedSats(1)} + ${await filterClause(me, models)} + ${await newTimedOrderByWeightedSats(me, models, 1)} OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date().setDate(new Date().getDate() - 5)), sub || 'NULL') } @@ -191,7 +230,8 @@ export default { WHERE "parentId" IS NULL AND "Item".created_at <= $1 AND "pinId" IS NULL ${subClause(3)} - ${newTimedOrderByWeightedSats(1)} + ${await filterClause(me, models)} + ${await newTimedOrderByWeightedSats(me, models, 1)} OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL') } @@ -219,11 +259,12 @@ export default { pins } }, - allItems: async (parent, { cursor }, { models }) => { + allItems: async (parent, { cursor }, { me, models }) => { const decodedCursor = decodeCursor(cursor) const items = await models.$queryRaw(` ${SELECT} FROM "Item" + ${await filterClause(me, models)} ORDER BY created_at DESC OFFSET $1 LIMIT ${LIMIT}`, decodedCursor.offset) @@ -242,6 +283,7 @@ export default { ${SELECT} FROM "Item" WHERE "parentId" IS NOT NULL AND created_at <= $1 + ${await filterClause(me, models)} ORDER BY created_at DESC OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) @@ -261,6 +303,7 @@ export default { FROM "Item" WHERE "userId" = $1 AND "parentId" IS NOT NULL AND created_at <= $2 + ${await filterClause(me, models)} ORDER BY created_at DESC OFFSET $3 LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset) @@ -272,7 +315,8 @@ export default { WHERE "parentId" IS NOT NULL AND "Item".created_at <= $1 ${topClause(within)} - ${TOP_ORDER_BY_SATS} + ${await filterClause(me, models)} + ${await topOrderByWeightedSats(me, models)} OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) break @@ -322,8 +366,8 @@ export default { ORDER BY created_at DESC LIMIT 3`, similar) }, - comments: async (parent, { id, sort }, { models }) => { - return comments(models, id, sort) + comments: async (parent, { id, sort }, { me, models }) => { + return comments(me, models, id, sort) }, search: async (parent, { q: query, sub, cursor }, { me, models, search }) => { const decodedCursor = decodeCursor(cursor) @@ -636,6 +680,25 @@ export default { vote, sats } + }, + dontLikeThis: async (parent, { id }, { me, models }) => { + // need to make sure we are logged in + if (!me) { + throw new AuthenticationError('you must be logged in') + } + + // disallow self down votes + const [item] = await models.$queryRaw(` + ${SELECT} + FROM "Item" + WHERE id = $1 AND "userId" = $2`, Number(id), me.id) + if (item) { + throw new UserInputError('cannot downvote your self') + } + + await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST})`) + + return true } }, Item: { @@ -710,11 +773,11 @@ export default { } return await models.user.findUnique({ where: { id: item.fwdUserId } }) }, - comments: async (item, args, { models }) => { + comments: async (item, args, { me, models }) => { if (item.comments) { return item.comments } - return comments(models, item.id, 'hot') + return comments(me, models, item.id, 'hot') }, upvotes: async (item, args, { models }) => { const { sum: { sats } } = await models.itemAct.aggregate({ @@ -768,6 +831,19 @@ export default { return sats || 0 }, + meDontLike: async (item, args, { me, models }) => { + if (!me) return false + + const dontLike = await models.itemAct.findFirst({ + where: { + itemId: Number(item.id), + userId: me.id, + act: 'DONT_LIKE_THIS' + } + }) + + return !!dontLike + }, mine: async (item, args, { me, models }) => { return me?.id === item.userId }, @@ -940,10 +1016,12 @@ export const SELECT = "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item"."paidImgLink", "Item".sats, "Item".ncomments, "Item"."commentSats", "Item"."lastCommentAt", ltree2text("Item"."path") AS "path"` -function newTimedOrderByWeightedSats (num) { +async function newTimedOrderByWeightedSats (me, models, num) { return ` - ORDER BY (POWER("Item"."weightedVotes", 1.2)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 1.3) + + ORDER BY (${await orderByNumerator(me, models)}/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 1.3) + ("Item".boost/${BOOST_MIN}::float)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 2.6)) DESC NULLS LAST, "Item".id DESC` } -const TOP_ORDER_BY_SATS = 'ORDER BY "Item"."weightedVotes" DESC NULLS LAST, "Item".id DESC' +async function topOrderByWeightedSats (me, models) { + return `ORDER BY ${await orderByNumerator(me, models)} DESC NULLS LAST, "Item".id DESC` +} diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 9a5a8c2c..aa08603a 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -1,6 +1,6 @@ import { AuthenticationError } from 'apollo-server-micro' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' -import { getItem } from './item' +import { getItem, filterClause } from './item' import { getInvoice } from './wallet' export default { @@ -76,7 +76,8 @@ export default { FROM "Item" JOIN "Item" p ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'} WHERE p."userId" = $1 - AND "Item"."userId" <> $1 AND "Item".created_at <= $2` + AND "Item"."userId" <> $1 AND "Item".created_at <= $2 + ${await filterClause(me, models)}` ) } else { queries.push( @@ -86,6 +87,7 @@ export default { JOIN "Item" p ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'} WHERE p."userId" = $1 AND "Item"."userId" <> $1 AND "Item".created_at <= $2 + ${await filterClause(me, models)} ORDER BY "sortTime" DESC LIMIT ${LIMIT}+$3)` ) @@ -129,6 +131,7 @@ export default { AND "Mention".created_at <= $2 AND "Item"."userId" <> $1 AND (p."userId" IS NULL OR p."userId" <> $1) + ${await filterClause(me, models)} ORDER BY "sortTime" DESC LIMIT ${LIMIT}+$3)` ) diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 18b38270..fb3277b5 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -1,7 +1,7 @@ import { AuthenticationError, UserInputError } from 'apollo-server-errors' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { mdHas } from '../../lib/md' -import { createMentions, getItem, SELECT, updateItem } from './item' +import { createMentions, getItem, SELECT, updateItem, filterClause } from './item' import serialize from './serial' export function topClause (within) { @@ -317,6 +317,7 @@ export default { JOIN "Item" p ON ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'} WHERE p."userId" = $1 AND "Item".created_at > $2 AND "Item"."userId" <> $1 + ${await filterClause(me, models)} LIMIT 1`, me.id, lastChecked) if (newReplies.length > 0) { return true diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 274b3b36..f9cbab91 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -27,6 +27,7 @@ export default gql` upsertPoll(id: ID, title: String!, text: String, options: [String!]!, boost: Int, forward: String): Item! createComment(text: String!, parentId: ID!): Item! updateComment(id: ID!, text: String!): Item! + dontLikeThis(id: ID!): Boolean! act(id: ID!, sats: Int): ItemActResult! pollVote(id: ID!): ID! } @@ -78,6 +79,7 @@ export default gql` lastCommentAt: String upvotes: Int! meSats: Int! + meDontLike: Boolean! paidImgLink: Boolean ncomments: Int! comments: [Item!]! diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 86fde278..411fb9b6 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -31,7 +31,7 @@ export default gql` setName(name: String!): Boolean setSettings(tipDefault: Int!, noteItemSats: Boolean!, noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!, - noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!): User + noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!, wildWestMode: Boolean!): User setPhoto(photoId: ID!): Int! upsertBio(bio: String!): User! setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean @@ -72,6 +72,7 @@ export default gql` noteInvites: Boolean! noteJobIndicator: Boolean! hideInvoiceDesc: Boolean! + wildWestMode: Boolean! lastCheckedJobs: String authMethods: AuthMethods! } diff --git a/components/comment.js b/components/comment.js index f8bca31a..0a923ff3 100644 --- a/components/comment.js +++ b/components/comment.js @@ -13,6 +13,9 @@ import CommentEdit from './comment-edit' import Countdown from './countdown' import { COMMENT_DEPTH_LIMIT, NOFOLLOW_LIMIT } from '../lib/constants' import { ignoreClick } from '../lib/clicks' +import { useMe } from './me' +import DontLikeThis from './dont-link-this' +import Flag from '../svgs/flag-fill.svg' function Parent ({ item, rootText }) { const ParentFrag = () => ( @@ -78,6 +81,7 @@ export default function Comment ({ const [edit, setEdit] = useState() const [collapse, setCollapse] = useState(false) const ref = useRef(null) + const me = useMe() const router = useRouter() const mine = item.mine const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 @@ -105,7 +109,7 @@ export default function Comment ({ ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse ? styles.collapsed : ''}`} >
- + {item.meDontLike ? : }
@@ -128,6 +132,7 @@ export default function Comment ({ {timeSince(new Date(item.createdAt))} {includeParent && } + {me && !item.meSats && !item.meDontLike && } {canEdit && <> \ diff --git a/components/comment.module.css b/components/comment.module.css index ea9d316d..047a00d4 100644 --- a/components/comment.module.css +++ b/components/comment.module.css @@ -8,6 +8,14 @@ margin-top: 9px; } +.dontLike { + fill: #a5a5a5; + margin-right: .2rem; + padding: 2px; + margin-left: 1px; + margin-top: 9px; +} + .text { margin-top: .1rem; padding-right: 15px; diff --git a/components/dont-link-this.js b/components/dont-link-this.js new file mode 100644 index 00000000..1019f5e6 --- /dev/null +++ b/components/dont-link-this.js @@ -0,0 +1,54 @@ +import { gql, useMutation } from '@apollo/client' +import { Dropdown } from 'react-bootstrap' +import MoreIcon from '../svgs/more-fill.svg' +import { useFundError } from './fund-error' + +export default function DontLikeThis ({ id }) { + const { setError } = useFundError() + + const [dontLikeThis] = useMutation( + gql` + mutation dontLikeThis($id: ID!) { + dontLikeThis(id: $id) + }`, { + update (cache) { + cache.modify({ + id: `Item:${id}`, + fields: { + meDontLike () { + return true + } + } + }) + } + } + ) + + return ( + + + + + + + { + try { + await dontLikeThis({ + variables: { id }, + optimisticResponse: { dontLikeThis: true } + }) + } catch (error) { + if (error.toString().includes('insufficient funds')) { + setError(true) + } + } + }} + > + I don't like this + + + + ) +} diff --git a/components/item.js b/components/item.js index f41559c2..91d11643 100644 --- a/components/item.js +++ b/components/item.js @@ -11,6 +11,9 @@ import Toc from './table-of-contents' import PollIcon from '../svgs/bar-chart-horizontal-fill.svg' import { Badge } from 'react-bootstrap' import { newComments } from '../lib/new-comments' +import { useMe } from './me' +import DontLikeThis from './dont-link-this' +import Flag from '../svgs/flag-fill.svg' export function SearchTitle ({ title }) { return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { @@ -36,6 +39,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) { useState(mine && (Date.now() < editThreshold)) const [wrap, setWrap] = useState(false) const titleRef = useRef() + const me = useMe() const [hasNewComments, setHasNewComments] = useState(false) useEffect(() => { @@ -58,7 +62,9 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
) :
}
- {item.position ? : } + {item.position + ? + : item.meDontLike ? : }
@@ -104,6 +110,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) { {timeSince(new Date(item.createdAt))} + {me && !item.meSats && !item.position && !item.meDontLike && } {item.prior && <> \ diff --git a/components/item.module.css b/components/item.module.css index 41b8515d..eb9bd351 100644 --- a/components/item.module.css +++ b/components/item.module.css @@ -30,6 +30,13 @@ a.title:visited { margin-right: .2rem; } +.dontLike { + fill: #a5a5a5; + margin-right: .2rem; + padding: 2px; + margin-left: 1px; +} + .case { fill: #a5a5a5; margin-right: .2rem; @@ -76,7 +83,7 @@ a.link:visited { } .hunk { - overflow: hidden; + min-width: 0; width: 100%; line-height: 1.06rem; } diff --git a/fragments/comments.js b/fragments/comments.js index d73fa49f..57718b50 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -14,6 +14,7 @@ export const COMMENT_FIELDS = gql` upvotes boost meSats + meDontLike path commentSats mine diff --git a/fragments/items.js b/fragments/items.js index 3196d896..7b7aa2a5 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -21,6 +21,7 @@ export const ITEM_FIELDS = gql` boost path meSats + meDontLike ncomments commentSats lastCommentAt diff --git a/fragments/users.js b/fragments/users.js index 93ae84a4..8ccbe45c 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -25,6 +25,7 @@ export const ME = gql` noteInvites noteJobIndicator hideInvoiceDesc + wildWestMode lastCheckedJobs } }` @@ -50,6 +51,7 @@ export const ME_SSR = gql` noteInvites noteJobIndicator hideInvoiceDesc + wildWestMode lastCheckedJobs } }` @@ -65,6 +67,7 @@ export const SETTINGS_FIELDS = gql` noteInvites noteJobIndicator hideInvoiceDesc + wildWestMode authMethods { lightning email @@ -86,11 +89,11 @@ gql` ${SETTINGS_FIELDS} mutation setSettings($tipDefault: Int!, $noteItemSats: Boolean!, $noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!, - $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!) { + $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!, $wildWestMode: Boolean!) { setSettings(tipDefault: $tipDefault, noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants, noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites, - noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc) { + noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc, wildWestMode: $wildWestMode) { ...SettingsFields } } diff --git a/lib/constants.js b/lib/constants.js index bce39bdc..ec7a9582 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -14,3 +14,5 @@ export const MAX_TITLE_LENGTH = 80 export const MAX_POLL_CHOICE_LENGTH = 30 export const ITEM_SPAM_INTERVAL = '10m' export const MAX_POLL_NUM_CHOICES = 10 +export const ITEM_FILTER_THRESHOLD = 1.2 +export const DONT_LIKE_THIS_COST = 1 diff --git a/pages/settings.js b/pages/settings.js index 2d260192..cc7a7789 100644 --- a/pages/settings.js +++ b/pages/settings.js @@ -61,7 +61,8 @@ export default function Settings ({ data: { settings } }) { noteDeposits: settings?.noteDeposits, noteInvites: settings?.noteInvites, noteJobIndicator: settings?.noteJobIndicator, - hideInvoiceDesc: settings?.hideInvoiceDesc + hideInvoiceDesc: settings?.hideInvoiceDesc, + wildWestMode: settings?.wildWestMode }} schema={SettingsSchema} onSubmit={async ({ tipDefault, ...values }) => { @@ -115,7 +116,7 @@ export default function Settings ({ data: { settings } }) {
privacy
hide invoice descriptions +
hide invoice descriptions
  • Use this if you don't want funding sources to be linkable to your SN identity.
  • @@ -127,10 +128,24 @@ export default function Settings ({ data: { settings } }) {
- +
} name='hideInvoiceDesc' /> +
content
+ wild west mode + +
    +
  • Don't hide flagged content
  • +
  • Don't down rank flagged content
  • +
+
+
+ } + name='wildWestMode' + />
save
diff --git a/prisma/migrations/20220920152500_downvotes/migration.sql b/prisma/migrations/20220920152500_downvotes/migration.sql new file mode 100644 index 00000000..7a08679b --- /dev/null +++ b/prisma/migrations/20220920152500_downvotes/migration.sql @@ -0,0 +1,8 @@ +-- AlterEnum +ALTER TYPE "ItemActType" ADD VALUE 'DONT_LIKE_THIS'; + +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "weightedDownVotes" DOUBLE PRECISION NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "wildWestMode" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20220920195257_dont_like_this/migration.sql b/prisma/migrations/20220920195257_dont_like_this/migration.sql new file mode 100644 index 00000000..d6c4912f --- /dev/null +++ b/prisma/migrations/20220920195257_dont_like_this/migration.sql @@ -0,0 +1,74 @@ +-- modify it to take DONT_LIKE_THIS +CREATE OR REPLACE FUNCTION item_act(item_id INTEGER, user_id INTEGER, act "ItemActType", act_sats INTEGER) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + user_sats INTEGER; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT (msats / 1000) INTO user_sats FROM users WHERE id = user_id; + IF act_sats > user_sats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + -- deduct sats from actor + UPDATE users SET msats = msats - (act_sats * 1000) WHERE id = user_id; + + IF act = 'VOTE' OR act = 'TIP' THEN + -- add sats to actee's balance and stacked count + UPDATE users + SET msats = msats + (act_sats * 1000), "stackedMsats" = "stackedMsats" + (act_sats * 1000) + WHERE id = (SELECT COALESCE("fwdUserId", "userId") FROM "Item" WHERE id = item_id); + + -- if they have already voted, this is a tip + IF EXISTS (SELECT 1 FROM "ItemAct" WHERE "itemId" = item_id AND "userId" = user_id AND "ItemAct".act = 'VOTE') THEN + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc()); + ELSE + -- else this is a vote with a possible extra tip + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (1, item_id, user_id, 'VOTE', now_utc(), now_utc()); + act_sats := act_sats - 1; + + -- if we have sats left after vote, leave them as a tip + IF act_sats > 0 THEN + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc()); + END IF; + + RETURN 1; + END IF; + ELSE -- BOOST, POLL, DONT_LIKE_THIS + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (act_sats, item_id, user_id, act, now_utc(), now_utc()); + END IF; + + RETURN 0; +END; +$$; + +CREATE OR REPLACE FUNCTION weighted_downvotes_after_act() RETURNS TRIGGER AS $$ +DECLARE + user_trust DOUBLE PRECISION; +BEGIN + -- grab user's trust who is upvoting + SELECT trust INTO user_trust FROM users WHERE id = NEW."userId"; + -- update item + UPDATE "Item" + SET "weightedDownVotes" = "weightedDownVotes" + user_trust + WHERE id = NEW."itemId" AND "userId" <> NEW."userId"; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS weighted_downvotes_after_act ON "ItemAct"; +CREATE TRIGGER weighted_downvotes_after_act + AFTER INSERT ON "ItemAct" + FOR EACH ROW + WHEN (NEW.act = 'DONT_LIKE_THIS') + EXECUTE PROCEDURE weighted_downvotes_after_act(); + +ALTER TABLE "Item" ADD CONSTRAINT "weighted_votes_positive" CHECK ("weightedVotes" >= 0) NOT VALID; +ALTER TABLE "Item" ADD CONSTRAINT "weighted_down_votes_positive" CHECK ("weightedDownVotes" >= 0) NOT VALID; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bb0350a1..64e4ed54 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -59,6 +59,9 @@ model User { // privacy settings hideInvoiceDesc Boolean @default(false) + // content settings + wildWestMode Boolean @default(false) + Earn Earn[] Upload Upload[] @relation(name: "Uploads") PollVote PollVote[] @@ -183,8 +186,9 @@ model Item { paidImgLink Boolean @default(false) // denormalized self stats - weightedVotes Float @default(0) - sats Int @default(0) + weightedVotes Float @default(0) + weightedDownVotes Float @default(0) + sats Int @default(0) // denormalized comment stats ncomments Int @default(0) @@ -296,6 +300,7 @@ enum ItemActType { TIP STREAM POLL + DONT_LIKE_THIS } model ItemAct { diff --git a/svgs/cloud-fill.svg b/svgs/cloud-fill.svg new file mode 100644 index 00000000..ba229a29 --- /dev/null +++ b/svgs/cloud-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/error-warning-fill.svg b/svgs/error-warning-fill.svg new file mode 100644 index 00000000..a0e4ce1a --- /dev/null +++ b/svgs/error-warning-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/flag-2-fill.svg b/svgs/flag-2-fill.svg new file mode 100644 index 00000000..db4089ec --- /dev/null +++ b/svgs/flag-2-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/flag-fill.svg b/svgs/flag-fill.svg new file mode 100644 index 00000000..cfc536a6 --- /dev/null +++ b/svgs/flag-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/more-fill.svg b/svgs/more-fill.svg new file mode 100644 index 00000000..087b4440 --- /dev/null +++ b/svgs/more-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/more-line.svg b/svgs/more-line.svg new file mode 100644 index 00000000..aafdf470 --- /dev/null +++ b/svgs/more-line.svg @@ -0,0 +1 @@ + \ No newline at end of file From 3dc86189bec5f0ec89c6c92553d58fddd930712c Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 22 Sep 2022 10:26:02 -0500 Subject: [PATCH 08/22] fix search icon --- components/search.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/search.js b/components/search.js index cdb724fb..664457e4 100644 --- a/components/search.js +++ b/components/search.js @@ -1,6 +1,6 @@ import { Button, Container } from 'react-bootstrap' import styles from './search.module.css' -import SearchIcon from '../svgs/search-fill.svg' +import SearchIcon from '../svgs/search-line.svg' import CloseIcon from '../svgs/close-line.svg' import { useEffect, useState } from 'react' import { Form, Input, SubmitButton } from './form' From 4b00661ad0ccdc4189fcc7879ff030594315787d Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 22 Sep 2022 10:43:17 -0500 Subject: [PATCH 09/22] change dont like this to flag --- components/dont-link-this.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/dont-link-this.js b/components/dont-link-this.js index 1019f5e6..1ed60658 100644 --- a/components/dont-link-this.js +++ b/components/dont-link-this.js @@ -46,7 +46,7 @@ export default function DontLikeThis ({ id }) { } }} > - I don't like this + flag From 2c7c237fc73126e658198664d9bcc5402258d88f Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 22 Sep 2022 13:44:50 -0500 Subject: [PATCH 10/22] show when items are outlawed --- api/resolvers/item.js | 28 ++++++++++++++++- api/typeDefs/item.js | 2 ++ components/comment.js | 2 ++ components/item.js | 1 + components/items.js | 9 ++++-- fragments/comments.js | 1 + fragments/items.js | 14 +++++++++ lib/apollo.js | 13 ++++++++ pages/outlawed.js | 73 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 pages/outlawed.js diff --git a/api/resolvers/item.js b/api/resolvers/item.js index fd6716b9..377c23c0 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -273,6 +273,25 @@ export default { items } }, + outlawedItems: async (parent, { cursor }, { me, models }) => { + const decodedCursor = decodeCursor(cursor) + const notMine = () => { + return me ? ` AND "userId" <> ${me.id} ` : '' + } + + const items = await models.$queryRaw(` + ${SELECT} + FROM "Item" + WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD} + ${notMine()} + ORDER BY created_at DESC + OFFSET $1 + LIMIT ${LIMIT}`, decodedCursor.offset) + return { + cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + items + } + }, moreFlatComments: async (parent, { cursor, name, sort, within }, { me, models }) => { const decodedCursor = decodeCursor(cursor) @@ -844,6 +863,12 @@ export default { return !!dontLike }, + outlawed: async (item, args, { me, models }) => { + if (me && Number(item.userId) === Number(me.id)) { + return false + } + return item.weightedVotes - item.weightedDownVotes <= -ITEM_FILTER_THRESHOLD + }, mine: async (item, args, { me, models }) => { return me?.id === item.userId }, @@ -1014,7 +1039,8 @@ export const SELECT = "Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid", "Item".company, "Item".location, "Item".remote, "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item"."paidImgLink", - "Item".sats, "Item".ncomments, "Item"."commentSats", "Item"."lastCommentAt", ltree2text("Item"."path") AS "path"` + "Item".sats, "Item".ncomments, "Item"."commentSats", "Item"."lastCommentAt", "Item"."weightedVotes", + "Item"."weightedDownVotes", ltree2text("Item"."path") AS "path"` async function newTimedOrderByWeightedSats (me, models, num) { return ` diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index f9cbab91..1e112a68 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -12,6 +12,7 @@ export default gql` search(q: String, sub: String, cursor: String): Items auctionPosition(sub: String, id: ID, bid: Int!): Int! itemRepetition(parentId: ID): Int! + outlawedItems(cursor: String): Items } type ItemActResult { @@ -80,6 +81,7 @@ export default gql` upvotes: Int! meSats: Int! meDontLike: Boolean! + outlawed: Boolean! paidImgLink: Boolean ncomments: Int! comments: [Item!]! diff --git a/components/comment.js b/components/comment.js index 0a923ff3..5768fc17 100644 --- a/components/comment.js +++ b/components/comment.js @@ -16,6 +16,7 @@ import { ignoreClick } from '../lib/clicks' import { useMe } from './me' import DontLikeThis from './dont-link-this' import Flag from '../svgs/flag-fill.svg' +import { Badge } from 'react-bootstrap' function Parent ({ item, rootText }) { const ParentFrag = () => ( @@ -133,6 +134,7 @@ export default function Comment ({ {includeParent && } {me && !item.meSats && !item.meDontLike && } + {item.outlawed && {' '}OUTLAWED} {canEdit && <> \ diff --git a/components/item.js b/components/item.js index 91d11643..cda815bc 100644 --- a/components/item.js +++ b/components/item.js @@ -111,6 +111,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) { {timeSince(new Date(item.createdAt))} {me && !item.meSats && !item.position && !item.meDontLike && } + {item.outlawed && {' '}OUTLAWED} {item.prior && <> \ diff --git a/components/items.js b/components/items.js index 4f4c5002..b7fa711b 100644 --- a/components/items.js +++ b/components/items.js @@ -30,7 +30,12 @@ export default function Items ({ variables = {}, rank, items, pins, cursor }) { ? <>
: (item.maxBid ? - : )} + : (item.title + ? + : ( +
+ +
)))} ))}
@@ -42,7 +47,7 @@ export default function Items ({ variables = {}, rank, items, pins, cursor }) { ) } -function ItemsSkeleton ({ rank, startRank = 0 }) { +export function ItemsSkeleton ({ rank, startRank = 0 }) { const items = new Array(21).fill(null) return ( diff --git a/fragments/comments.js b/fragments/comments.js index 57718b50..5e188576 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -15,6 +15,7 @@ export const COMMENT_FIELDS = gql` boost meSats meDontLike + outlawed path commentSats mine diff --git a/fragments/items.js b/fragments/items.js index 7b7aa2a5..d2b92a0a 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -22,6 +22,7 @@ export const ITEM_FIELDS = gql` path meSats meDontLike + outlawed ncomments commentSats lastCommentAt @@ -68,6 +69,19 @@ export const ITEMS = gql` } }` +export const OUTLAWED_ITEMS = gql` + ${ITEM_FIELDS} + + query outlawedItems($cursor: String) { + outlawedItems(cursor: $cursor) { + cursor + items { + ...ItemFields + text + } + } + }` + export const POLL_FIELDS = gql` fragment PollFields on Item { poll { diff --git a/lib/apollo.js b/lib/apollo.js index dab833e1..ef9bb49d 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -52,6 +52,19 @@ export default function getApolloClient () { } } }, + outlawedItems: { + keyArgs: [], + merge (existing, incoming) { + if (isFirstPage(incoming.cursor, existing?.items)) { + return incoming + } + + return { + cursor: incoming.cursor, + items: [...(existing?.items || []), ...incoming.items] + } + } + }, search: { keyArgs: ['q'], merge (existing, incoming) { diff --git a/pages/outlawed.js b/pages/outlawed.js new file mode 100644 index 00000000..086a6158 --- /dev/null +++ b/pages/outlawed.js @@ -0,0 +1,73 @@ +import Layout from '../components/layout' +import { ItemsSkeleton } from '../components/items' +import { getGetServerSideProps } from '../api/ssrApollo' +import { OUTLAWED_ITEMS } from '../fragments/items' +import { useQuery } from '@apollo/client' +import React from 'react' +import styles from '../components/items.module.css' +import MoreFooter from '../components/more-footer' +import Item from '../components/item' +import ItemJob from '../components/item-job' +import Comment from '../components/comment' +import { ignoreClick } from '../lib/clicks' +import { useRouter } from 'next/router' + +export const getServerSideProps = getGetServerSideProps(OUTLAWED_ITEMS) + +export default function Index ({ data: { outlawedItems: { items, cursor } } }) { + return ( + + + + ) +} + +function Items ({ rank, items, cursor }) { + const { data, fetchMore } = useQuery(OUTLAWED_ITEMS) + const router = useRouter() + + if (!data && !items) { + return + } + + if (data) { + ({ outlawedItems: { items, cursor } } = data) + } + + return ( + <> +
+ {items.map((item, i) => ( + + {item.parentId + ? ( + <>
+
{ + if (ignoreClick(e)) { + return + } + router.push({ + pathname: '/items/[id]', + query: { id: item.root.id, commentId: item.id } + }, `/items/${item.root.id}`) + }} + > + +
+ ) + : (item.maxBid + ? + : )} + + ))} +
+ } + /> + + ) +} From 9c4d74888f367664e303d6557f182a567570bfe5 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 22 Sep 2022 15:42:04 -0500 Subject: [PATCH 11/22] add borderland --- api/resolvers/item.js | 20 +++++++++++++++++ api/typeDefs/item.js | 1 + components/items-mixed.js | 47 +++++++++++++++++++++++++++++++++++++++ fragments/items.js | 13 +++++++++++ lib/apollo.js | 13 +++++++++++ pages/borderland.js | 32 ++++++++++++++++++++++++++ pages/outlawed.js | 45 ++----------------------------------- 7 files changed, 128 insertions(+), 43 deletions(-) create mode 100644 components/items-mixed.js create mode 100644 pages/borderland.js diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 377c23c0..78a09ade 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -292,6 +292,26 @@ export default { items } }, + borderlandItems: async (parent, { cursor }, { me, models }) => { + const decodedCursor = decodeCursor(cursor) + const notMine = () => { + return me ? ` AND "userId" <> ${me.id} ` : '' + } + + const items = await models.$queryRaw(` + ${SELECT} + FROM "Item" + WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" < 0 + AND "Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD} + ${notMine()} + ORDER BY created_at DESC + OFFSET $1 + LIMIT ${LIMIT}`, decodedCursor.offset) + return { + cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + items + } + }, moreFlatComments: async (parent, { cursor, name, sort, within }, { me, models }) => { const decodedCursor = decodeCursor(cursor) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 1e112a68..6b3bf2bd 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -13,6 +13,7 @@ export default gql` auctionPosition(sub: String, id: ID, bid: Int!): Int! itemRepetition(parentId: ID): Int! outlawedItems(cursor: String): Items + borderlandItems(cursor: String): Items } type ItemActResult { diff --git a/components/items-mixed.js b/components/items-mixed.js new file mode 100644 index 00000000..efa3f79f --- /dev/null +++ b/components/items-mixed.js @@ -0,0 +1,47 @@ +import { useRouter } from 'next/router' +import React from 'react' +import { ignoreClick } from '../lib/clicks' +import Comment from './comment' +import Item from './item' +import ItemJob from './item-job' +import { ItemsSkeleton } from './items' +import styles from './items.module.css' +import MoreFooter from './more-footer' + +export default function MixedItems ({ rank, items, cursor, fetchMore }) { + const router = useRouter() + return ( + <> +
+ {items.map((item, i) => ( + + {item.parentId + ? ( + <>
+
{ + if (ignoreClick(e)) { + return + } + router.push({ + pathname: '/items/[id]', + query: { id: item.root.id, commentId: item.id } + }, `/items/${item.root.id}`) + }} + > + +
+ ) + : (item.maxBid + ? + : )} + + ))} +
+ } + /> + + ) +} diff --git a/fragments/items.js b/fragments/items.js index d2b92a0a..acdecb8d 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -82,6 +82,19 @@ export const OUTLAWED_ITEMS = gql` } }` +export const BORDERLAND_ITEMS = gql` + ${ITEM_FIELDS} + + query borderlandItems($cursor: String) { + borderlandItems(cursor: $cursor) { + cursor + items { + ...ItemFields + text + } + } + }` + export const POLL_FIELDS = gql` fragment PollFields on Item { poll { diff --git a/lib/apollo.js b/lib/apollo.js index ef9bb49d..8a50c84d 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -65,6 +65,19 @@ export default function getApolloClient () { } } }, + borderlandItems: { + keyArgs: [], + merge (existing, incoming) { + if (isFirstPage(incoming.cursor, existing?.items)) { + return incoming + } + + return { + cursor: incoming.cursor, + items: [...(existing?.items || []), ...incoming.items] + } + } + }, search: { keyArgs: ['q'], merge (existing, incoming) { diff --git a/pages/borderland.js b/pages/borderland.js new file mode 100644 index 00000000..a3d6bd2b --- /dev/null +++ b/pages/borderland.js @@ -0,0 +1,32 @@ +import Layout from '../components/layout' +import { ItemsSkeleton } from '../components/items' +import { getGetServerSideProps } from '../api/ssrApollo' +import { BORDERLAND_ITEMS } from '../fragments/items' +import { useQuery } from '@apollo/client' +import MixedItems from '../components/items-mixed' + +export const getServerSideProps = getGetServerSideProps(BORDERLAND_ITEMS) + +export default function Index ({ data: { borderlandItems: { items, cursor } } }) { + return ( + + + + ) +} + +function Items ({ rank, items, cursor }) { + const { data, fetchMore } = useQuery(BORDERLAND_ITEMS) + + if (!data && !items) { + return + } + + if (data) { + ({ borderlandItems: { items, cursor } } = data) + } + + return +} diff --git a/pages/outlawed.js b/pages/outlawed.js index 086a6158..48c62a92 100644 --- a/pages/outlawed.js +++ b/pages/outlawed.js @@ -3,14 +3,7 @@ import { ItemsSkeleton } from '../components/items' import { getGetServerSideProps } from '../api/ssrApollo' import { OUTLAWED_ITEMS } from '../fragments/items' import { useQuery } from '@apollo/client' -import React from 'react' -import styles from '../components/items.module.css' -import MoreFooter from '../components/more-footer' -import Item from '../components/item' -import ItemJob from '../components/item-job' -import Comment from '../components/comment' -import { ignoreClick } from '../lib/clicks' -import { useRouter } from 'next/router' +import MixedItems from '../components/items-mixed' export const getServerSideProps = getGetServerSideProps(OUTLAWED_ITEMS) @@ -26,7 +19,6 @@ export default function Index ({ data: { outlawedItems: { items, cursor } } }) { function Items ({ rank, items, cursor }) { const { data, fetchMore } = useQuery(OUTLAWED_ITEMS) - const router = useRouter() if (!data && !items) { return @@ -36,38 +28,5 @@ function Items ({ rank, items, cursor }) { ({ outlawedItems: { items, cursor } } = data) } - return ( - <> -
- {items.map((item, i) => ( - - {item.parentId - ? ( - <>
-
{ - if (ignoreClick(e)) { - return - } - router.push({ - pathname: '/items/[id]', - query: { id: item.root.id, commentId: item.id } - }, `/items/${item.root.id}`) - }} - > - -
- ) - : (item.maxBid - ? - : )} - - ))} -
- } - /> - - ) + return } From ddb1b4a958d7d2d1fc1d8861f2e2ff20169ab451 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 22 Sep 2022 17:27:27 -0500 Subject: [PATCH 12/22] presume median downvote on new post --- .../20220922210703_outlaw/migration.sql | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 prisma/migrations/20220922210703_outlaw/migration.sql diff --git a/prisma/migrations/20220922210703_outlaw/migration.sql b/prisma/migrations/20220922210703_outlaw/migration.sql new file mode 100644 index 00000000..98903f5d --- /dev/null +++ b/prisma/migrations/20220922210703_outlaw/migration.sql @@ -0,0 +1,64 @@ +CREATE OR REPLACE FUNCTION create_item( + title TEXT, url TEXT, text TEXT, boost INTEGER, + parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER, + has_img_link BOOLEAN, spam_within INTERVAL) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + cost INTEGER; + free_posts INTEGER; + free_comments INTEGER; + freebie BOOLEAN; + item "Item"; + med_votes INTEGER; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT msats, "freePosts", "freeComments" + INTO user_msats, free_posts, free_comments + FROM users WHERE id = user_id; + + freebie := (parent_id IS NULL AND free_posts > 0) OR (parent_id IS NOT NULL AND free_comments > 0); + cost := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within)) * CASE WHEN has_img_link THEN 10 ELSE 1 END; + + IF NOT freebie AND cost > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + -- get this user's median item score + SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP(ORDER BY "weightedVotes" - "weightedDownVotes"), 0) INTO med_votes FROM "Item" WHERE "userId" = user_id; + + -- if their median votes are positive, start at 0 + -- if the median votes are negative, start their post with that many down votes + -- basically: if their median post is bad, presume this post is too + IF med_votes >= 0 THEN + med_votes := 0; + ELSE + med_votes := ABS(med_votes); + END IF; + + INSERT INTO "Item" (title, url, text, "userId", "parentId", "fwdUserId", "paidImgLink", "weightedDownVotes", created_at, updated_at) + VALUES (title, url, text, user_id, parent_id, fwd_user_id, has_img_link, med_votes, now_utc(), now_utc()) RETURNING * INTO item; + + IF freebie THEN + IF parent_id IS NULL THEN + UPDATE users SET "freePosts" = "freePosts" - 1 WHERE id = user_id; + ELSE + UPDATE users SET "freeComments" = "freeComments" - 1 WHERE id = user_id; + END IF; + ELSE + UPDATE users SET msats = msats - cost WHERE id = user_id; + + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (cost / 1000, item.id, user_id, 'VOTE', now_utc(), now_utc()); + END IF; + + IF boost > 0 THEN + PERFORM item_act(item.id, user_id, 'BOOST', boost); + END IF; + + RETURN item; +END; +$$; \ No newline at end of file From dd233346d95355a886d4eae8eeb71320a2409ced Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 23 Sep 2022 09:27:01 -0500 Subject: [PATCH 13/22] don't rank outlawed/borderland --- pages/borderland.js | 2 +- pages/outlawed.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pages/borderland.js b/pages/borderland.js index a3d6bd2b..7fa28763 100644 --- a/pages/borderland.js +++ b/pages/borderland.js @@ -28,5 +28,5 @@ function Items ({ rank, items, cursor }) { ({ borderlandItems: { items, cursor } } = data) } - return + return } diff --git a/pages/outlawed.js b/pages/outlawed.js index 48c62a92..505fed2c 100644 --- a/pages/outlawed.js +++ b/pages/outlawed.js @@ -28,5 +28,5 @@ function Items ({ rank, items, cursor }) { ({ outlawedItems: { items, cursor } } = data) } - return + return } From 1621eeac8088b39e4c4c2da383d44a570a507f67 Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 23 Sep 2022 10:43:57 -0500 Subject: [PATCH 14/22] median votes: int -> float --- .../20220923153826_outlaw_float/migration.sql | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 prisma/migrations/20220923153826_outlaw_float/migration.sql diff --git a/prisma/migrations/20220923153826_outlaw_float/migration.sql b/prisma/migrations/20220923153826_outlaw_float/migration.sql new file mode 100644 index 00000000..69777cdc --- /dev/null +++ b/prisma/migrations/20220923153826_outlaw_float/migration.sql @@ -0,0 +1,64 @@ +CREATE OR REPLACE FUNCTION create_item( + title TEXT, url TEXT, text TEXT, boost INTEGER, + parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER, + has_img_link BOOLEAN, spam_within INTERVAL) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + cost INTEGER; + free_posts INTEGER; + free_comments INTEGER; + freebie BOOLEAN; + item "Item"; + med_votes FLOAT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT msats, "freePosts", "freeComments" + INTO user_msats, free_posts, free_comments + FROM users WHERE id = user_id; + + freebie := (parent_id IS NULL AND free_posts > 0) OR (parent_id IS NOT NULL AND free_comments > 0); + cost := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within)) * CASE WHEN has_img_link THEN 10 ELSE 1 END; + + IF NOT freebie AND cost > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + -- get this user's median item score + SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP(ORDER BY "weightedVotes" - "weightedDownVotes"), 0) INTO med_votes FROM "Item" WHERE "userId" = user_id; + + -- if their median votes are positive, start at 0 + -- if the median votes are negative, start their post with that many down votes + -- basically: if their median post is bad, presume this post is too + IF med_votes >= 0 THEN + med_votes := 0; + ELSE + med_votes := ABS(med_votes); + END IF; + + INSERT INTO "Item" (title, url, text, "userId", "parentId", "fwdUserId", "paidImgLink", "weightedDownVotes", created_at, updated_at) + VALUES (title, url, text, user_id, parent_id, fwd_user_id, has_img_link, med_votes, now_utc(), now_utc()) RETURNING * INTO item; + + IF freebie THEN + IF parent_id IS NULL THEN + UPDATE users SET "freePosts" = "freePosts" - 1 WHERE id = user_id; + ELSE + UPDATE users SET "freeComments" = "freeComments" - 1 WHERE id = user_id; + END IF; + ELSE + UPDATE users SET msats = msats - cost WHERE id = user_id; + + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (cost / 1000, item.id, user_id, 'VOTE', now_utc(), now_utc()); + END IF; + + IF boost > 0 THEN + PERFORM item_act(item.id, user_id, 'BOOST', boost); + END IF; + + RETURN item; +END; +$$; \ No newline at end of file From d9d426e5c3851fcbe3a27e62c7953b26d156b830 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 27 Sep 2022 16:19:15 -0500 Subject: [PATCH 15/22] add freebies --- api/resolvers/item.js | 61 +++++-- api/resolvers/user.js | 7 +- api/typeDefs/item.js | 2 + api/typeDefs/user.js | 4 +- components/comment-edit.js | 10 +- components/comment.js | 5 +- components/discussion-form.js | 7 +- components/item.js | 5 +- components/item.module.css | 1 + components/poll-form.js | 7 +- components/reply.js | 5 +- fragments/comments.js | 2 +- fragments/items.js | 15 +- fragments/users.js | 9 +- pages/[name]/index.js | 7 +- pages/freebie.js | 32 ++++ pages/settings.js | 22 ++- .../migration.sql | 2 +- .../20220926201629_freebies/migration.sql | 9 + .../20220926204325_item_bio/migration.sql | 172 ++++++++++++++++++ prisma/schema.prisma | 9 +- 21 files changed, 325 insertions(+), 68 deletions(-) create mode 100644 pages/freebie.js create mode 100644 prisma/migrations/20220926201629_freebies/migration.sql create mode 100644 prisma/migrations/20220926204325_item_bio/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 78a09ade..5afa557b 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -8,7 +8,6 @@ import { BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST } from '../../lib/constants' -import { mdHas } from '../../lib/md' async function comments (me, models, id, sort) { let orderBy @@ -85,15 +84,28 @@ export async function orderByNumerator (me, models) { } export async function filterClause (me, models) { + // by default don't include freebies unless they have upvotes + let clause = ' AND (NOT "Item".freebie OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0' if (me) { const user = await models.user.findUnique({ where: { id: me.id } }) + // wild west mode has everything if (user.wildWestMode) { return '' } + // greeter mode includes freebies if feebies haven't been flagged + if (user.greeterMode) { + clause = 'AND (NOT "Item".freebie OR ("Item"."weightedVotes" - "Item"."weightedDownVotes" >= 0 AND "Item".freebie)' + } + + // always include if it's mine + clause += ` OR "Item"."userId" = ${me.id})` + } else { + // close default freebie clause + clause += ')' } // if the item is above the threshold or is mine - let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` + clause += ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` if (me) { clause += ` OR "Item"."userId" = ${me.id}` } @@ -215,7 +227,7 @@ export default { ${SELECT} FROM "Item" WHERE "parentId" IS NULL AND "Item".created_at <= $1 AND "Item".created_at > $3 - AND "pinId" IS NULL + AND "pinId" IS NULL AND NOT bio ${subClause(4)} ${await filterClause(me, models)} ${await newTimedOrderByWeightedSats(me, models, 1)} @@ -228,7 +240,7 @@ export default { ${SELECT} FROM "Item" WHERE "parentId" IS NULL AND "Item".created_at <= $1 - AND "pinId" IS NULL + AND "pinId" IS NULL AND NOT bio ${subClause(3)} ${await filterClause(me, models)} ${await newTimedOrderByWeightedSats(me, models, 1)} @@ -312,6 +324,21 @@ export default { items } }, + freebieItems: async (parent, { cursor }, { me, models }) => { + const decodedCursor = decodeCursor(cursor) + + const items = await models.$queryRaw(` + ${SELECT} + FROM "Item" + WHERE "Item".freebie + ORDER BY created_at DESC + OFFSET $1 + LIMIT ${LIMIT}`, decodedCursor.offset) + return { + cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + items + } + }, moreFlatComments: async (parent, { cursor, name, sort, within }, { me, models }) => { const decodedCursor = decodeCursor(cursor) @@ -574,8 +601,6 @@ export default { } } - const hasImgLink = !!(text && mdHas(text, ['link', 'image'])) - if (id) { const optionCount = await models.pollOption.count({ where: { @@ -588,8 +613,8 @@ export default { } const [item] = await serialize(models, - models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6, $7) AS "Item"`, - Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id), hasImgLink)) + models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6) AS "Item"`, + Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id))) return item } else { @@ -598,8 +623,8 @@ export default { } const [item] = await serialize(models, - models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`, - title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id), hasImgLink)) + models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6, $7, '${ITEM_SPAM_INTERVAL}') AS "Item"`, + title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id))) await createMentions(item, models) @@ -981,12 +1006,10 @@ export const updateItem = async (parent, { id, data: { title, url, text, boost, } } - const hasImgLink = !!(text && mdHas(text, ['link', 'image'])) - const [item] = await serialize(models, models.$queryRaw( - `${SELECT} FROM update_item($1, $2, $3, $4, $5, $6, $7) AS "Item"`, - Number(id), title, url, text, Number(boost || 0), Number(fwdUser?.id), hasImgLink)) + `${SELECT} FROM update_item($1, $2, $3, $4, $5, $6) AS "Item"`, + Number(id), title, url, text, Number(boost || 0), Number(fwdUser?.id))) await createMentions(item, models) @@ -1014,13 +1037,11 @@ const createItem = async (parent, { title, url, text, boost, forward, parentId } } } - const hasImgLink = !!(text && mdHas(text, ['link', 'image'])) - const [item] = await serialize(models, models.$queryRaw( - `${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`, + `${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, '${ITEM_SPAM_INTERVAL}') AS "Item"`, title, url, text, Number(boost || 0), Number(parentId), Number(me.id), - Number(fwdUser?.id), hasImgLink)) + Number(fwdUser?.id))) await createMentions(item, models) @@ -1058,9 +1079,9 @@ export const SELECT = `SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title, "Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid", "Item".company, "Item".location, "Item".remote, - "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item"."paidImgLink", + "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".sats, "Item".ncomments, "Item"."commentSats", "Item"."lastCommentAt", "Item"."weightedVotes", - "Item"."weightedDownVotes", ltree2text("Item"."path") AS "path"` + "Item"."weightedDownVotes", "Item".freebie, ltree2text("Item"."path") AS "path"` async function newTimedOrderByWeightedSats (me, models, num) { return ` diff --git a/api/resolvers/user.js b/api/resolvers/user.js index fb3277b5..d4d81ac8 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -1,6 +1,5 @@ import { AuthenticationError, UserInputError } from 'apollo-server-errors' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' -import { mdHas } from '../../lib/md' import { createMentions, getItem, SELECT, updateItem, filterClause } from './item' import serialize from './serial' @@ -202,11 +201,9 @@ export default { if (user.bioId) { await updateItem(parent, { id: user.bioId, data: { text: bio, title: `@${user.name}'s bio` } }, { me, models }) } else { - const hasImgLink = !!(bio && mdHas(bio, ['link', 'image'])) - const [item] = await serialize(models, - models.$queryRaw(`${SELECT} FROM create_bio($1, $2, $3, $4) AS "Item"`, - `@${user.name}'s bio`, bio, Number(me.id), hasImgLink)) + models.$queryRaw(`${SELECT} FROM create_bio($1, $2, $3) AS "Item"`, + `@${user.name}'s bio`, bio, Number(me.id))) await createMentions(item, models) } diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 6b3bf2bd..2d7e0217 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -14,6 +14,7 @@ export default gql` itemRepetition(parentId: ID): Int! outlawedItems(cursor: String): Items borderlandItems(cursor: String): Items + freebieItems(cursor: String): Items } type ItemActResult { @@ -83,6 +84,7 @@ export default gql` meSats: Int! meDontLike: Boolean! outlawed: Boolean! + freebie: Boolean! paidImgLink: Boolean ncomments: Int! comments: [Item!]! diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 411fb9b6..8e580219 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -31,7 +31,8 @@ export default gql` setName(name: String!): Boolean setSettings(tipDefault: Int!, noteItemSats: Boolean!, noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!, - noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!, wildWestMode: Boolean!): User + noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!, + wildWestMode: Boolean!, greeterMode: Boolean!): User setPhoto(photoId: ID!): Int! upsertBio(bio: String!): User! setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean @@ -73,6 +74,7 @@ export default gql` noteJobIndicator: Boolean! hideInvoiceDesc: Boolean! wildWestMode: Boolean! + greeterMode: Boolean! lastCheckedJobs: String authMethods: AuthMethods! } diff --git a/components/comment-edit.js b/components/comment-edit.js index 971fc609..3d78b375 100644 --- a/components/comment-edit.js +++ b/components/comment-edit.js @@ -3,7 +3,6 @@ import * as Yup from 'yup' import { gql, useMutation } from '@apollo/client' import styles from './reply.module.css' import TextareaAutosize from 'react-textarea-autosize' -import { useState } from 'react' import { EditFeeButton } from './fee-button' export const CommentSchema = Yup.object({ @@ -11,14 +10,11 @@ export const CommentSchema = Yup.object({ }) export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) { - const [hasImgLink, setHasImgLink] = useState() - const [updateComment] = useMutation( gql` mutation updateComment($id: ID! $text: String!) { updateComment(id: $id, text: $text) { text - paidImgLink } }`, { update (cache, { data: { updateComment } }) { @@ -27,9 +23,6 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc fields: { text () { return updateComment.text - }, - paidImgLink () { - return updateComment.paidImgLink } } }) @@ -59,11 +52,10 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc as={TextareaAutosize} minRows={6} autoFocus - setHasImgLink={setHasImgLink} required /> diff --git a/components/comment.js b/components/comment.js index 5768fc17..157ba5e8 100644 --- a/components/comment.js +++ b/components/comment.js @@ -133,8 +133,9 @@ export default function Comment ({ {timeSince(new Date(item.createdAt))} {includeParent && } - {me && !item.meSats && !item.meDontLike && } - {item.outlawed && {' '}OUTLAWED} + {me && !item.meSats && !item.meDontLike && !item.mine && } + {(item.outlawed && {' '}OUTLAWED) || + (item.freebie && !item.mine && (me?.greeterMode) && {' '}FREEBIE)} {canEdit && <> \ diff --git a/components/discussion-form.js b/components/discussion-form.js index be90ebcf..611d97d3 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -6,7 +6,6 @@ import TextareaAutosize from 'react-textarea-autosize' import Countdown from './countdown' import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' import { MAX_TITLE_LENGTH } from '../lib/constants' -import { useState } from 'react' import FeeButton, { EditFeeButton } from './fee-button' export function DiscussionForm ({ @@ -16,7 +15,6 @@ export function DiscussionForm ({ }) { const router = useRouter() const client = useApolloClient() - const [hasImgLink, setHasImgLink] = useState() // const me = useMe() const [upsertDiscussion] = useMutation( gql` @@ -77,17 +75,16 @@ export function DiscussionForm ({ hint={editThreshold ?
: null} - setHasImgLink={setHasImgLink} /> {adv && }
{item ? : }
diff --git a/components/item.js b/components/item.js index cda815bc..651f8c76 100644 --- a/components/item.js +++ b/components/item.js @@ -110,8 +110,9 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) { {timeSince(new Date(item.createdAt))} - {me && !item.meSats && !item.position && !item.meDontLike && } - {item.outlawed && {' '}OUTLAWED} + {me && !item.meSats && !item.position && !item.meDontLike && !item.mine && } + {(item.outlawed && {' '}OUTLAWED) || + (item.freebie && !item.mine && (me?.greeterMode) && {' '}FREEBIE)} {item.prior && <> \ diff --git a/components/item.module.css b/components/item.module.css index eb9bd351..19bc09c1 100644 --- a/components/item.module.css +++ b/components/item.module.css @@ -23,6 +23,7 @@ a.title:visited { .newComment { color: var(--theme-grey) !important; background: var(--theme-clickToContextColor) !important; + vertical-align: middle; } .pin { diff --git a/components/poll-form.js b/components/poll-form.js index 597f7bf5..664ea441 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -6,13 +6,11 @@ import Countdown from './countdown' import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' import { MAX_TITLE_LENGTH, MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES } from '../lib/constants' import TextareaAutosize from 'react-textarea-autosize' -import { useState } from 'react' import FeeButton, { EditFeeButton } from './fee-button' export function PollForm ({ item, editThreshold }) { const router = useRouter() const client = useApolloClient() - const [hasImgLink, setHasImgLink] = useState() const [upsertPoll] = useMutation( gql` @@ -82,7 +80,6 @@ export function PollForm ({ item, editThreshold }) { name='text' as={TextareaAutosize} minRows={2} - setHasImgLink={setHasImgLink} /> {item ? : }
diff --git a/components/reply.js b/components/reply.js index 44fade81..bf863d43 100644 --- a/components/reply.js +++ b/components/reply.js @@ -25,7 +25,6 @@ export function ReplyOnAnotherPage ({ parentId }) { export default function Reply ({ item, onSuccess, replyOpen }) { const [reply, setReply] = useState(replyOpen) const me = useMe() - const [hasImgLink, setHasImgLink] = useState() const parentId = item.id useEffect(() => { @@ -104,7 +103,6 @@ export default function Reply ({ item, onSuccess, replyOpen }) { } resetForm({ text: '' }) setReply(replyOpen || false) - setHasImgLink(false) }} storageKeyPrefix={'reply-' + parentId} > @@ -114,13 +112,12 @@ export default function Reply ({ item, onSuccess, replyOpen }) { minRows={6} autoFocus={!replyOpen} required - setHasImgLink={setHasImgLink} hint={me?.freeComments ? {me.freeComments} free comments left : null} /> {reply &&
} diff --git a/fragments/comments.js b/fragments/comments.js index 5e188576..f54705d2 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -16,10 +16,10 @@ export const COMMENT_FIELDS = gql` meSats meDontLike outlawed + freebie path commentSats mine - paidImgLink ncomments root { id diff --git a/fragments/items.js b/fragments/items.js index acdecb8d..763a355e 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -23,6 +23,7 @@ export const ITEM_FIELDS = gql` meSats meDontLike outlawed + freebie ncomments commentSats lastCommentAt @@ -38,7 +39,6 @@ export const ITEM_FIELDS = gql` status uploadId mine - paidImgLink root { id title @@ -95,6 +95,19 @@ export const BORDERLAND_ITEMS = gql` } }` +export const FREEBIE_ITEMS = gql` + ${ITEM_FIELDS} + + query freebieItems($cursor: String) { + freebieItems(cursor: $cursor) { + cursor + items { + ...ItemFields + text + } + } + }` + export const POLL_FIELDS = gql` fragment PollFields on Item { poll { diff --git a/fragments/users.js b/fragments/users.js index 8ccbe45c..7cd460ad 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -26,6 +26,7 @@ export const ME = gql` noteJobIndicator hideInvoiceDesc wildWestMode + greeterMode lastCheckedJobs } }` @@ -52,6 +53,7 @@ export const ME_SSR = gql` noteJobIndicator hideInvoiceDesc wildWestMode + greeterMode lastCheckedJobs } }` @@ -68,6 +70,7 @@ export const SETTINGS_FIELDS = gql` noteJobIndicator hideInvoiceDesc wildWestMode + greeterMode authMethods { lightning email @@ -89,11 +92,13 @@ gql` ${SETTINGS_FIELDS} mutation setSettings($tipDefault: Int!, $noteItemSats: Boolean!, $noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!, - $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!, $wildWestMode: Boolean!) { + $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!, + $wildWestMode: Boolean!, $greeterMode: Boolean!) { setSettings(tipDefault: $tipDefault, noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants, noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites, - noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc, wildWestMode: $wildWestMode) { + noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc, wildWestMode: $wildWestMode, + greeterMode: $greeterMode) { ...SettingsFields } } diff --git a/pages/[name]/index.js b/pages/[name]/index.js index 5788b0e5..6aff4a24 100644 --- a/pages/[name]/index.js +++ b/pages/[name]/index.js @@ -23,8 +23,6 @@ const BioSchema = Yup.object({ }) export function BioForm ({ handleSuccess, bio }) { - const [hasImgLink, setHasImgLink] = useState() - const [upsertBio] = useMutation( gql` ${ITEM_FIELDS} @@ -70,16 +68,15 @@ export function BioForm ({ handleSuccess, bio }) { name='bio' as={TextareaAutosize} minRows={6} - setHasImgLink={setHasImgLink} />
{bio?.text ? : }
diff --git a/pages/freebie.js b/pages/freebie.js new file mode 100644 index 00000000..ad5d49c8 --- /dev/null +++ b/pages/freebie.js @@ -0,0 +1,32 @@ +import Layout from '../components/layout' +import { ItemsSkeleton } from '../components/items' +import { getGetServerSideProps } from '../api/ssrApollo' +import { FREEBIE_ITEMS } from '../fragments/items' +import { useQuery } from '@apollo/client' +import MixedItems from '../components/items-mixed' + +export const getServerSideProps = getGetServerSideProps(FREEBIE_ITEMS) + +export default function Index ({ data: { freebieItems: { items, cursor } } }) { + return ( + + + + ) +} + +function Items ({ rank, items, cursor }) { + const { data, fetchMore } = useQuery(FREEBIE_ITEMS) + + if (!data && !items) { + return + } + + if (data) { + ({ freebieItems: { items, cursor } } = data) + } + + return +} diff --git a/pages/settings.js b/pages/settings.js index cc7a7789..51673427 100644 --- a/pages/settings.js +++ b/pages/settings.js @@ -62,7 +62,8 @@ export default function Settings ({ data: { settings } }) { noteInvites: settings?.noteInvites, noteJobIndicator: settings?.noteJobIndicator, hideInvoiceDesc: settings?.hideInvoiceDesc, - wildWestMode: settings?.wildWestMode + wildWestMode: settings?.wildWestMode, + greeterMode: settings?.greeterMode }} schema={SettingsSchema} onSubmit={async ({ tipDefault, ...values }) => { @@ -138,13 +139,28 @@ export default function Settings ({ data: { settings } }) {
wild west mode
    -
  • Don't hide flagged content
  • -
  • Don't down rank flagged content
  • +
  • don't hide flagged content
  • +
  • don't down rank flagged content
} name='wildWestMode' + groupClassName='mb-0' + /> + greeter mode + +
    +
  • see and screen free posts and comments
  • +
  • help onboard users to SN and Lightning
  • +
  • you might be subject to more spam
  • +
+
+
+ } + name='greeterMode' />
save diff --git a/prisma/migrations/20220412190704_item_path_index/migration.sql b/prisma/migrations/20220412190704_item_path_index/migration.sql index f701cd03..2ec14089 100644 --- a/prisma/migrations/20220412190704_item_path_index/migration.sql +++ b/prisma/migrations/20220412190704_item_path_index/migration.sql @@ -1 +1 @@ -CREATE INDEX IF NOT EXISTS "item_gist_path_index" ON "Item" USING GIST ("path"); \ No newline at end of file +CREATE INDEX "item_gist_path_index" ON "Item" USING GIST ("path" gist_ltree_ops(siglen=2024)); \ No newline at end of file diff --git a/prisma/migrations/20220926201629_freebies/migration.sql b/prisma/migrations/20220926201629_freebies/migration.sql new file mode 100644 index 00000000..7e8139aa --- /dev/null +++ b/prisma/migrations/20220926201629_freebies/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Item" +ADD COLUMN "bio" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "freebie" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "greeterMode" BOOLEAN NOT NULL DEFAULT false, +ALTER COLUMN "freeComments" SET DEFAULT 5, +ALTER COLUMN "freePosts" SET DEFAULT 2; \ No newline at end of file diff --git a/prisma/migrations/20220926204325_item_bio/migration.sql b/prisma/migrations/20220926204325_item_bio/migration.sql new file mode 100644 index 00000000..0dac0f3d --- /dev/null +++ b/prisma/migrations/20220926204325_item_bio/migration.sql @@ -0,0 +1,172 @@ +DROP FUNCTION IF EXISTS create_bio(title TEXT, text TEXT, user_id INTEGER, has_img_link BOOLEAN); + +-- when creating bio, set bio flag so they won't appear on first page +CREATE OR REPLACE FUNCTION create_bio(title TEXT, text TEXT, user_id INTEGER) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + item "Item"; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT * INTO item FROM create_item(title, NULL, text, 0, NULL, user_id, NULL, '0'); + + UPDATE "Item" SET bio = true WHERE id = item.id; + UPDATE users SET "bioId" = item.id WHERE id = user_id; + + RETURN item; +END; +$$; + +DROP FUNCTION IF EXISTS create_item( + title TEXT, url TEXT, text TEXT, boost INTEGER, + parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER, + has_img_link BOOLEAN, spam_within INTERVAL); + +-- when creating free item, set freebie flag so can be optionally viewed +CREATE OR REPLACE FUNCTION create_item( + title TEXT, url TEXT, text TEXT, boost INTEGER, + parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER, + spam_within INTERVAL) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + cost INTEGER; + free_posts INTEGER; + free_comments INTEGER; + freebie BOOLEAN; + item "Item"; + med_votes FLOAT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT msats, "freePosts", "freeComments" + INTO user_msats, free_posts, free_comments + FROM users WHERE id = user_id; + + cost := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within)); + freebie := (cost <= 1000) AND ((parent_id IS NULL AND free_posts > 0) OR (parent_id IS NOT NULL AND free_comments > 0)); + + IF NOT freebie AND cost > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + -- get this user's median item score + SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP(ORDER BY "weightedVotes" - "weightedDownVotes"), 0) INTO med_votes FROM "Item" WHERE "userId" = user_id; + + -- if their median votes are positive, start at 0 + -- if the median votes are negative, start their post with that many down votes + -- basically: if their median post is bad, presume this post is too + IF med_votes >= 0 THEN + med_votes := 0; + ELSE + med_votes := ABS(med_votes); + END IF; + + INSERT INTO "Item" (title, url, text, "userId", "parentId", "fwdUserId", freebie, "weightedDownVotes", created_at, updated_at) + VALUES (title, url, text, user_id, parent_id, fwd_user_id, freebie, med_votes, now_utc(), now_utc()) RETURNING * INTO item; + + IF freebie THEN + IF parent_id IS NULL THEN + UPDATE users SET "freePosts" = "freePosts" - 1 WHERE id = user_id; + ELSE + UPDATE users SET "freeComments" = "freeComments" - 1 WHERE id = user_id; + END IF; + ELSE + UPDATE users SET msats = msats - cost WHERE id = user_id; + + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (cost / 1000, item.id, user_id, 'VOTE', now_utc(), now_utc()); + END IF; + + IF boost > 0 THEN + PERFORM item_act(item.id, user_id, 'BOOST', boost); + END IF; + + RETURN item; +END; +$$; + +DROP FUNCTION IF EXISTS update_item(item_id INTEGER, + item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER, + fwd_user_id INTEGER, has_img_link BOOLEAN); + +CREATE OR REPLACE FUNCTION update_item(item_id INTEGER, + item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER, + fwd_user_id INTEGER) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + item "Item"; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + UPDATE "Item" set title = item_title, url = item_url, text = item_text, "fwdUserId" = fwd_user_id + WHERE id = item_id + RETURNING * INTO item; + + IF boost > 0 THEN + PERFORM item_act(item.id, item."userId", 'BOOST', boost); + END IF; + + RETURN item; +END; +$$; + +DROP FUNCTION IF EXISTS create_poll( + title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER, + options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN, spam_within INTERVAL); + +CREATE OR REPLACE FUNCTION create_poll( + title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER, + options TEXT[], fwd_user_id INTEGER, spam_within INTERVAL) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + item "Item"; + option TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + item := create_item(title, null, text, boost, null, user_id, fwd_user_id, spam_within); + + UPDATE "Item" set "pollCost" = poll_cost where id = item.id; + FOREACH option IN ARRAY options LOOP + INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option); + END LOOP; + + RETURN item; +END; +$$; + +DROP FUNCTION IF EXISTS update_poll( + id INTEGER, title TEXT, text TEXT, boost INTEGER, + options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN); + +CREATE OR REPLACE FUNCTION update_poll( + id INTEGER, title TEXT, text TEXT, boost INTEGER, + options TEXT[], fwd_user_id INTEGER) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + item "Item"; + option TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + item := update_item(id, title, null, text, boost, fwd_user_id); + + FOREACH option IN ARRAY options LOOP + INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option); + END LOOP; + + RETURN item; +END; +$$; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 64e4ed54..7e9df16d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,8 +32,8 @@ model User { bioId Int? msats Int @default(0) stackedMsats Int @default(0) - freeComments Int @default(0) - freePosts Int @default(0) + freeComments Int @default(5) + freePosts Int @default(2) checkedNotesAt DateTime? tipDefault Int @default(10) pubkey String? @unique @@ -61,6 +61,7 @@ model User { // content settings wildWestMode Boolean @default(false) + greeterMode Boolean @default(false) Earn Earn[] Upload Upload[] @relation(name: "Uploads") @@ -185,6 +186,10 @@ model Item { upload Upload? paidImgLink Boolean @default(false) + // is free post or bio + freebie Boolean @default(false) + bio Boolean @default(false) + // denormalized self stats weightedVotes Float @default(0) weightedDownVotes Float @default(0) From 401efbd55066dabcc9b5c60848ec7fb58fc5b8f4 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 27 Sep 2022 16:27:29 -0500 Subject: [PATCH 16/22] add ln icon to login --- components/header.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/components/header.js b/components/header.js index aa7c353f..d42f1b68 100644 --- a/components/header.js +++ b/components/header.js @@ -14,6 +14,7 @@ import { randInRange } from '../lib/rand' import { formatSats } from '../lib/format' import NoteIcon from '../svgs/notification-4-fill.svg' import { useQuery, gql } from '@apollo/client' +import LightningIcon from '../svgs/bolt.svg' function WalletSummary ({ me }) { if (!me) return null @@ -127,9 +128,15 @@ export default function Header ({ sub }) { } return path !== '/login' && !path.startsWith('/invites') && } } From 357b19bebd6ea00a3992525d43a7fbfb79f4011e Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 27 Sep 2022 16:43:29 -0500 Subject: [PATCH 17/22] reserve top level names --- prisma/migrations/20220927214007_reserve_names/migration.sql | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 prisma/migrations/20220927214007_reserve_names/migration.sql diff --git a/prisma/migrations/20220927214007_reserve_names/migration.sql b/prisma/migrations/20220927214007_reserve_names/migration.sql new file mode 100644 index 00000000..e28da3b0 --- /dev/null +++ b/prisma/migrations/20220927214007_reserve_names/migration.sql @@ -0,0 +1,4 @@ +INSERT INTO "users" ("name") VALUES +('freebie'), +('borderland'), +('outlawed'); \ No newline at end of file From 257303903f30f36230a48f96c6358747ee158b86 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 27 Sep 2022 16:44:36 -0500 Subject: [PATCH 18/22] revert path index change --- prisma/migrations/20220412190704_item_path_index/migration.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prisma/migrations/20220412190704_item_path_index/migration.sql b/prisma/migrations/20220412190704_item_path_index/migration.sql index 2ec14089..f701cd03 100644 --- a/prisma/migrations/20220412190704_item_path_index/migration.sql +++ b/prisma/migrations/20220412190704_item_path_index/migration.sql @@ -1 +1 @@ -CREATE INDEX "item_gist_path_index" ON "Item" USING GIST ("path" gist_ltree_ops(siglen=2024)); \ No newline at end of file +CREATE INDEX IF NOT EXISTS "item_gist_path_index" ON "Item" USING GIST ("path"); \ No newline at end of file From 52fab60cda6de0aa6efae5c5d1fb013554bc1fc0 Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 28 Sep 2022 11:28:53 -0500 Subject: [PATCH 19/22] fix missing search fields --- api/resolvers/item.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 5afa557b..e329d687 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -529,8 +529,9 @@ export default { } // return highlights - const items = sitems.body.hits.hits.map(e => { - const item = e._source + const items = sitems.body.hits.hits.map(async e => { + // this is super inefficient but will suffice until we do something more generic + const item = await getItem(parent, { id: e._source.id }, { me, models }) item.searchTitle = (e.highlight.title && e.highlight.title[0]) || item.title item.searchText = (e.highlight.text && e.highlight.text[0]) || item.text From 6c853c642e5d4cec7e359f31cb24f9d1358251b9 Mon Sep 17 00:00:00 2001 From: Suhail Saqan Date: Thu, 29 Sep 2022 13:20:38 -0700 Subject: [PATCH 20/22] change drop down scroll to auto --- styles/globals.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/styles/globals.scss b/styles/globals.scss index 4fcff96a..3d65842f 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -217,7 +217,7 @@ a:hover { background-color: var(--theme-inputBg); border: 1px solid var(--theme-borderColor); max-width: 90vw; - overflow: scroll; + overflow: auto; } .dropdown-item { From 46ea2f661c545daef71bab887268d752cfb34b6a Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 29 Sep 2022 15:42:33 -0500 Subject: [PATCH 21/22] make jobs great again --- api/resolvers/item.js | 92 ++++++------- api/resolvers/notifications.js | 1 - api/resolvers/user.js | 3 - api/typeDefs/item.js | 1 + components/item-full.js | 2 +- components/item-job.js | 9 +- components/items-mixed.js | 2 +- components/items.js | 2 +- components/job-form.js | 121 ++++++++++-------- components/notifications.js | 12 +- fragments/items.js | 1 + pages/items/[id]/edit.js | 2 +- pages/items/[id]/index.js | 2 +- .../20220929183848_job_funcs/migration.sql | 101 +++++++++++++++ 14 files changed, 229 insertions(+), 122 deletions(-) create mode 100644 prisma/migrations/20220929183848_job_funcs/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index e329d687..b3de372d 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -201,7 +201,7 @@ export default { WHERE "parentId" IS NULL AND created_at <= $1 AND "pinId" IS NULL ${subClause(3)} - AND status = 'ACTIVE' + AND status = 'ACTIVE' AND "maxBid" > 0 ORDER BY "maxBid" DESC, created_at ASC) UNION ALL (${SELECT} @@ -209,7 +209,7 @@ export default { WHERE "parentId" IS NULL AND created_at <= $1 AND "pinId" IS NULL ${subClause(3)} - AND status = 'NOSATS' + AND ((status = 'ACTIVE' AND "maxBid" = 0) OR status = 'NOSATS') ORDER BY created_at DESC) ) a OFFSET $2 @@ -456,11 +456,19 @@ export default { bool: { should: [ { match: { status: 'ACTIVE' } }, + { match: { status: 'NOSATS' } }, { match: { userId: me.id } } ] } } - : { match: { status: 'ACTIVE' } }, + : { + bool: { + should: [ + { match: { status: 'ACTIVE' } }, + { match: { status: 'NOSATS' } } + ] + } + }, { bool: { should: [ @@ -544,19 +552,26 @@ export default { items } }, - auctionPosition: async (parent, { id, sub, bid }, { models }) => { + auctionPosition: async (parent, { id, sub, bid }, { models, me }) => { // count items that have a bid gte to the current bid or // gte current bid and older const where = { where: { subName: sub, - status: 'ACTIVE', - maxBid: { - gte: bid - } + status: { not: 'STOPPED' } } } + if (bid > 0) { + where.where.maxBid = { gte: bid } + } else { + const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date() + where.where.OR = [ + { maxBid: { gt: 0 } }, + { createdAt: { gt: createdAt } } + ] + } + if (id) { where.where.id = { not: Number(id) } } @@ -646,62 +661,36 @@ export default { throw new UserInputError('not a valid sub', { argumentName: 'sub' }) } - if (fullSub.baseCost > maxBid) { - throw new UserInputError(`bid must be at least ${fullSub.baseCost}`, { argumentName: 'maxBid' }) + if (maxBid < 0) { + throw new UserInputError('bid must be at least 0', { argumentName: 'maxBid' }) } if (!location && !remote) { throw new UserInputError('must specify location or remote', { argumentName: 'location' }) } - const checkSats = async () => { - // check if the user has the funds to run for the first minute - const minuteMsats = maxBid * 1000 - const user = await models.user.findUnique({ where: { id: me.id } }) - if (user.msats < minuteMsats) { - throw new UserInputError('insufficient funds') - } - } - - const data = { - title, - company, - location: location.toLowerCase() === 'remote' ? undefined : location, - remote, - text, - url, - maxBid, - subName: sub, - userId: me.id, - uploadId: logo - } + location = location.toLowerCase() === 'remote' ? undefined : location + let item if (id) { - if (status) { - data.status = status - - // if the job is changing to active, we need to check they have funds - if (status === 'ACTIVE') { - await checkSats() - } - } - const old = await models.item.findUnique({ where: { id: Number(id) } }) if (Number(old.userId) !== Number(me?.id)) { throw new AuthenticationError('item does not belong to you') } - - return await models.item.update({ - where: { id: Number(id) }, - data - }) + ([item] = await serialize(models, + models.$queryRaw( + `${SELECT} FROM update_job($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) AS "Item"`, + Number(id), title, url, text, Number(maxBid), company, location, remote, Number(logo), status))) + } else { + ([item] = await serialize(models, + models.$queryRaw( + `${SELECT} FROM create_job($1, $2, $3, $4, $5, $6, $7, $8, $9) AS "Item"`, + title, url, text, Number(me.id), Number(maxBid), company, location, remote, Number(logo)))) } - // before creating job, check the sats - await checkSats() - return await models.item.create({ - data - }) + await createMentions(item, models) + + return item }, createComment: async (parent, { text, parentId }, { me, models }) => { return await createItem(parent, { text, parentId }, { me, models }) @@ -767,6 +756,9 @@ export default { } }, Item: { + isJob: async (item, args, { models }) => { + return item.subName === 'jobs' + }, sub: async (item, args, { models }) => { if (!item.subName) { return null diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index aa08603a..7e630494 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -98,7 +98,6 @@ export default { FROM "Item" WHERE "Item"."userId" = $1 AND "maxBid" IS NOT NULL - AND status <> 'STOPPED' AND "statusUpdatedAt" <= $2 ORDER BY "sortTime" DESC LIMIT ${LIMIT}+$3)` diff --git a/api/resolvers/user.js b/api/resolvers/user.js index d4d81ac8..41c3ebea 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -337,9 +337,6 @@ export default { const job = await models.item.findFirst({ where: { - status: { - not: 'STOPPED' - }, maxBid: { not: null }, diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 2d7e0217..2ee07a68 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -92,6 +92,7 @@ export default gql` position: Int prior: Int maxBid: Int + isJob: Boolean! pollCost: Int poll: Poll company: String diff --git a/components/item-full.js b/components/item-full.js index 60f6c1f2..14900764 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -83,7 +83,7 @@ function ItemEmbed ({ item }) { } function TopLevelItem ({ item, noReply, ...props }) { - const ItemComponent = item.maxBid ? ItemJob : Item + const ItemComponent = item.isJob ? ItemJob : Item return ( diff --git a/components/item-job.js b/components/item-job.js index 5c55fc36..7061f5a1 100644 --- a/components/item-job.js +++ b/components/item-job.js @@ -18,7 +18,7 @@ export default function ItemJob ({ item, toc, rank, children }) { {rank}
) :
} - diff --git a/components/items-mixed.js b/components/items-mixed.js index efa3f79f..678ec080 100644 --- a/components/items-mixed.js +++ b/components/items-mixed.js @@ -32,7 +32,7 @@ export default function MixedItems ({ rank, items, cursor, fetchMore }) {
) - : (item.maxBid + : (item.isJob ? : )}
diff --git a/components/items.js b/components/items.js index b7fa711b..beb7f719 100644 --- a/components/items.js +++ b/components/items.js @@ -28,7 +28,7 @@ export default function Items ({ variables = {}, rank, items, pins, cursor }) { {pinMap && pinMap[i + 1] && } {item.parentId ? <>
- : (item.maxBid + : (item.isJob ? : (item.title ? diff --git a/components/job-form.js b/components/job-form.js index 12c2d99a..46ae30be 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -11,6 +11,8 @@ import { useRouter } from 'next/router' import Link from 'next/link' import { usePrice } from './price' import Avatar from './avatar' +import BootstrapForm from 'react-bootstrap/Form' +import Alert from 'react-bootstrap/Alert' Yup.addMethod(Yup.string, 'or', function (schemas, msg) { return this.test({ @@ -34,7 +36,7 @@ function satsMin2Mo (minute) { function PriceHint ({ monthly }) { const price = usePrice() - if (!price) { + if (!price || !monthly) { return null } const fixed = (n, f) => Number.parseFloat(n).toFixed(f) @@ -47,13 +49,7 @@ function PriceHint ({ monthly }) { export default function JobForm ({ item, sub }) { const storageKeyPrefix = item ? undefined : `${sub.name}-job` const router = useRouter() - const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || sub.baseCost)) const [logoId, setLogoId] = useState(item?.uploadId) - const [getAuctionPosition, { data }] = useLazyQuery(gql` - query AuctionPosition($id: ID, $bid: Int!) { - auctionPosition(sub: "${sub.name}", id: $id, bid: $bid) - }`, - { fetchPolicy: 'network-only' }) const [upsertJob] = useMutation(gql` mutation upsertJob($id: ID, $title: String!, $company: String!, $location: String, $remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int) { @@ -72,8 +68,8 @@ export default function JobForm ({ item, sub }) { url: Yup.string() .or([Yup.string().email(), Yup.string().url()], 'invalid url or email') .required('required'), - maxBid: Yup.number('must be number') - .integer('must be whole').min(sub.baseCost, `must be at least ${sub.baseCost}`) + maxBid: Yup.number().typeError('must be a number') + .integer('must be whole').min(0, 'must be positive') .required('required'), location: Yup.string().test( 'no-remote', @@ -85,14 +81,6 @@ export default function JobForm ({ item, sub }) { }) }) - const position = data?.auctionPosition - - useEffect(() => { - const initialMaxBid = Number(item?.maxBid || localStorage.getItem(storageKeyPrefix + '-maxBid')) || sub.baseCost - getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } }) - setMonthly(satsMin2Mo(initialMaxBid)) - }, []) - return ( <>
- bid - -
    -
  1. The higher your bid the higher your job will rank
  2. -
  3. The minimum bid is {sub.baseCost} sats/min
  4. -
  5. You can increase or decrease your bid, and edit or stop your job at anytime
  6. -
  7. Your job will be hidden if your wallet runs out of sats and can be unhidden by filling your wallet again
  8. -
-
-
- } - name='maxBid' - onChange={async (formik, e) => { - if (e.target.value >= sub.baseCost && e.target.value <= 100000000) { - setMonthly(satsMin2Mo(e.target.value)) - getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } }) - } else { - setMonthly(satsMin2Mo(sub.baseCost)) - } - }} - append={sats/min} - hint={} - /> - <>
This bid puts your job in position: {position}
+ {item && } {item ? 'save' : 'post'} @@ -221,6 +184,61 @@ export default function JobForm ({ item, sub }) { ) } +function PromoteJob ({ item, sub, storageKeyPrefix }) { + const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0)) + const [getAuctionPosition, { data }] = useLazyQuery(gql` + query AuctionPosition($id: ID, $bid: Int!) { + auctionPosition(sub: "${sub.name}", id: $id, bid: $bid) + }`, + { fetchPolicy: 'network-only' }) + const position = data?.auctionPosition + + useEffect(() => { + const initialMaxBid = Number(item?.maxBid || localStorage.getItem(storageKeyPrefix + '-maxBid')) || 0 + getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } }) + setMonthly(satsMin2Mo(initialMaxBid)) + }, []) + + return ( + 0} + header={
promote
} + body={ + <> + bid + +
    +
  1. The higher your bid the higher your job will rank
  2. +
  3. You can increase, decrease, or remove your bid at anytime
  4. +
  5. You can edit or stop your job at anytime
  6. +
  7. If you run out of sats, your job will stop being promoted until you fill your wallet again
  8. +
+
+ optional +
+ } + name='maxBid' + onChange={async (formik, e) => { + if (e.target.value >= 0 && e.target.value <= 100000000) { + setMonthly(satsMin2Mo(e.target.value)) + getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } }) + } else { + setMonthly(satsMin2Mo(0)) + } + }} + append={sats/min} + hint={} + storageKeyPrefix={storageKeyPrefix} + /> + <>
This bid puts your job in position: {position}
+ + } + /> + ) +} + function StatusControl ({ item }) { let StatusComp @@ -241,7 +259,7 @@ function StatusControl ({ item }) { ) } - } else { + } else if (item.status === 'STOPPED') { StatusComp = () => { return ( - {item.status === 'NOSATS' && -
- you have no sats! fund your wallet to resume your job -
} - +
+
+ job control + {item.status === 'NOSATS' && + your promotion ran out of sats. fund your wallet or reduce bid to continue promoting your job} + +
) } diff --git a/components/notifications.js b/components/notifications.js index 79d853f2..4b4fd57c 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -105,13 +105,15 @@ function Notification ({ n }) { you were mentioned in } {n.__typename === 'JobChanged' && - - {n.item.status === 'NOSATS' - ? 'your job ran out of sats' - : 'your job is active again'} + + {n.item.status === 'ACTIVE' + ? 'your job is active again' + : (n.item.status === 'NOSATS' + ? 'your job promotion ran out of sats' + : 'your job has been stopped')} }
- {n.item.maxBid + {n.item.isJob ? : n.item.title ? diff --git a/fragments/items.js b/fragments/items.js index 763a355e..2737f9e0 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -28,6 +28,7 @@ export const ITEM_FIELDS = gql` commentSats lastCommentAt maxBid + isJob company location remote diff --git a/pages/items/[id]/edit.js b/pages/items/[id]/edit.js index 4e8328e7..40028457 100644 --- a/pages/items/[id]/edit.js +++ b/pages/items/[id]/edit.js @@ -14,7 +14,7 @@ export default function PostEdit ({ data: { item } }) { return ( - {item.maxBid + {item.isJob ? : (item.url ? diff --git a/pages/items/[id]/index.js b/pages/items/[id]/index.js index e06e8a92..7b758f58 100644 --- a/pages/items/[id]/index.js +++ b/pages/items/[id]/index.js @@ -6,7 +6,7 @@ import { getGetServerSideProps } from '../../../api/ssrApollo' import { useQuery } from '@apollo/client' export const getServerSideProps = getGetServerSideProps(ITEM_FULL, null, - data => !data.item || (data.item.status !== 'ACTIVE' && !data.item.mine)) + data => !data.item || (data.item.status === 'STOPPED' && !data.item.mine)) export default function AnItem ({ data: { item } }) { const { data } = useQuery(ITEM_FULL, { diff --git a/prisma/migrations/20220929183848_job_funcs/migration.sql b/prisma/migrations/20220929183848_job_funcs/migration.sql new file mode 100644 index 00000000..040cbedf --- /dev/null +++ b/prisma/migrations/20220929183848_job_funcs/migration.sql @@ -0,0 +1,101 @@ +-- charge the user for the auction item +CREATE OR REPLACE FUNCTION run_auction(item_id INTEGER) RETURNS void AS $$ + DECLARE + bid INTEGER; + user_id INTEGER; + user_msats INTEGER; + item_status "Status"; + status_updated_at timestamp(3); + BEGIN + PERFORM ASSERT_SERIALIZED(); + + -- extract data we need + SELECT "maxBid" * 1000, "userId", status, "statusUpdatedAt" INTO bid, user_id, item_status, status_updated_at FROM "Item" WHERE id = item_id; + SELECT msats INTO user_msats FROM users WHERE id = user_id; + + -- 0 bid items expire after 30 days unless updated + IF bid = 0 THEN + IF item_status <> 'STOPPED' AND status_updated_at < now_utc() - INTERVAL '30 days' THEN + UPDATE "Item" SET status = 'STOPPED', "statusUpdatedAt" = now_utc() WHERE id = item_id; + END IF; + RETURN; + END IF; + + -- check if user wallet has enough sats + IF bid > user_msats THEN + -- if not, set status = NOSATS and statusUpdatedAt to now_utc if not already set + IF item_status <> 'NOSATS' THEN + UPDATE "Item" SET status = 'NOSATS', "statusUpdatedAt" = now_utc() WHERE id = item_id; + END IF; + ELSE + -- if so, deduct from user + UPDATE users SET msats = msats - bid WHERE id = user_id; + + -- create an item act + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (bid / 1000, item_id, user_id, 'STREAM', now_utc(), now_utc()); + + -- update item status = ACTIVE and statusUpdatedAt = now_utc if NOSATS + IF item_status = 'NOSATS' THEN + UPDATE "Item" SET status = 'ACTIVE', "statusUpdatedAt" = now_utc() WHERE id = item_id; + END IF; + END IF; + END; +$$ LANGUAGE plpgsql; + +-- when creating free item, set freebie flag so can be optionally viewed +CREATE OR REPLACE FUNCTION create_job( + title TEXT, url TEXT, text TEXT, user_id INTEGER, job_bid INTEGER, job_company TEXT, + job_location TEXT, job_remote BOOLEAN, job_upload_id INTEGER) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + item "Item"; +BEGIN + PERFORM ASSERT_SERIALIZED(); + -- create item + SELECT * INTO item FROM create_item(title, url, text, 0, NULL, user_id, NULL, '0'); + + -- update by adding additional fields + UPDATE "Item" + SET "maxBid" = job_bid, company = job_company, location = job_location, remote = job_remote, "uploadId" = job_upload_id, "subName" = 'jobs' + WHERE id = item.id RETURNING * INTO item; + + -- run_auction + EXECUTE run_auction(item.id); + + RETURN item; +END; +$$; + +CREATE OR REPLACE FUNCTION update_job(item_id INTEGER, + item_title TEXT, item_url TEXT, item_text TEXT, job_bid INTEGER, job_company TEXT, + job_location TEXT, job_remote BOOLEAN, job_upload_id INTEGER, job_status "Status") +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + item "Item"; +BEGIN + PERFORM ASSERT_SERIALIZED(); + -- update item + SELECT * INTO item FROM update_item(item_id, item_title, item_url, item_text, 0, NULL); + + IF item.status <> job_status THEN + UPDATE "Item" + SET "maxBid" = job_bid, company = job_company, location = job_location, remote = job_remote, "uploadId" = job_upload_id, status = job_status, "statusUpdatedAt" = now_utc() + WHERE id = item.id RETURNING * INTO item; + ELSE + UPDATE "Item" + SET "maxBid" = job_bid, company = job_company, location = job_location, remote = job_remote, "uploadId" = job_upload_id + WHERE id = item.id RETURNING * INTO item; + END IF; + + -- run_auction + EXECUTE run_auction(item.id); + + RETURN item; +END; +$$; \ No newline at end of file From 5ca67c3411b18405a316e675ddae7fe7b10484fe Mon Sep 17 00:00:00 2001 From: keyan Date: Mon, 3 Oct 2022 16:05:06 -0500 Subject: [PATCH 22/22] make getting more freebies work --- lib/apollo.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/apollo.js b/lib/apollo.js index 8a50c84d..465d0a71 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -78,6 +78,19 @@ export default function getApolloClient () { } } }, + freebieItems: { + keyArgs: [], + merge (existing, incoming) { + if (isFirstPage(incoming.cursor, existing?.items)) { + return incoming + } + + return { + cursor: incoming.cursor, + items: [...(existing?.items || []), ...incoming.items] + } + } + }, search: { keyArgs: ['q'], merge (existing, incoming) {