From 70cbdd057a63841afd83fc7eb51284c3b2488087 Mon Sep 17 00:00:00 2001 From: keyan Date: Mon, 18 Jul 2022 16:24:28 -0500 Subject: [PATCH 01/64] add table of contents --- components/comment.module.css | 1 - components/comments.js | 4 +- components/item-full.js | 2 +- components/item.js | 19 +++--- components/table-of-contents.js | 91 +++++++++++++++++++++++++ components/text.js | 13 ++-- package.json | 2 + styles/globals.scss | 114 ++++++++++++++++++++++---------- svgs/list-unordered.svg | 1 + 9 files changed, 192 insertions(+), 55 deletions(-) create mode 100644 components/table-of-contents.js create mode 100644 svgs/list-unordered.svg diff --git a/components/comment.module.css b/components/comment.module.css index cd205557..6e80226f 100644 --- a/components/comment.module.css +++ b/components/comment.module.css @@ -77,7 +77,6 @@ } .hunk { - overflow: visible; margin-bottom: 0; margin-top: 0.15rem; } diff --git a/components/comments.js b/components/comments.js index 283d3414..ed99b8a2 100644 --- a/components/comments.js +++ b/components/comments.js @@ -59,7 +59,9 @@ export default function Comments ({ parentId, comments, ...props }) { useEffect(() => { const hash = window.location.hash if (hash) { - document.querySelector(hash).scrollIntoView({ behavior: 'smooth' }) + try { + document.querySelector(hash).scrollIntoView({ behavior: 'smooth' }) + } catch {} } }, []) const [getComments, { loading }] = useLazyQuery(COMMENTS_QUERY, { diff --git a/components/item-full.js b/components/item-full.js index 60c5576a..cd402bff 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -83,7 +83,7 @@ function TopLevelItem ({ item, noReply, ...props }) { const ItemComponent = item.maxBid ? ItemJob : Item return ( - + {item.text && } {item.url && } {!noReply && } diff --git a/components/item.js b/components/item.js index ae734dbe..a77383a2 100644 --- a/components/item.js +++ b/components/item.js @@ -10,6 +10,7 @@ import reactStringReplace from 'react-string-replace' import { formatSats } from '../lib/format' import * as Yup from 'yup' import Briefcase from '../svgs/briefcase-4-fill.svg' +import Toc from './table-of-contents' function SearchTitle ({ title }) { return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { @@ -17,7 +18,7 @@ function SearchTitle ({ title }) { }) } -export function ItemJob ({ item, rank, children }) { +export function ItemJob ({ item, toc, rank, children }) { const isEmail = Yup.string().email().isValidSync(item.url) return ( @@ -52,12 +53,12 @@ export function ItemJob ({ item, rank, children }) { {/* eslint-disable-next-line */} - - apply - + + apply +
{item.status !== 'NOSATS' @@ -89,6 +90,7 @@ export function ItemJob ({ item, rank, children }) { }
+ {toc && } {children && (
@@ -110,7 +112,7 @@ function FwdUser ({ user }) { ) } -export default function Item ({ item, rank, showFwdUser, children }) { +export default function Item ({ item, rank, showFwdUser, toc, children }) { const mine = item.mine const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 const [canEdit, setCanEdit] = @@ -202,6 +204,7 @@ export default function Item ({ item, rank, showFwdUser, children }) {
{showFwdUser && item.fwdUser && } + {toc && } {children && (
diff --git a/components/table-of-contents.js b/components/table-of-contents.js new file mode 100644 index 00000000..eb6f3f12 --- /dev/null +++ b/components/table-of-contents.js @@ -0,0 +1,91 @@ +import React, { useState } from 'react' +import { Dropdown, FormControl } from 'react-bootstrap' +import TocIcon from '../svgs/list-unordered.svg' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { visit } from 'unist-util-visit' +import { toString } from 'mdast-util-to-string' +import GithubSlugger from 'github-slugger' + +export default function Toc ({ text }) { + if (!text || text.length === 0) { + return null + } + + const tree = fromMarkdown(text) + const toc = [] + const slugger = new GithubSlugger() + visit(tree, 'heading', (node, position, parent) => { + const str = toString(node) + toc.push({ heading: str, slug: slugger.slug(str.replace(/[^\w\-\s]+/gi, '')), depth: node.depth }) + }) + + if (toc.length === 0) { + return null + } + + return ( + + + + + + + {toc.map(v => { + return ( + {v.heading} + + ) + })} + + + ) +} + +const CustomToggle = React.forwardRef(({ children, onClick }, ref) => ( + { + e.preventDefault() + onClick(e) + }} + > + {children} + +)) + +// forwardRef again here! +// Dropdown needs access to the DOM of the Menu to measure it +const CustomMenu = React.forwardRef( + ({ children, style, className, 'aria-labelledby': labeledBy }, ref) => { + const [value, setValue] = useState('') + + return ( +
+ setValue(e.target.value)} + value={value} + /> +
    + {React.Children.toArray(children).filter( + (child) => + !value || child.props.children.toLowerCase().includes(value) + )} +
+
+ ) + } +) diff --git a/components/text.js b/components/text.js index 80495d12..ba3f9c80 100644 --- a/components/text.js +++ b/components/text.js @@ -11,6 +11,7 @@ import reactStringReplace from 'react-string-replace' import React, { useEffect, useState } from 'react' import GithubSlugger from 'github-slugger' import Link from '../svgs/link.svg' +import {toString} from 'mdast-util-to-string' function myRemarkPlugin () { return (tree) => { @@ -29,16 +30,10 @@ function myRemarkPlugin () { } } + + function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props }) { - const id = noFragments - ? undefined - : slugger.slug(children.reduce( - (acc, cur) => { - if (typeof cur !== 'string') { - return acc - } - return acc + cur.replace(/[^\w\-\s]+/gi, '') - }, '')) + const id = noFragments ? undefined : slugger.slug(toString(node).replace(/[^\w\-\s]+/gi, '')) return (
diff --git a/package.json b/package.json index 44e5a229..7cd276a1 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "graphql-type-json": "^0.3.2", "ln-service": "^52.8.0", "mdast-util-find-and-replace": "^1.1.1", + "mdast-util-from-markdown": "^1.2.0", + "mdast-util-to-string": "^3.1.0", "next": "^11.1.2", "next-auth": "^3.29.3", "next-plausible": "^2.1.3", diff --git a/styles/globals.scss b/styles/globals.scss index 4b05c904..4fcff96a 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -1,5 +1,5 @@ $theme-colors: ( - "primary" : #FADA5E, + "primary" : #FADA5E, "secondary" : #F6911D, "danger" : #c03221, "info" : #007cbe, @@ -14,17 +14,17 @@ $border-radius: .4rem; $enable-transitions: false; $enable-gradients: false; $enable-shadows: false; -$btn-transition: none; +$btn-transition: none; $form-feedback-icon-valid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='12' height='12'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M2 9h3v12H2a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1zm5.293-1.293l6.4-6.4a.5.5 0 0 1 .654-.047l.853.64a1.5 1.5 0 0 1 .553 1.57L14.6 8H21a2 2 0 0 1 2 2v2.104a2 2 0 0 1-.15.762l-3.095 7.515a1 1 0 0 1-.925.619H8a1 1 0 0 1-1-1V8.414a1 1 0 0 1 .293-.707z' fill='rgba(92, 128, 1, 1)'/%3E%3C/svg%3E"); $form-feedback-icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='12' height='12'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M22 15h-3V3h3a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1zm-5.293 1.293l-6.4 6.4a.5.5 0 0 1-.654.047L8.8 22.1a1.5 1.5 0 0 1-.553-1.57L9.4 16H3a2 2 0 0 1-2-2v-2.104a2 2 0 0 1 .15-.762L4.246 3.62A1 1 0 0 1 5.17 3H16a1 1 0 0 1 1 1v11.586a1 1 0 0 1-.293.707z' fill='rgba(192,50,33,1)'/%3E%3C/svg%3E"); $line-height-base: 1.75; $input-btn-padding-y: .42rem; $input-btn-padding-x: .84rem; $btn-padding-y: .42rem; -$btn-padding-x: 1.1rem; +$btn-padding-x: 1.1rem; $btn-font-weight: bold; $btn-focus-width: 0; -$btn-border-width: 0; +$btn-border-width: 0; $btn-focus-box-shadow: none; $alert-border-width: 0; $close-text-shadow: none; @@ -38,13 +38,13 @@ $dropdown-border-color: #ced4da; $dropdown-link-active-color: inherit; $dropdown-link-hover-bg: transparent; $dropdown-link-active-bg: transparent; -$dropdown-link-color: rgba(0, 0, 0, 0.7); +$dropdown-link-color: rgba(0, 0, 0, 0.7); $dropdown-link-hover-color: rgba(0, 0, 0, 0.9); $container-max-widths: ( sm: 540px, md: 720px, lg: 900px, -) !default; + ) !default; $nav-link-padding-y: .1rem; $nav-tabs-link-active-bg: #fff; $nav-tabs-link-hover-border-color: transparent; @@ -54,7 +54,9 @@ $tooltip-bg: #5c8001; @import "~bootstrap/scss/bootstrap"; @media screen and (min-width: 767px) { - .table-sm th, .table-sm td { + + .table-sm th, + .table-sm td { padding: .3rem .75rem; } } @@ -74,7 +76,7 @@ $tooltip-bg: #5c8001; opacity: 0.7; } -.modal-close + .modal-body { +.modal-close+.modal-body { padding-top: 0.5rem; } @@ -82,16 +84,20 @@ $tooltip-bg: #5c8001; color: var(--theme-grey) !important; } -ol, ul, dl { +ol, +ul, +dl { padding-inline-start: 2rem; } mark { - background-color: var(--primary); - padding: 0 0.2rem; + background-color: var(--primary + ); +padding: 0 0.2rem; } -.table-sm th, .table-sm td { +.table-sm th, +.table-sm td { line-height: 1.2rem; } @@ -114,7 +120,9 @@ mark { background-color: var(--theme-body); } -.table th, .table td, .table thead th { +.table th, +.table td, +.table thead th { border-color: var(--theme-borderColor); } @@ -138,7 +146,8 @@ a:hover { color: var(--theme-linkHover); } -.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link { +.nav-tabs .nav-link.active, +.nav-tabs .nav-item.show .nav-link { color: inherit; background-color: var(--theme-inputBg); border-color: var(--theme-borderColor); @@ -152,11 +161,12 @@ a:hover { } .form-control:focus { - background-color: var(--theme-inputBg); + background-color: var(--theme-inputBg); color: var(--theme-color); } -.form-control:disabled, .form-control[readonly] { +.form-control:disabled, +.form-control[readonly] { background-color: var(--theme-inputDisabledBg); border-color: var(--theme-borderColor); opacity: 1; @@ -190,7 +200,8 @@ a:hover { color: var(--theme-navLink) !important; } -.nav-link:not(.text-success):hover, .nav-link:not(.text-success):focus { +.nav-link:not(.text-success):hover, +.nav-link:not(.text-success):focus { color: var(--theme-navLinkFocus) !important; } @@ -205,6 +216,8 @@ a:hover { .dropdown-menu { background-color: var(--theme-inputBg); border: 1px solid var(--theme-borderColor); + max-width: 90vw; + overflow: scroll; } .dropdown-item { @@ -267,7 +280,13 @@ footer { } @media screen and (max-width: 767px) { - input, select, textarea, .form-control, .form-control:focus, .input-group-text { + + input, + select, + textarea, + .form-control, + .form-control:focus, + .input-group-text { font-size: 1rem !important; } } @@ -420,17 +439,34 @@ textarea.form-control { } @keyframes flash { - from { filter: brightness(1);} - 2% { filter: brightness(2.3); } - 4% { filter: brightness(1.4); } - 8% { filter: brightness(3); } - 16% { filter: brightness(1); } - to { filter: brightness(1);} + from { + filter: brightness(1); + } + + 2% { + filter: brightness(2.3); + } + + 4% { + filter: brightness(1.4); + } + + 8% { + filter: brightness(3); + } + + 16% { + filter: brightness(1); + } + + to { + filter: brightness(1); + } } .clouds { background-image: url('/clouds.jpeg') !important; - background-color:var(--theme-grey); + background-color: var(--theme-grey); background-repeat: repeat; background-origin: content-box; background-size: cover; @@ -443,8 +479,13 @@ textarea.form-control { } @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(359deg); } + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(359deg); + } } .spin { @@ -453,7 +494,7 @@ textarea.form-control { .static { background: url('/giphy.gif'); - background-color:var(--theme-grey); + background-color: var(--theme-grey); background-repeat: repeat; background-origin: content-box; background-size: cover; @@ -461,12 +502,13 @@ textarea.form-control { opacity: .1; } -@keyframes flipX{ +@keyframes flipX { from { - transform: rotateX(180deg); + transform: rotateX(180deg); } + to { - transform: rotateX(-180deg); + transform: rotateX(-180deg); } } @@ -474,12 +516,13 @@ textarea.form-control { animation: flipX 2s linear infinite; } -@keyframes flipY{ +@keyframes flipY { from { - transform: rotateY(0deg); + transform: rotateY(0deg); } + to { - transform: rotateY(360deg); + transform: rotateY(360deg); } } @@ -487,7 +530,8 @@ textarea.form-control { animation: flipY 4s linear infinite; } -@media (hover:none), (hover:on-demand) { +@media (hover:none), +(hover:on-demand) { .tooltip { visibility: hidden; pointer-events: none; diff --git a/svgs/list-unordered.svg b/svgs/list-unordered.svg new file mode 100644 index 00000000..702a11e7 --- /dev/null +++ b/svgs/list-unordered.svg @@ -0,0 +1 @@ + \ No newline at end of file From cb313429d5daa33087a06b3caba9aaf4494be1a9 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 21 Jul 2022 17:55:05 -0500 Subject: [PATCH 02/64] job board enhancements --- api/resolvers/item.js | 35 +++-- api/typeDefs/item.js | 4 +- components/avatar.js | 74 +++++++++++ components/item-full.js | 3 +- components/item-job.js | 96 ++++++++++++++ components/item.js | 88 +----------- components/item.module.css | 18 ++- components/items.js | 3 +- components/job-form.js | 18 ++- components/notifications.js | 3 +- components/table-of-contents.js | 2 +- components/user-header.js | 125 ++++-------------- fragments/items.js | 1 + fragments/subs.js | 5 + .../20220720211644_item_uploads/migration.sql | 11 ++ prisma/schema.prisma | 7 +- public/jobs-default.png | Bin 0 -> 4183 bytes svgs/mail-open-fill.svg | 1 + svgs/mail-open-line.svg | 1 + 19 files changed, 289 insertions(+), 206 deletions(-) create mode 100644 components/avatar.js create mode 100644 components/item-job.js create mode 100644 prisma/migrations/20220720211644_item_uploads/migration.sql create mode 100644 public/jobs-default.png create mode 100644 svgs/mail-open-fill.svg create mode 100644 svgs/mail-open-line.svg diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 2bb8f0d2..506f3405 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -135,13 +135,24 @@ export default { // we pull from their wallet // TODO: need to filter out by payment status items = await models.$queryRaw(` - ${SELECT} - FROM "Item" - WHERE "parentId" IS NULL AND created_at <= $1 - AND "pinId" IS NULL - ${subClause(3)} - AND status <> 'STOPPED' - ORDER BY (CASE WHEN status = 'ACTIVE' THEN "maxBid" ELSE 0 END) DESC, created_at ASC + SELECT * + FROM ( + (${SELECT} + FROM "Item" + WHERE "parentId" IS NULL AND created_at <= $1 + AND "pinId" IS NULL + ${subClause(3)} + AND status = 'ACTIVE' + ORDER BY "maxBid" DESC, created_at ASC) + UNION ALL + (${SELECT} + FROM "Item" + WHERE "parentId" IS NULL AND created_at <= $1 + AND "pinId" IS NULL + ${subClause(3)} + AND status = 'NOSATS' + ORDER BY created_at DESC) + ) a OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub) break @@ -447,7 +458,10 @@ export default { return await createItem(parent, data, { me, models }) } }, - upsertJob: async (parent, { id, sub, title, company, location, remote, text, url, maxBid, status }, { me, models }) => { + upsertJob: async (parent, { + id, sub, title, company, location, remote, + text, url, maxBid, status, logo + }, { me, models }) => { if (!me) { throw new AuthenticationError('you must be logged in to create job') } @@ -483,7 +497,8 @@ export default { url, maxBid, subName: sub, - userId: me.id + userId: me.id, + uploadId: logo } if (id) { @@ -837,7 +852,7 @@ 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, ltree2text("Item"."path") AS "path"` + "Item"."subName", "Item".status, "Item"."uploadId", ltree2text("Item"."path") AS "path"` function newTimedOrderByWeightedSats (num) { return ` diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 642069a1..a02c3e74 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -21,7 +21,8 @@ export default gql` extend type Mutation { upsertLink(id: ID, title: String!, url: String!, boost: Int, forward: String): Item! upsertDiscussion(id: ID, title: String!, text: String, boost: Int, forward: String): Item! - upsertJob(id: ID, sub: ID!, title: String!, company: String!, location: String, remote: Boolean, text: String!, url: String!, maxBid: Int!, status: String): Item! + upsertJob(id: ID, sub: ID!, title: String!, company: String!, location: String, remote: Boolean, + text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! createComment(text: String!, parentId: ID!): Item! updateComment(id: ID!, text: String!): Item! act(id: ID!, sats: Int): ItemActResult! @@ -71,5 +72,6 @@ export default gql` remote: Boolean sub: Sub status: String + uploadId: Int } ` diff --git a/components/avatar.js b/components/avatar.js new file mode 100644 index 00000000..d9fd394a --- /dev/null +++ b/components/avatar.js @@ -0,0 +1,74 @@ +import { useRef, useState } from 'react' +import AvatarEditor from 'react-avatar-editor' +import { Button, Modal, Form as BootstrapForm } from 'react-bootstrap' +import Upload from './upload' +import EditImage from '../svgs/image-edit-fill.svg' +import Moon from '../svgs/moon-fill.svg' + +export default function Avatar ({ onSuccess }) { + const [uploading, setUploading] = useState() + const [editProps, setEditProps] = useState() + const ref = useRef() + const [scale, setScale] = useState(1) + + return ( + <> + setEditProps(null)} + > +
setEditProps(null)}>X
+ + + + setScale(parseFloat(e.target.value))} + min={1} max={2} step='0.05' + defaultValue={scale} custom + /> + + + +
+ +
+ {uploading + ? + : } +
} + onError={e => { + console.log(e) + setUploading(false) + }} + onSelect={(file, upload) => { + setEditProps({ file, upload }) + }} + onSuccess={async key => { + onSuccess && onSuccess(key) + setUploading(false) + }} + onStarted={() => { + setUploading(true) + }} + /> + + ) +} diff --git a/components/item-full.js b/components/item-full.js index cd402bff..fb945e38 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -1,4 +1,5 @@ -import Item, { ItemJob } from './item' +import Item from './item' +import ItemJob from './item-job' import Reply from './reply' import Comment from './comment' import Text from './text' diff --git a/components/item-job.js b/components/item-job.js new file mode 100644 index 00000000..4f3fd0ea --- /dev/null +++ b/components/item-job.js @@ -0,0 +1,96 @@ +import * as Yup from 'yup' +import Toc from './table-of-contents' +import { Button, Image } from 'react-bootstrap' +import { SearchTitle } from './item' +import styles from './item.module.css' +import Link from 'next/link' +import { timeSince } from '../lib/time' +import EmailIcon from '../svgs/mail-open-line.svg' + +export default function ItemJob ({ item, toc, rank, children }) { + const isEmail = Yup.string().email().isValidSync(item.url) + + return ( + <> + {rank + ? ( +
+ {rank} +
) + :
} +
+ + + + + +
+ +
+ {item.status === 'NOSATS' && + <> + expired + {item.company && \ } + } + {item.company && + <> + {item.company} + } + {(item.location || item.remote) && + <> + \ + {`${item.location || ''}${item.location && item.remote ? ' or ' : ''}${item.remote ? 'Remote' : ''}`} + } + + \ + + + @{item.user.name} + + + + {timeSince(new Date(item.createdAt))} + + + {item.mine && + <> + + \ + + + edit + + + {item.status !== 'ACTIVE' && {item.status}} + } +
+
+ {toc && } +
+ {children && ( +
+
+ + {isEmail &&
{item.url}
} +
+ {children} +
+ )} + + ) +} diff --git a/components/item.js b/components/item.js index a77383a2..7a1a7344 100644 --- a/components/item.js +++ b/components/item.js @@ -7,100 +7,14 @@ import Countdown from './countdown' import { NOFOLLOW_LIMIT } from '../lib/constants' import Pin from '../svgs/pushpin-fill.svg' import reactStringReplace from 'react-string-replace' -import { formatSats } from '../lib/format' -import * as Yup from 'yup' -import Briefcase from '../svgs/briefcase-4-fill.svg' import Toc from './table-of-contents' -function SearchTitle ({ title }) { +export function SearchTitle ({ title }) { return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { return {match} }) } -export function ItemJob ({ item, toc, rank, children }) { - const isEmail = Yup.string().email().isValidSync(item.url) - - return ( - <> - {rank - ? ( -
- {rank} -
) - :
} -
- -
- -
- {item.status !== 'NOSATS' - ? {formatSats(item.maxBid)} sats per min - : expired} - \ - - {item.ncomments} comments - - \ - - - @{item.user.name} - - - - {timeSince(new Date(item.createdAt))} - - - {item.mine && - <> - \ - - - edit - - - {item.status !== 'ACTIVE' && {item.status}} - } -
-
- {toc && } -
- {children && ( -
- {children} -
- )} - - ) -} - function FwdUser ({ user }) { return (
diff --git a/components/item.module.css b/components/item.module.css index 92828e9f..2f6e3c75 100644 --- a/components/item.module.css +++ b/components/item.module.css @@ -41,13 +41,20 @@ a.link:visited { .other { font-size: 80%; color: var(--theme-grey); - margin-bottom: .15rem; } .item { display: flex; justify-content: flex-start; min-width: 0; + margin-bottom: .45rem; +} + +.item .companyImage { + border-radius: 100%; + align-self: center; + margin-right: 0.5rem; + margin-left: 0.3rem; } .itemDead { @@ -62,10 +69,17 @@ a.link:visited { .hunk { overflow: hidden; width: 100%; - margin-bottom: .3rem; line-height: 1.06rem; } +/* .itemJob .hunk { + align-self: center; +} + +.itemJob .rank { + align-self: center; +} */ + .main { display: flex; align-items: baseline; diff --git a/components/items.js b/components/items.js index a8aaef7b..4f4c5002 100644 --- a/components/items.js +++ b/components/items.js @@ -1,5 +1,6 @@ import { useQuery } from '@apollo/client' -import Item, { ItemJob, ItemSkeleton } from './item' +import Item, { ItemSkeleton } from './item' +import ItemJob from './item-job' import styles from './items.module.css' import { ITEMS } from '../fragments/items' import MoreFooter from './more-footer' diff --git a/components/job-form.js b/components/job-form.js index e94c4572..122c2d35 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -1,6 +1,6 @@ import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form' import TextareaAutosize from 'react-textarea-autosize' -import { InputGroup, Form as BForm, Col } from 'react-bootstrap' +import { InputGroup, Form as BForm, Col, Image } from 'react-bootstrap' import * as Yup from 'yup' import { useEffect, useState } from 'react' import Info from './info' @@ -10,6 +10,7 @@ import { useLazyQuery, gql, useMutation } from '@apollo/client' import { useRouter } from 'next/router' import Link from 'next/link' import { usePrice } from './price' +import Avatar from './avatar' Yup.addMethod(Yup.string, 'or', function (schemas, msg) { return this.test({ @@ -47,6 +48,7 @@ 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) @@ -54,10 +56,10 @@ export default function JobForm ({ item, sub }) { { 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) { + $remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int) { upsertJob(sub: "${sub.name}", id: $id, title: $title, company: $company, location: $location, remote: $remote, text: $text, - url: $url, maxBid: $maxBid, status: $status) { + url: $url, maxBid: $maxBid, status: $status, logo: $logo) { id } }` @@ -122,6 +124,7 @@ export default function JobForm ({ item, sub }) { sub: sub.name, maxBid: Number(maxBid), status, + logo: Number(logoId), ...values } }) @@ -136,6 +139,15 @@ export default function JobForm ({ item, sub }) { } })} > +
+ +
+ + +
+
+ diff --git a/components/user-header.js b/components/user-header.js index 9aec579b..525b0fb9 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -1,8 +1,8 @@ -import { Button, InputGroup, Image, Modal, Form as BootstrapForm } from 'react-bootstrap' +import { Button, InputGroup, Image } from 'react-bootstrap' import Link from 'next/link' import { useRouter } from 'next/router' import Nav from 'react-bootstrap/Nav' -import { useRef, useState } from 'react' +import { useState } from 'react' import { Form, Input, SubmitButton } from './form' import * as Yup from 'yup' import { gql, useApolloClient, useMutation } from '@apollo/client' @@ -13,10 +13,7 @@ import QRCode from 'qrcode.react' import LightningIcon from '../svgs/bolt.svg' import ModalButton from './modal-button' import { encodeLNUrl } from '../lib/lnurl' -import Upload from './upload' -import EditImage from '../svgs/image-edit-fill.svg' -import Moon from '../svgs/moon-fill.svg' -import AvatarEditor from 'react-avatar-editor' +import Avatar from './avatar' export default function UserHeader ({ user }) { const [editting, setEditting] = useState(false) @@ -25,6 +22,24 @@ export default function UserHeader ({ user }) { const client = useApolloClient() const [setName] = useMutation(NAME_MUTATION) + const [setPhoto] = useMutation( + gql` + mutation setPhoto($photoId: ID!) { + setPhoto(photoId: $photoId) + }`, { + update (cache, { data: { setPhoto } }) { + cache.modify({ + id: `User:${user.id}`, + fields: { + photoId () { + return setPhoto + } + } + }) + } + } + ) + const isMe = me?.name === user.name const Satistics = () =>
{isMe ? `${user.sats} sats \\ ` : ''}{user.stacked} stacked
@@ -54,7 +69,14 @@ export default function UserHeader ({ user }) { src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='135' height='135' className={styles.userimg} /> - {isMe && } + {isMe && + { + const { error } = await setPhoto({ variables: { photoId } }) + if (error) { + console.log(error) + } + }} + />}
{editting @@ -161,92 +183,3 @@ export default function UserHeader ({ user }) { ) } - -function PhotoEditor ({ userId }) { - const [uploading, setUploading] = useState() - const [editProps, setEditProps] = useState() - const ref = useRef() - const [scale, setScale] = useState(1) - - const [setPhoto] = useMutation( - gql` - mutation setPhoto($photoId: ID!) { - setPhoto(photoId: $photoId) - }`, { - update (cache, { data: { setPhoto } }) { - cache.modify({ - id: `User:${userId}`, - fields: { - photoId () { - return setPhoto - } - } - }) - } - } - ) - - return ( - <> - setEditProps(null)} - > -
setEditProps(null)}>X
- - - - setScale(parseFloat(e.target.value))} - min={1} max={2} step='0.05' - defaultValue={scale} custom - /> - - - -
- -
- {uploading - ? - : } -
} - onError={e => { - console.log(e) - setUploading(false) - }} - onSelect={(file, upload) => { - setEditProps({ file, upload }) - }} - onSuccess={async key => { - const { error } = await setPhoto({ variables: { photoId: key } }) - if (error) { - console.log(error) - } - setUploading(false) - }} - onStarted={() => { - setUploading(true) - }} - /> - - ) -} diff --git a/fragments/items.js b/fragments/items.js index 23459c22..e131881a 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -31,6 +31,7 @@ export const ITEM_FIELDS = gql` baseCost } status + uploadId mine root { id diff --git a/fragments/subs.js b/fragments/subs.js index cea8ec61..fc6025d0 100644 --- a/fragments/subs.js +++ b/fragments/subs.js @@ -29,6 +29,11 @@ export const SUB_ITEMS = gql` cursor items { ...ItemFields + position + }, + pins { + ...ItemFields + position } } } diff --git a/prisma/migrations/20220720211644_item_uploads/migration.sql b/prisma/migrations/20220720211644_item_uploads/migration.sql new file mode 100644 index 00000000..c4b17159 --- /dev/null +++ b/prisma/migrations/20220720211644_item_uploads/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[itemId]` on the table `Upload` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "uploadId" INTEGER; + +-- CreateIndex +CREATE UNIQUE INDEX "Upload.itemId_unique" ON "Upload"("itemId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bca8d0c4..32980bb9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,7 +72,7 @@ model Upload { width Int? height Int? item Item? @relation(fields: [itemId], references: [id]) - itemId Int? + itemId Int? @unique user User @relation(name: "Uploads", fields: [userId], references: [id]) userId Int @@ -161,6 +161,8 @@ model Item { pinId Int? weightedVotes Float @default(0) boost Int @default(0) + uploadId Int? + upload Upload? // if sub is null, this is the main sub sub Sub? @relation(fields: [subName], references: [name]) @@ -178,8 +180,7 @@ model Item { longitude Float? remote Boolean? - User User[] - Upload Upload[] + User User[] @@index([createdAt]) @@index([userId]) @@index([parentId]) diff --git a/public/jobs-default.png b/public/jobs-default.png new file mode 100644 index 0000000000000000000000000000000000000000..89f6d793c77d148307711c82009abd493f18190d GIT binary patch literal 4183 zcmeHL`9IWM|NhvrOqRM0*_V`%B|_OHM7G2X!;mFQVq}}!HccWSTh@@Bh9SwmG`2+9 z*Kykhm2EKD2g5w`eZGIf^ZfYybk^5(u5+F9KG*AgVl2!I+1dEn003Y&x_8$S0O)8{ zI)LRIt+4mQxB&o{u+d#ztG{zL=e#PcCiuQ>>)T<_kf;z=CK+UM>;oxYEQ@o5_$ISA zx2XsHbR^rgx_c%3Ww6M?%6##!v5axN3T4&X5xOPf-J)O%EK964x3=$JcW;gxWV?r| zoIhzk=+BvHr8+J#A8z;$_A8KTrudAUb6#OgMmb2*HWgw}{eJJI#uz`{xV3iE(L!BPu`-#be?_0hm6a;Yr)`d%X zioee_AW81Z+abl!s`+Xcj4Y#0nn}*zmFmZ!#UjQESYaScp+o&CU)I{z1^@ELn#`+!f)l7kUpEZhZE$)NN8yJ{oo{U#fq)Oz#w3F(r1%wGF#6#T&1SjD*Kk)SynkpD?Z36r^=UNuR|Ew>uwwV8^q`c~$e z7UfUZl*=DIK*D0AEJ@@!1)&>vG68jDlJym4dKJ6Ww37Zi=bf4AC2s<5?#uSK4jGVi zyU$#+$lo06fta+9kcHHBJfdB!gO?qE9-gfpt!is>GFk2A_<1Ba^=HcXfd~_(xWUY4 z0^N>c^rF$tGg!!}5(`84FsF8__j0s4j1Nhge!jw*XMPXAn`t{(OK^o?s+kLZ5D9zW!g)-J#5{nBRE9auDrR4$pPrRKo z9VHg~z?~2uw$!x3G;=!;khY@CoK z_Vnvti9HGKM$#zwUGYwcRkLvu~O(ChK@*`8x=H_6`p19WsrPJQXJYn+qcr!^(9Wcb|M$>PaI zmcVBOMAGyYht|=r`6y(y zwXf@@mEfp!cdI~Z`3*WuyZTB_M|2p}S(qv1l}_4T#Gv-`zt`JzFYX)j$l3R9=?1c( z1&R1Jo)i90meS63Ax195-+g(=lY?-cr`NLryfun*?|d**ddXQj1oz>WcW1iaiwxZ` zz6wl$ZWAkN*apNffBL?7eYTozxLHy*NvOQSsFQ$zq?ItE1BjECZvggi^GBrlp$N@$ zHL!AD!HVh-2omm8G+?}oDalqQ4&8Q7)272Ljd5L-nJO%|MeE0EKizMNx|Kll<|@h$ zFYiQ6m6OoDy^gWp4bOgF{l$~ryx*+t&_Zw6QYH=Agl0km`0G7*=09YI95Kg#nYg~EkSRs8?{_K_PJ{PH^0W!w^u5QECojkR0Lep_$qqw$(A(wkFdPkr6b<~zuR0rKL$Au#dUkqEHFKvy zO%~0xoj|1vy&)W8aH9N9!4RlQ+~p}WF^a^-tzC)Jrf=7q*U)K{m_oW89n4TK*-P)P z850>u>XU=blX>4%TX)9Y7uAQB@WUT!@sVMSXy$?CM8RNNw+l~hm?m@PruU)Fj%n3RMAW$3<~y%5k1gF)QWVMW|E7e{U=}w!JSziAT-L}XccROt$?x$?Fs@Gu1v_0A2iSVEm@O1MVtlUP-^W#ehaDo*!ek-yjQMQTf86uf zUtMFDsw8{v6Pb|=JfEDpUiGIWYM+s(stY^o=t~IfmrAnVNW&N2s?cFN@uar1HsN80YYT6b275J*zW#N3@OwA89-@|pb^16v=}K9kbcs*74{otEWvP%iLw5w&?cbwv+Se8NfDvnyeO83^ zGJSqPng|?a<1P&tLytU_VNES-^mUykKBuDd`amg0K6H`9m|!MKP6wLcIm zgYN^5UcAoY1S)qz_a~qN0gj>hO_()c_>M^Uvnz0vsO`WxG|%xT4xwI14V_MgNN-wjOMvG9V^#HoA8jHE16OfRVo0-H&%3Mf?jzBvdZ| literal 0 HcmV?d00001 diff --git a/svgs/mail-open-fill.svg b/svgs/mail-open-fill.svg new file mode 100644 index 00000000..cd75d9cb --- /dev/null +++ b/svgs/mail-open-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/mail-open-line.svg b/svgs/mail-open-line.svg new file mode 100644 index 00000000..64b33032 --- /dev/null +++ b/svgs/mail-open-line.svg @@ -0,0 +1 @@ + \ No newline at end of file From 5ac894baedc55be211e63f397dd7a8f3c20fe02d Mon Sep 17 00:00:00 2001 From: keyan Date: Sat, 23 Jul 2022 19:00:57 -0500 Subject: [PATCH 03/64] more expensive boost + explainer --- api/resolvers/item.js | 2 +- components/adv-post-form.js | 23 ++++++++++++++++++++++- lib/constants.js | 4 ++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 506f3405..d95f9deb 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -857,7 +857,7 @@ export const SELECT = function newTimedOrderByWeightedSats (num) { return ` ORDER BY (POWER("Item"."weightedVotes", 1.2)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 1.3) + - GREATEST("Item".boost-1000+5, 0)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 4)) DESC NULLS LAST, "Item".id DESC` + ("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' diff --git a/components/adv-post-form.js b/components/adv-post-form.js index 10bb272b..f2c91568 100644 --- a/components/adv-post-form.js +++ b/components/adv-post-form.js @@ -4,6 +4,7 @@ import { Input } from './form' import { InputGroup } from 'react-bootstrap' import { BOOST_MIN } from '../lib/constants' import { NAME_QUERY } from '../fragments/users' +import Info from './info' export function AdvPostSchema (client) { return { @@ -34,7 +35,27 @@ export default function AdvPostForm () { body={ <> boost} + label={ +
boost + +
    +
  1. Boost ranks posts higher temporarily based on the amount
  2. +
  3. The minimum boost is {BOOST_MIN} sats
  4. +
  5. Each {BOOST_MIN} sats of boost is equivalent to one trusted upvote +
      +
    • e.g. {BOOST_MIN * 2} sats is like 2 votes
    • +
    +
  6. +
  7. The decay of boost "votes" increases at 2x the rate of organic votes +
      +
    • i.e. boost votes fall out of ranking faster
    • +
    +
  8. +
  9. 100% of sats from boost are given back to top users as rewards
  10. +
+
+
+ } name='boost' hint={ranks posts higher temporarily based on the amount} append={sats} diff --git a/lib/constants.js b/lib/constants.js index 1e2f1816..a45ff6cb 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -1,5 +1,5 @@ -export const NOFOLLOW_LIMIT = 100 -export const BOOST_MIN = 1000 +export const NOFOLLOW_LIMIT = 1000 +export const BOOST_MIN = 5000 export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024 export const IMAGE_PIXELS_MAX = 35000000 export const UPLOAD_TYPES_ALLOW = [ From 82280b0966d695ced83ec8e66aa70a207416ce23 Mon Sep 17 00:00:00 2001 From: keyan Date: Sat, 30 Jul 2022 08:25:46 -0500 Subject: [PATCH 04/64] add polls --- api/resolvers/growth.js | 4 +- api/resolvers/item.js | 79 +++++++++++- api/resolvers/sub.js | 2 +- api/typeDefs/item.js | 17 +++ components/action-tooltip.js | 4 +- components/form.js | 52 +++++++- components/item-full.js | 2 + components/item.js | 2 + components/poll-form.js | 96 +++++++++++++++ components/poll.js | 99 ++++++++++++++++ components/poll.module.css | 45 +++++++ components/price.js | 6 +- fragments/items.js | 11 ++ lib/constants.js | 1 + lib/format.js | 4 + lib/time.js | 22 ++++ pages/post.js | 23 +++- pages/users/forever.js | 3 + .../20220727194641_polls/migration.sql | 55 +++++++++ .../migration.sql | 23 ++++ .../migration.sql | 112 ++++++++++++++++++ prisma/schema.prisma | 42 ++++++- svgs/add-fill.svg | 1 + svgs/bar-chart-horizontal-fill.svg | 1 + svgs/checkbox-circle-fill.svg | 1 + worker/earn.js | 2 +- 26 files changed, 685 insertions(+), 24 deletions(-) create mode 100644 components/poll-form.js create mode 100644 components/poll.js create mode 100644 components/poll.module.css create mode 100644 prisma/migrations/20220727194641_polls/migration.sql create mode 100644 prisma/migrations/20220727194920_poll_functions/migration.sql create mode 100644 prisma/migrations/20220727203003_poll_functions2/migration.sql create mode 100644 svgs/add-fill.svg create mode 100644 svgs/bar-chart-horizontal-fill.svg create mode 100644 svgs/checkbox-circle-fill.svg diff --git a/api/resolvers/growth.js b/api/resolvers/growth.js index 0009ecd1..84b1ddcb 100644 --- a/api/resolvers/growth.js +++ b/api/resolvers/growth.js @@ -34,7 +34,7 @@ export default { return await models.$queryRaw( `SELECT date_trunc('month', "ItemAct".created_at) AS time, sum(CASE WHEN act = 'STREAM' THEN sats ELSE 0 END) as jobs, - sum(CASE WHEN act = 'VOTE' AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END) as fees, + sum(CASE WHEN act IN ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END) as fees, sum(CASE WHEN act = 'BOOST' THEN sats ELSE 0 END) as boost, sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END) as tips FROM "ItemAct" @@ -122,7 +122,7 @@ export default { const [stats] = await models.$queryRaw( `SELECT json_build_array( json_build_object('name', 'jobs', 'value', sum(CASE WHEN act = 'STREAM' THEN sats ELSE 0 END)), - json_build_object('name', 'fees', 'value', sum(CASE WHEN act = 'VOTE' AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END)), + json_build_object('name', 'fees', 'value', sum(CASE WHEN act in ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END)), json_build_object('name', 'boost', 'value', sum(CASE WHEN act = 'BOOST' THEN sats ELSE 0 END)), json_build_object('name', 'tips', 'value', sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END))) as array FROM "ItemAct" diff --git a/api/resolvers/item.js b/api/resolvers/item.js index d95f9deb..6dbfa555 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -458,6 +458,50 @@ export default { return await createItem(parent, data, { me, models }) } }, + upsertPoll: async (parent, { id, forward, boost, title, text, options }, { me, models }) => { + if (!me) { + throw new AuthenticationError('you must be logged in') + } + + if (boost && boost < BOOST_MIN) { + throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' }) + } + + if (id) { + // TODO: this isn't ever called clientside, we edit like it's a discussion + + const item = await models.item.update({ + where: { id: Number(id) }, + data: { title: title } + }) + + return item + } else { + let fwdUser + if (forward) { + fwdUser = await models.user.findUnique({ where: { name: forward } }) + if (!fwdUser) { + throw new UserInputError('forward user does not exist', { argumentName: 'forward' }) + } + } + + const [item] = await serialize(models, + models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6) AS "Item"`, + title, text, 1, Number(boost || 0), Number(me.id), options)) + + if (fwdUser) { + await models.item.update({ + where: { id: item.id }, + data: { + fwdUserId: fwdUser.id + } + }) + } + + item.comments = [] + return item + } + }, upsertJob: async (parent, { id, sub, title, company, location, remote, text, url, maxBid, status, logo @@ -534,6 +578,17 @@ export default { updateComment: async (parent, { id, text }, { me, models }) => { return await updateItem(parent, { id, data: { text } }, { me, models }) }, + pollVote: async (parent, { id }, { me, models }) => { + if (!me) { + throw new AuthenticationError('you must be logged in') + } + + await serialize(models, + models.$queryRaw(`${SELECT} FROM poll_vote($1, $2) AS "Item"`, + Number(id), Number(me.id))) + + return id + }, act: async (parent, { id, sats }, { me, models }) => { // need to make sure we are logged in if (!me) { @@ -561,7 +616,6 @@ export default { } } }, - Item: { sub: async (item, args, { models }) => { if (!item.subName) { @@ -605,6 +659,27 @@ export default { return prior.id }, + poll: async (item, args, { models, me }) => { + if (!item.pollCost) { + return null + } + + const options = await models.$queryRaw` + SELECT "PollOption".id, option, count("PollVote"."userId") as count, + coalesce(bool_or("PollVote"."userId" = ${me?.id}), 'f') as "meVoted" + FROM "PollOption" + LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id + WHERE "PollOption"."itemId" = ${item.id} + GROUP BY "PollOption".id + ORDER BY "PollOption".id ASC + ` + const poll = {} + poll.options = options + poll.meVoted = options.some(o => o.meVoted) + poll.count = options.reduce((t, o) => t + o.count, 0) + + return poll + }, user: async (item, args, { models }) => await models.user.findUnique({ where: { id: item.userId } }), fwdUser: async (item, args, { models }) => { @@ -852,7 +927,7 @@ 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", ltree2text("Item"."path") AS "path"` + "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", ltree2text("Item"."path") AS "path"` function newTimedOrderByWeightedSats (num) { return ` diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index f68c6909..0ec79f84 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -28,7 +28,7 @@ export default { } }) - return latest.createdAt + return latest?.createdAt } } } diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index a02c3e74..931435e3 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -23,9 +23,24 @@ export default gql` upsertDiscussion(id: ID, title: String!, text: String, boost: Int, forward: String): Item! upsertJob(id: ID, sub: ID!, title: String!, company: String!, location: String, remote: Boolean, text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! + 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! act(id: ID!, sats: Int): ItemActResult! + pollVote(id: ID!): ID! + } + + type PollOption { + id: ID, + option: String! + count: Int! + meVoted: Boolean! + } + + type Poll { + meVoted: Boolean! + count: Int! + options: [PollOption!]! } type Items { @@ -67,6 +82,8 @@ export default gql` position: Int prior: Int maxBid: Int + pollCost: Int + poll: Poll company: String location: String remote: Boolean diff --git a/components/action-tooltip.js b/components/action-tooltip.js index f79158e8..5d64eabb 100644 --- a/components/action-tooltip.js +++ b/components/action-tooltip.js @@ -1,7 +1,7 @@ import { useFormikContext } from 'formik' import { OverlayTrigger, Tooltip } from 'react-bootstrap' -export default function ActionTooltip ({ children, notForm, disable, overlayText }) { +export default function ActionTooltip ({ children, notForm, disable, overlayText, placement }) { // if we're in a form, we want to hide tooltip on submit let formik if (!notForm) { @@ -12,7 +12,7 @@ export default function ActionTooltip ({ children, notForm, disable, overlayText } return ( {overlayText || '1 sat'} diff --git a/components/form.js b/components/form.js index aa3b9ca1..7373bbc3 100644 --- a/components/form.js +++ b/components/form.js @@ -2,14 +2,15 @@ import Button from 'react-bootstrap/Button' import InputGroup from 'react-bootstrap/InputGroup' import BootstrapForm from 'react-bootstrap/Form' import Alert from 'react-bootstrap/Alert' -import { Formik, Form as FormikForm, useFormikContext, useField } from 'formik' +import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik' import React, { useEffect, useRef, useState } from 'react' import copy from 'clipboard-copy' import Thumb from '../svgs/thumb-up-fill.svg' -import { Nav } from 'react-bootstrap' +import { Col, Nav } from 'react-bootstrap' import Markdown from '../svgs/markdown-line.svg' import styles from './form.module.css' import Text from '../components/text' +import AddIcon from '../svgs/add-fill.svg' export function SubmitButton ({ children, variant, value, onClick, ...props @@ -201,6 +202,39 @@ export function Input ({ label, groupClassName, ...props }) { ) } +export function VariableInput ({ label, groupClassName, name, hint, max, ...props }) { + return ( + + + {({ form, ...fieldArrayHelpers }) => { + const options = form.values.options + return ( + <> + {options.map((_, i) => ( +
+ + + 1 ? 'optional' : undefined} /> + + {options.length - 1 === i && options.length !== max + ? fieldArrayHelpers.push('')} /> + : null} + +
+ ))} + + ) + }} +
+ {hint && ( + + {hint} + + )} +
+ ) +} + export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, handleChange, inline, ...props }) { // React treats radios and checkbox inputs differently other input types, select, and textarea. // Formik does this too! When you specify `type` to useField(), it will @@ -243,11 +277,17 @@ export function Form ({ validationSchema={schema} initialTouched={validateImmediately && initial} validateOnBlur={false} - onSubmit={async (...args) => - onSubmit && onSubmit(...args).then(() => { + onSubmit={async (values, ...args) => + onSubmit && onSubmit(values, ...args).then(() => { if (!storageKeyPrefix) return - Object.keys(...args).forEach(v => - localStorage.removeItem(storageKeyPrefix + '-' + v)) + Object.keys(values).forEach(v => { + localStorage.removeItem(storageKeyPrefix + '-' + v) + if (Array.isArray(values[v])) { + values[v].forEach( + (_, i) => localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`)) + } + } + ) }).catch(e => setError(e.message || e))} > diff --git a/components/item-full.js b/components/item-full.js index fb945e38..4d6228a2 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -12,6 +12,7 @@ import { TwitterTweetEmbed } from 'react-twitter-embed' import YouTube from 'react-youtube' import useDarkMode from 'use-dark-mode' import { useState } from 'react' +import Poll from './poll' function BioItem ({ item, handleClick }) { const me = useMe() @@ -87,6 +88,7 @@ function TopLevelItem ({ item, noReply, ...props }) { {item.text && } {item.url && } + {item.poll && } {!noReply && } ) diff --git a/components/item.js b/components/item.js index 7a1a7344..c9bf3ffe 100644 --- a/components/item.js +++ b/components/item.js @@ -8,6 +8,7 @@ import { NOFOLLOW_LIMIT } from '../lib/constants' import Pin from '../svgs/pushpin-fill.svg' import reactStringReplace from 'react-string-replace' import Toc from './table-of-contents' +import PollIcon from '../svgs/bar-chart-horizontal-fill.svg' export function SearchTitle ({ title }) { return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { @@ -55,6 +56,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) { {item.searchTitle ? : item.title} + {item.pollCost && } {item.url && diff --git a/components/poll-form.js b/components/poll-form.js new file mode 100644 index 00000000..766021e0 --- /dev/null +++ b/components/poll-form.js @@ -0,0 +1,96 @@ +import { Form, Input, MarkdownInput, SubmitButton, VariableInput } from '../components/form' +import { useRouter } from 'next/router' +import * as Yup from 'yup' +import { gql, useApolloClient, useMutation } from '@apollo/client' +import ActionTooltip from '../components/action-tooltip' +import Countdown from './countdown' +import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' +import { MAX_TITLE_LENGTH, MAX_POLL_CHOICE_LENGTH } from '../lib/constants' +import TextareaAutosize from 'react-textarea-autosize' + +export function PollForm ({ item, editThreshold }) { + const router = useRouter() + const client = useApolloClient() + + const [upsertPoll] = useMutation( + gql` + mutation upsertPoll($id: ID, $title: String!, $text: String, + $options: [String!]!, $boost: Int, $forward: String) { + upsertPoll(id: $id, title: $title, text: $text, + options: $options, boost: $boost, forward: $forward) { + id + } + }` + ) + + const PollSchema = Yup.object({ + title: Yup.string().required('required').trim() + .max(MAX_TITLE_LENGTH, + ({ max, value }) => `${Math.abs(max - value.length)} too many`), + options: Yup.array().of( + Yup.string().trim().test('my-test', 'required', function (value) { + return (this.path !== 'options[0]' && this.path !== 'options[1]') || value + }).max(MAX_POLL_CHOICE_LENGTH, + ({ max, value }) => `${Math.abs(max - value.length)} too many`) + ), + ...AdvPostSchema(client) + }) + + return ( +
{ + const optionsFiltered = options.filter(word => word.trim().length > 0) + const { error } = await upsertPoll({ + variables: { + id: item?.id, + boost: Number(boost), + title: title.trim(), + options: optionsFiltered, + ...values + } + }) + if (error) { + throw new Error({ message: error.toString() }) + } + if (item) { + await router.push(`/items/${item.id}`) + } else { + await router.push('/recent') + } + }} + storageKeyPrefix={item ? undefined : 'poll'} + > + + text optional} + name='text' + as={TextareaAutosize} + minRows={2} + /> +
+ : null} + /> + {!item && } + + {item ? 'save' : 'post'} + + + + ) +} diff --git a/components/poll.js b/components/poll.js new file mode 100644 index 00000000..952c511d --- /dev/null +++ b/components/poll.js @@ -0,0 +1,99 @@ +import { gql, useMutation } from '@apollo/client' +import { Button } from 'react-bootstrap' +import { fixedDecimal } from '../lib/format' +import { timeLeft } from '../lib/time' +import { useMe } from './me' +import styles from './poll.module.css' +import Check from '../svgs/checkbox-circle-fill.svg' +import { signIn } from 'next-auth/client' +import { useFundError } from './fund-error' +import ActionTooltip from './action-tooltip' + +export default function Poll ({ item }) { + const me = useMe() + const { setError } = useFundError() + const [pollVote] = useMutation( + gql` + mutation pollVote($id: ID!) { + pollVote(id: $id) + }`, { + update (cache, { data: { pollVote } }) { + cache.modify({ + id: `Item:${item.id}`, + fields: { + poll (existingPoll) { + const poll = { ...existingPoll } + poll.meVoted = true + poll.count += 1 + return poll + } + } + }) + cache.modify({ + id: `PollOption:${pollVote}`, + fields: { + count (existingCount) { + return existingCount + 1 + }, + meVoted () { + return true + } + } + }) + } + } + ) + + const PollButton = ({ v }) => { + return ( + + + + ) + } + + const expiresIn = timeLeft(new Date(+new Date(item.createdAt) + 864e5)) + const mine = item.user.id === me?.id + return ( +
+ {item.poll.options.map(v => + expiresIn && !item.poll.meVoted && !mine + ? + : )} +
{item.poll.count} votes \ {expiresIn ? `${expiresIn} left` : 'poll ended'}
+
+ ) +} + +function PollResult ({ v, progress }) { + return ( +
+ {v.option}{v.meVoted && } + {progress}% +
+
+ ) +} diff --git a/components/poll.module.css b/components/poll.module.css new file mode 100644 index 00000000..b482d810 --- /dev/null +++ b/components/poll.module.css @@ -0,0 +1,45 @@ +.pollButton { + margin-top: .25rem; + display: block; + border: 2px solid var(--info); + border-radius: 2rem; + width: 100%; + max-width: 600px; + padding: 0rem 1.1rem; + height: 2rem; + text-transform: uppercase; +} + +.pollBox { + padding-top: .5rem; + padding-right: 15px; + width: 100%; + max-width: 600px; +} + +.pollResult { + text-transform: uppercase; + position: relative; + width: 100%; + max-width: 600px; + height: 2rem; + margin-top: .25rem; + display: flex; + border-radius: .4rem; +} + +.pollProgress { + content: '\A'; + border-radius: .4rem 0rem 0rem .4rem; + position: absolute; + background: var(--theme-clickToContextColor); + top: 0; + bottom: 0; + left: 0; +} + +.pollResult .pollOption { + align-self: center; + margin-left: .5rem; + display: flex; +} \ No newline at end of file diff --git a/components/price.js b/components/price.js index 51c848df..34350d64 100644 --- a/components/price.js +++ b/components/price.js @@ -1,6 +1,7 @@ import React, { useContext, useEffect, useState } from 'react' import { Button } from 'react-bootstrap' import useSWR from 'swr' +import { fixedDecimal } from '../lib/format' const fetcher = url => fetch(url).then(res => res.json()).catch() @@ -49,7 +50,6 @@ export default function Price () { if (!price) return null - const fixed = (n, f) => Number.parseFloat(n).toFixed(f) const handleClick = () => { if (asSats === 'yep') { localStorage.setItem('asSats', '1btc') @@ -66,7 +66,7 @@ export default function Price () { if (asSats === 'yep') { return ( ) } @@ -81,7 +81,7 @@ export default function Price () { return ( ) } diff --git a/fragments/items.js b/fragments/items.js index e131881a..8be4c001 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -30,6 +30,7 @@ export const ITEM_FIELDS = gql` name baseCost } + pollCost status uploadId mine @@ -93,6 +94,16 @@ export const ITEM_FULL = gql` meComments position text + poll { + meVoted + count + options { + id + option + count + meVoted + } + } comments { ...CommentsRecursive } diff --git a/lib/constants.js b/lib/constants.js index a45ff6cb..ae6e033e 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -11,3 +11,4 @@ export const UPLOAD_TYPES_ALLOW = [ ] export const COMMENT_DEPTH_LIMIT = 10 export const MAX_TITLE_LENGTH = 80 +export const MAX_POLL_CHOICE_LENGTH = 30 diff --git a/lib/format.js b/lib/format.js index 9a2ad2ca..4dfa0da6 100644 --- a/lib/format.js +++ b/lib/format.js @@ -5,3 +5,7 @@ export const formatSats = n => { if (n >= 1e9 && n < 1e12) return +(n / 1e9).toFixed(1) + 'b' if (n >= 1e12) return +(n / 1e12).toFixed(1) + 't' } + +export const fixedDecimal = (n, f) => { + return Number.parseFloat(n).toFixed(f) +} diff --git a/lib/time.js b/lib/time.js index b56b2f1c..bb06a02a 100644 --- a/lib/time.js +++ b/lib/time.js @@ -19,3 +19,25 @@ export function timeSince (timeStamp) { return 'now' } + +export function timeLeft (timeStamp) { + const now = new Date() + const secondsPast = (timeStamp - now.getTime()) / 1000 + + if (secondsPast < 0) { + return false + } + + if (secondsPast < 60) { + return parseInt(secondsPast) + 's' + } + if (secondsPast < 3600) { + return parseInt(secondsPast / 60) + 'm' + } + if (secondsPast <= 86400) { + return parseInt(secondsPast / 3600) + 'h' + } + if (secondsPast > 86400) { + return parseInt(secondsPast / (3600 * 24)) + ' days' + } +} diff --git a/pages/post.js b/pages/post.js index 041a7518..bd055936 100644 --- a/pages/post.js +++ b/pages/post.js @@ -6,6 +6,8 @@ import { useMe } from '../components/me' import { DiscussionForm } from '../components/discussion-form' import { LinkForm } from '../components/link-form' import { getGetServerSideProps } from '../api/ssrApollo' +import AccordianItem from '../components/accordian-item' +import { PollForm } from '../components/poll-form' export const getServerSideProps = getGetServerSideProps() @@ -16,6 +18,9 @@ export function PostForm () { if (!router.query.type) { return (
+ {me?.freePosts + ?
{me.freePosts} free posts left
+ : null} @@ -23,17 +28,27 @@ export function PostForm () { - {me?.freePosts - ?
{me.freePosts} free posts left
- : null} +
+ more
} + body={ + + + + } + /> +
) } if (router.query.type === 'discussion') { return - } else { + } else if (router.query.type === 'link') { return + } else { + return } } diff --git a/pages/users/forever.js b/pages/users/forever.js index aa2d3bfd..7c21f07f 100644 --- a/pages/users/forever.js +++ b/pages/users/forever.js @@ -97,6 +97,9 @@ const COLORS = [ ] function GrowthAreaChart ({ data, xName, title }) { + if (!data || data.length === 0) { + return null + } return ( 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 = 'BOOST' OR act = 'POLL' THEN + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (act_sats, item_id, user_id, act, now_utc(), now_utc()); + ELSE + -- 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; + END IF; + + RETURN 0; +END; +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 32980bb9..32cd8f11 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,8 +56,9 @@ model User { noteInvites Boolean @default(true) noteJobIndicator Boolean @default(true) - Earn Earn[] - Upload Upload[] @relation(name: "Uploads") + Earn Earn[] + Upload Upload[] @relation(name: "Uploads") + PollVote PollVote[] @@index([createdAt]) @@index([inviteId]) @@map(name: "users") @@ -180,7 +181,12 @@ model Item { longitude Float? remote Boolean? - User User[] + // fields for polls + pollCost Int? + + User User[] + PollOption PollOption[] + PollVote PollVote[] @@index([createdAt]) @@index([userId]) @@index([parentId]) @@ -192,10 +198,39 @@ model Item { @@index([path]) } +model PollOption { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at") + itemId Int + item Item @relation(fields: [itemId], references: [id]) + option String + + PollVote PollVote[] + @@index([itemId]) +} + +model PollVote { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at") + userId Int + user User @relation(fields: [userId], references: [id]) + itemId Int + item Item @relation(fields: [itemId], references: [id]) + pollOptionId Int + pollOption PollOption @relation(fields: [pollOptionId], references: [id]) + + @@unique([itemId, userId]) + @@index([userId]) + @@index([pollOptionId]) +} + enum PostType { LINK DISCUSSION JOB + POLL } enum RankingType { @@ -232,6 +267,7 @@ enum ItemActType { BOOST TIP STREAM + POLL } model ItemAct { diff --git a/svgs/add-fill.svg b/svgs/add-fill.svg new file mode 100644 index 00000000..3069852f --- /dev/null +++ b/svgs/add-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/bar-chart-horizontal-fill.svg b/svgs/bar-chart-horizontal-fill.svg new file mode 100644 index 00000000..0acc9efa --- /dev/null +++ b/svgs/bar-chart-horizontal-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/checkbox-circle-fill.svg b/svgs/checkbox-circle-fill.svg new file mode 100644 index 00000000..1ff6c04c --- /dev/null +++ b/svgs/checkbox-circle-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/worker/earn.js b/worker/earn.js index 7ad9db1c..1e7c892c 100644 --- a/worker/earn.js +++ b/worker/earn.js @@ -16,7 +16,7 @@ function earn ({ models }) { FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id WHERE ("ItemAct".act in ('BOOST', 'STREAM') - OR ("ItemAct".act = 'VOTE' AND "Item"."userId" = "ItemAct"."userId")) + OR ("ItemAct".act IN ('VOTE','POLL') AND "Item"."userId" = "ItemAct"."userId")) AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'` /* From 12fad3c45ac2a48524d66ade66c08bb3ebfdf04c Mon Sep 17 00:00:00 2001 From: keyan Date: Mon, 1 Aug 2022 15:48:28 -0500 Subject: [PATCH 05/64] fix genesis typo --- components/more-footer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/more-footer.js b/components/more-footer.js index 5aacaf5f..39c15921 100644 --- a/components/more-footer.js +++ b/components/more-footer.js @@ -28,7 +28,7 @@ export default function MoreFooter ({ cursor, fetchMore, Skeleton, noMoreText }) ) } else { Footer = () => ( -
{noMoreText || 'GENISIS'}
+
{noMoreText || 'GENESIS'}
) } From 7cf5396ef38919bcb0bf93291ba7448415b1f39a Mon Sep 17 00:00:00 2001 From: keyan Date: Sat, 6 Aug 2022 17:03:57 -0500 Subject: [PATCH 06/64] remove free comments --- .../20220806220043_no_free_comments/migration.sql | 2 ++ prisma/schema.prisma | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20220806220043_no_free_comments/migration.sql diff --git a/prisma/migrations/20220806220043_no_free_comments/migration.sql b/prisma/migrations/20220806220043_no_free_comments/migration.sql new file mode 100644 index 00000000..6545ec52 --- /dev/null +++ b/prisma/migrations/20220806220043_no_free_comments/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ALTER COLUMN "freeComments" SET DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 32cd8f11..c07c9b83 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,7 +32,7 @@ model User { bioId Int? msats Int @default(0) stackedMsats Int @default(0) - freeComments Int @default(5) + freeComments Int @default(0) freePosts Int @default(2) checkedNotesAt DateTime? tipDefault Int @default(10) @@ -59,6 +59,7 @@ model User { Earn Earn[] Upload Upload[] @relation(name: "Uploads") PollVote PollVote[] + @@index([createdAt]) @@index([inviteId]) @@map(name: "users") @@ -78,6 +79,7 @@ model Upload { userId Int User User[] + @@index([createdAt]) @@index([itemId]) @@index([userId]) @@ -187,6 +189,7 @@ model Item { User User[] PollOption PollOption[] PollVote PollVote[] + @@index([createdAt]) @@index([userId]) @@index([parentId]) @@ -207,6 +210,7 @@ model PollOption { option String PollVote PollVote[] + @@index([itemId]) } @@ -345,6 +349,7 @@ model Withdrawl { msatsFeePaid Int? status WithdrawlStatus? + @@index([createdAt]) @@index([userId]) } From ddb4a30c4b0cae36f940229960b7c900266bc383 Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 10 Aug 2022 10:06:31 -0500 Subject: [PATCH 07/64] spam fees --- api/resolvers/item.js | 31 ++++--- api/typeDefs/item.js | 1 + components/discussion-form.js | 19 ++++- components/fee-button.js | 64 ++++++++++++++ components/fee-button.module.css | 15 ++++ components/form.js | 10 ++- components/link-form.js | 13 ++- components/reply.js | 25 +++--- components/text.js | 5 ++ lib/constants.js | 1 + lib/md.js | 19 +++++ pages/[name]/index.js | 16 +++- .../20220810162813_item_spam/migration.sql | 5 ++ .../20220810203210_item_spam2/migration.sql | 83 +++++++++++++++++++ prisma/schema.prisma | 3 +- 15 files changed, 270 insertions(+), 40 deletions(-) create mode 100644 components/fee-button.js create mode 100644 components/fee-button.module.css create mode 100644 lib/md.js create mode 100644 prisma/migrations/20220810162813_item_spam/migration.sql create mode 100644 prisma/migrations/20220810203210_item_spam2/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 6dbfa555..5540ced2 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -4,7 +4,8 @@ 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 } from '../../lib/constants' +import { BOOST_MIN, ITEM_SPAM_INTERVAL } from '../../lib/constants' +import { mdHas } from '../../lib/md' async function comments (models, id, sort) { let orderBy @@ -68,6 +69,13 @@ function topClause (within) { export default { Query: { + itemRepetition: async (parent, { parentId }, { me, models }) => { + if (!me) return 0 + // how many of the parents starting at parentId belong to me + const [{ item_spam: count }] = await models.$queryRaw(`SELECT item_spam($1, $2, '${ITEM_SPAM_INTERVAL}')`, Number(parentId), Number(me.id)) + + return count + }, items: async (parent, { sub, sort, cursor, name, within }, { me, models }) => { const decodedCursor = decodeCursor(cursor) let items; let user; let pins; let subFull @@ -851,6 +859,10 @@ const updateItem = async (parent, { id, data }, { me, models }) => { throw new UserInputError('item can no longer be editted') } + if (data?.text && !old.paidImgLink && mdHas(data.text, ['link', 'image'])) { + throw new UserInputError('adding links or images on edit is not allowed yet') + } + const item = await models.item.update({ where: { id: Number(id) }, data @@ -878,21 +890,16 @@ const createItem = async (parent, { title, url, text, boost, forward, parentId } } } + const hasImgLink = mdHas(text, ['link', 'image']) + const [item] = await serialize(models, - models.$queryRaw(`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6) AS "Item"`, - title, url, text, Number(boost || 0), Number(parentId), Number(me.id))) + models.$queryRaw( + `${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`, + title, url, text, Number(boost || 0), Number(parentId), Number(me.id), + Number(fwdUser?.id), hasImgLink)) await createMentions(item, models) - if (fwdUser) { - await models.item.update({ - where: { id: item.id }, - data: { - fwdUserId: fwdUser.id - } - }) - } - item.comments = [] return item } diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 931435e3..9a743173 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -11,6 +11,7 @@ export default gql` allItems(cursor: String): Items search(q: String, sub: String, cursor: String): Items auctionPosition(sub: String, id: ID, bid: Int!): Int! + itemRepetition(parentId: ID): Int! } type ItemActResult { diff --git a/components/discussion-form.js b/components/discussion-form.js index 82a62374..43c1caf7 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -2,11 +2,12 @@ import { Form, Input, MarkdownInput, SubmitButton } from '../components/form' import { useRouter } from 'next/router' import * as Yup from 'yup' import { gql, useApolloClient, useMutation } from '@apollo/client' -import ActionTooltip from '../components/action-tooltip' 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 from './fee-button' export function DiscussionForm ({ item, editThreshold, titleLabel = 'title', @@ -15,6 +16,8 @@ export function DiscussionForm ({ }) { const router = useRouter() const client = useApolloClient() + const [hasImgLink, setHasImgLink] = useState() + // const me = useMe() const [upsertDiscussion] = useMutation( gql` mutation upsertDiscussion($id: ID, $title: String!, $text: String, $boost: Int, $forward: String) { @@ -31,6 +34,8 @@ export function DiscussionForm ({ ...AdvPostSchema(client) }) + // const cost = linkOrImg ? 10 : me?.freePosts ? 0 : 1 + return (
: null} + setHasImgLink={setHasImgLink} /> {!item && adv && } - - {item ? 'save' : buttonText} - +
+ {item + ? save + : } +
) } diff --git a/components/fee-button.js b/components/fee-button.js new file mode 100644 index 00000000..07426df8 --- /dev/null +++ b/components/fee-button.js @@ -0,0 +1,64 @@ +import { Table } from 'react-bootstrap' +import ActionTooltip from './action-tooltip' +import Info from './info' +import styles from './fee-button.module.css' +import { gql, useQuery } from '@apollo/client' +import { useFormikContext } from 'formik' + +function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { + return ( + + + + + + + {hasImgLink && + + + + } + {repetition > 0 && + + + + } + {boost > 0 && + + + + } + + + + + + + +
{baseFee} sats{parentId ? 'reply' : 'post'} fee
x 10image/link fee
x 10{repetition}{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m
+ {boost} satsboost
{cost} satstotal fee
+ ) +} + +export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow }) { + const query = parentId + ? gql`{ itemRepetition(parentId: "${parentId}") }` + : gql`{ itemRepetition }` + const { data } = useQuery(query, { pollInterval: 1000 }) + const repetition = data?.itemRepetition || 0 + const formik = useFormikContext() + const boost = formik?.values?.boost || 0 + const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost) + + const show = alwaysShow || !formik?.isSubmitting + return ( +
+ + {text}{cost > baseFee && show && {cost} sats} + + {cost > baseFee && show && + + + } +
+ ) +} diff --git a/components/fee-button.module.css b/components/fee-button.module.css new file mode 100644 index 00000000..87c27ed9 --- /dev/null +++ b/components/fee-button.module.css @@ -0,0 +1,15 @@ +.receipt { + background-color: var(--theme-inputBg); + max-width: 250px; + margin: auto; + table-layout: auto; + width: 100%; +} + +.receipt td { + padding: .25rem .1rem; +} + +.receipt tfoot { + border-top: 2px solid var(--theme-borderColor); +} \ No newline at end of file diff --git a/components/form.js b/components/form.js index 7373bbc3..b2a5e0ea 100644 --- a/components/form.js +++ b/components/form.js @@ -11,6 +11,7 @@ import Markdown from '../svgs/markdown-line.svg' import styles from './form.module.css' import Text from '../components/text' import AddIcon from '../svgs/add-fill.svg' +import { mdHas } from '../lib/md' export function SubmitButton ({ children, variant, value, onClick, ...props @@ -72,7 +73,7 @@ export function InputSkeleton ({ label, hint }) { ) } -export function MarkdownInput ({ label, topLevel, groupClassName, ...props }) { +export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, ...props }) { const [tab, setTab] = useState('write') const [, meta] = useField(props) @@ -99,7 +100,12 @@ export function MarkdownInput ({ label, topLevel, groupClassName, ...props }) {
{ + if (onChange) onChange(formik, e) + if (setHasImgLink) { + setHasImgLink(mdHas(e.target.value, ['link', 'image'])) + } + }} />
diff --git a/components/link-form.js b/components/link-form.js index e6c2e296..867b27b2 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -2,13 +2,13 @@ import { Form, Input, SubmitButton } from '../components/form' import { useRouter } from 'next/router' import * as Yup from 'yup' import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client' -import ActionTooltip from '../components/action-tooltip' import Countdown from './countdown' import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' import { ITEM_FIELDS } from '../fragments/items' import Item from './item' import AccordianItem from './accordian-item' import { MAX_TITLE_LENGTH } from '../lib/constants' +import FeeButton from './fee-button' // eslint-disable-next-line const URL = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i @@ -99,9 +99,14 @@ export function LinkForm ({ item, editThreshold }) { }} /> {!item && } - - {item ? 'save' : 'post'} - +
+ {item + ? save + : } +
{dupesData?.dupes?.length > 0 &&
{ setReply(replyOpen || !!localStorage.getItem('reply-' + parentId + '-' + 'text')) @@ -65,7 +65,7 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) { } ) - const cost = me?.freeComments ? 0 : Math.pow(10, meComments) + // const cost = me?.freeComments ? 0 : Math.pow(10, meComments) return (
@@ -91,6 +91,7 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) { } resetForm({ text: '' }) setReply(replyOpen || false) + setHasImgLink(false) }} storageKeyPrefix={'reply-' + parentId} > @@ -100,18 +101,16 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) { minRows={6} autoFocus={!replyOpen} required + setHasImgLink={setHasImgLink} hint={me?.freeComments ? {me.freeComments} free comments left : null} /> -
- - reply{cost > 1 && {cost} sats} - - {cost > 1 && ( - -
Multiple replies on the same level get pricier, but we still love your thoughts!
-
- )} -
+ {reply && +
+ +
}
diff --git a/components/text.js b/components/text.js index ba3f9c80..ec9cd7fb 100644 --- a/components/text.js +++ b/components/text.js @@ -82,6 +82,11 @@ export default function Text ({ topLevel, noFragments, nofollow, children }) { ) }, a: ({ node, href, children, ...props }) => { + if (children?.some(e => e?.props?.node?.tagName === 'img')) { + return <>{children} + } + + // map: fix any highlighted links children = children?.map(e => typeof e === 'string' ? reactStringReplace(e, /:high\[([^\]]+)\]/g, (match, i) => { diff --git a/lib/constants.js b/lib/constants.js index ae6e033e..f6fb514b 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -12,3 +12,4 @@ export const UPLOAD_TYPES_ALLOW = [ export const COMMENT_DEPTH_LIMIT = 10 export const MAX_TITLE_LENGTH = 80 export const MAX_POLL_CHOICE_LENGTH = 30 +export const ITEM_SPAM_INTERVAL = '10m' diff --git a/lib/md.js b/lib/md.js new file mode 100644 index 00000000..8c37bc64 --- /dev/null +++ b/lib/md.js @@ -0,0 +1,19 @@ +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { visit } from 'unist-util-visit' +import { gfm } from 'micromark-extension-gfm' + +export function mdHas (md, test) { + const tree = fromMarkdown(md, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()] + }) + + let found = false + visit(tree, test, () => { + found = true + return false + }) + + return found +} diff --git a/pages/[name]/index.js b/pages/[name]/index.js index 0a830a75..e88dafd2 100644 --- a/pages/[name]/index.js +++ b/pages/[name]/index.js @@ -8,12 +8,12 @@ import { useState } from 'react' import ItemFull from '../../components/item-full' import * as Yup from 'yup' import { Form, MarkdownInput, SubmitButton } from '../../components/form' -import ActionTooltip from '../../components/action-tooltip' import TextareaAutosize from 'react-textarea-autosize' import { useMe } from '../../components/me' import { USER_FULL } from '../../fragments/users' import { ITEM_FIELDS } from '../../fragments/items' import { getGetServerSideProps } from '../../api/ssrApollo' +import FeeButton from '../../components/fee-button' export const getServerSideProps = getGetServerSideProps(USER_FULL, null, data => !data.user) @@ -23,6 +23,8 @@ const BioSchema = Yup.object({ }) export function BioForm ({ handleSuccess, bio }) { + const [hasImgLink, setHasImgLink] = useState() + const [upsertBio] = useMutation( gql` ${ITEM_FIELDS} @@ -68,10 +70,16 @@ export function BioForm ({ handleSuccess, bio }) { name='bio' as={TextareaAutosize} minRows={6} + setHasImgLink={setHasImgLink} /> - - {bio?.text ? 'save' : 'create'} - +
+ {bio?.text + ? save + : } +
) diff --git a/prisma/migrations/20220810162813_item_spam/migration.sql b/prisma/migrations/20220810162813_item_spam/migration.sql new file mode 100644 index 00000000..1ebc30b7 --- /dev/null +++ b/prisma/migrations/20220810162813_item_spam/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "paidImgLink" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "users" ALTER COLUMN "freePosts" SET DEFAULT 0; \ No newline at end of file diff --git a/prisma/migrations/20220810203210_item_spam2/migration.sql b/prisma/migrations/20220810203210_item_spam2/migration.sql new file mode 100644 index 00000000..1b2652e6 --- /dev/null +++ b/prisma/migrations/20220810203210_item_spam2/migration.sql @@ -0,0 +1,83 @@ +CREATE OR REPLACE FUNCTION item_spam(parent_id INTEGER, user_id INTEGER, within INTERVAL) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + repeats INTEGER; + self_replies INTEGER; +BEGIN + SELECT count(*) INTO repeats + FROM "Item" + WHERE (parent_id IS NULL AND "parentId" IS NULL OR "parentId" = parent_id) + AND "userId" = user_id + AND created_at > now_utc() - within; + + IF parent_id IS NULL THEN + RETURN repeats; + END IF; + + WITH RECURSIVE base AS ( + SELECT "Item".id, "Item"."parentId", "Item"."userId" + FROM "Item" + WHERE id = parent_id AND "userId" = user_id AND created_at > now_utc() - within + UNION ALL + SELECT "Item".id, "Item"."parentId", "Item"."userId" + FROM base p + JOIN "Item" ON "Item".id = p."parentId" AND "Item"."userId" = p."userId" AND "Item".created_at > now_utc() - within) + SELECT count(*) INTO self_replies FROM base; + + RETURN repeats + self_replies; +END; +$$; + +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"; +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; + + INSERT INTO "Item" (title, url, text, "userId", "parentId", "fwdUserId", "paidImgLink", created_at, updated_at) + VALUES (title, url, text, user_id, parent_id, fwd_user_id, has_img_link, 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 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c07c9b83..75e8c3a4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,7 +33,7 @@ model User { msats Int @default(0) stackedMsats Int @default(0) freeComments Int @default(0) - freePosts Int @default(2) + freePosts Int @default(0) checkedNotesAt DateTime? tipDefault Int @default(10) pubkey String? @unique @@ -166,6 +166,7 @@ model Item { boost Int @default(0) uploadId Int? upload Upload? + paidImgLink Boolean @default(false) // if sub is null, this is the main sub sub Sub? @relation(fields: [subName], references: [name]) From 69d008283900fc01fd6bc24e8e8b78c634b7369b Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 11 Aug 2022 16:42:52 -0500 Subject: [PATCH 08/64] fix null boolean --- api/resolvers/item.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 5540ced2..7fa7de75 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -890,7 +890,7 @@ const createItem = async (parent, { title, url, text, boost, forward, parentId } } } - const hasImgLink = mdHas(text, ['link', 'image']) + const hasImgLink = !!(text && mdHas(text, ['link', 'image'])) const [item] = await serialize(models, models.$queryRaw( From 9b8b6078d68f94560b18a97c7093fa403bbbdfbc Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 12 Aug 2022 15:14:36 -0500 Subject: [PATCH 09/64] fix image distortion on certain browsers --- components/text.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/text.module.css b/components/text.module.css index 83f0c2a4..da89e20c 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -63,7 +63,7 @@ display: block; margin-top: .5rem; border-radius: .4rem; - width: min-content; + width: auto; max-width: 100%; } From 388c7d02406d23eb573ce24eb8fdac147d21bef9 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 18 Aug 2022 13:15:24 -0500 Subject: [PATCH 10/64] full powered editing --- api/resolvers/item.js | 89 +++++++++------ api/resolvers/user.js | 22 ++-- api/typeDefs/item.js | 1 + components/adv-post-form.js | 21 +++- components/comment-edit.js | 14 ++- components/discussion-form.js | 11 +- components/fee-button.js | 55 +++++++++ components/footer.js | 1 + components/form.js | 12 +- components/invite.js | 2 +- components/link-form.js | 11 +- components/lnqr.js | 2 +- components/poll-form.js | 36 ++++-- fragments/comments.js | 1 + fragments/items.js | 29 +++-- lib/constants.js | 1 + pages/[name]/index.js | 7 +- pages/items/[id]/edit.js | 7 +- pages/settings.js | 1 + pages/withdrawals/[id].js | 4 +- .../20220815195309_edit_funcs/migration.sql | 107 ++++++++++++++++++ 21 files changed, 334 insertions(+), 100 deletions(-) create mode 100644 prisma/migrations/20220815195309_edit_funcs/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 7fa7de75..f64f7ded 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -4,7 +4,7 @@ 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 } from '../../lib/constants' +import { BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES } from '../../lib/constants' import { mdHas } from '../../lib/md' async function comments (models, id, sort) { @@ -450,8 +450,7 @@ export default { data.url = ensureProtocol(data.url) if (id) { - const { forward, boost, ...remaining } = data - return await updateItem(parent, { id, data: remaining }, { me, models }) + return await updateItem(parent, { id, data }, { me, models }) } else { return await createItem(parent, data, { me, models }) } @@ -460,8 +459,7 @@ export default { const { id, ...data } = args if (id) { - const { forward, boost, ...remaining } = data - return await updateItem(parent, { id, data: remaining }, { me, models }) + return await updateItem(parent, { id, data }, { me, models }) } else { return await createItem(parent, data, { me, models }) } @@ -475,37 +473,43 @@ export default { throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' }) } - if (id) { - // TODO: this isn't ever called clientside, we edit like it's a discussion + let fwdUser + if (forward) { + fwdUser = await models.user.findUnique({ where: { name: forward } }) + if (!fwdUser) { + throw new UserInputError('forward user does not exist', { argumentName: 'forward' }) + } + } - const item = await models.item.update({ - where: { id: Number(id) }, - data: { title: title } + const hasImgLink = !!(text && mdHas(text, ['link', 'image'])) + + if (id) { + const optionCount = await models.pollOption.count({ + where: { + itemId: Number(id) + } }) - return item - } else { - let fwdUser - if (forward) { - fwdUser = await models.user.findUnique({ where: { name: forward } }) - if (!fwdUser) { - throw new UserInputError('forward user does not exist', { argumentName: 'forward' }) - } + if (options.length + optionCount > MAX_POLL_NUM_CHOICES) { + throw new UserInputError(`total choices must be <${MAX_POLL_NUM_CHOICES}`, { argumentName: 'options' }) } const [item] = await serialize(models, - models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6) AS "Item"`, - title, text, 1, Number(boost || 0), Number(me.id), options)) + 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)) - if (fwdUser) { - await models.item.update({ - where: { id: item.id }, - data: { - fwdUserId: fwdUser.id - } - }) + return item + } else { + if (options.length < 2 || options.length > MAX_POLL_NUM_CHOICES) { + throw new UserInputError(`choices must be >2 and <${MAX_POLL_NUM_CHOICES}`, { argumentName: 'options' }) } + 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)) + + await createMentions(item, models) + item.comments = [] return item } @@ -847,26 +851,37 @@ export const createMentions = async (item, models) => { } } -const updateItem = async (parent, { id, data }, { me, models }) => { +export const updateItem = async (parent, { id, data: { title, url, text, boost, forward, parentId } }, { me, models }) => { // update iff this item belongs to me 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') } - // if it's not the FAQ and older than 10 minutes - if (old.id !== 349 && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) { + // if it's not the FAQ, not their bio, and older than 10 minutes + const user = await models.user.findUnique({ where: { id: me.id } }) + if (old.id !== 349 && user.bioId !== id && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) { throw new UserInputError('item can no longer be editted') } - if (data?.text && !old.paidImgLink && mdHas(data.text, ['link', 'image'])) { - throw new UserInputError('adding links or images on edit is not allowed yet') + if (boost && boost < BOOST_MIN) { + throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' }) } - const item = await models.item.update({ - where: { id: Number(id) }, - data - }) + let fwdUser + if (forward) { + fwdUser = await models.user.findUnique({ where: { name: forward } }) + if (!fwdUser) { + throw new UserInputError('forward user does not exist', { argumentName: 'forward' }) + } + } + + 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)) await createMentions(item, models) @@ -934,7 +949,7 @@ 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", ltree2text("Item"."path") AS "path"` + "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item"."paidImgLink", ltree2text("Item"."path") AS "path"` function newTimedOrderByWeightedSats (num) { return ` diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 06207be1..ee71ca76 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -1,6 +1,7 @@ import { AuthenticationError, UserInputError } from 'apollo-server-errors' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' -import { createMentions, getItem, SELECT } from './item' +import { mdHas } from '../../lib/md' +import { createMentions, getItem, SELECT, updateItem } from './item' import serialize from './serial' export function topClause (within) { @@ -188,21 +189,16 @@ export default { const user = await models.user.findUnique({ where: { id: me.id } }) - let item if (user.bioId) { - item = await models.item.update({ - where: { id: Number(user.bioId) }, - data: { - text: bio - } - }) + await updateItem(parent, { id: user.bioId, data: { text: bio, title: `@${user.name}'s bio` } }, { me, models }) } else { - ([item] = await serialize(models, - models.$queryRaw(`${SELECT} FROM create_bio($1, $2, $3) AS "Item"`, - `@${user.name}'s bio`, bio, Number(me.id)))) - } + const hasImgLink = !!(bio && mdHas(bio, ['link', 'image'])) - await createMentions(item, models) + 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)) + await createMentions(item, models) + } return await models.user.findUnique({ where: { id: me.id } }) }, diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 9a743173..6ba733aa 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -76,6 +76,7 @@ export default gql` sats: Int! upvotes: Int! meSats: Int! + paidImgLink: Boolean meComments: Int! ncomments: Int! comments: [Item!]! diff --git a/components/adv-post-form.js b/components/adv-post-form.js index f2c91568..816d6ca4 100644 --- a/components/adv-post-form.js +++ b/components/adv-post-form.js @@ -9,7 +9,14 @@ import Info from './info' export function AdvPostSchema (client) { return { boost: Yup.number().typeError('must be a number') - .min(BOOST_MIN, `must be blank or at least ${BOOST_MIN}`).integer('must be whole'), + .min(BOOST_MIN, `must be blank or at least ${BOOST_MIN}`).integer('must be whole').test({ + name: 'boost', + test: async boost => { + if (!boost || boost % BOOST_MIN === 0) return true + return false + }, + message: `must be divisble be ${BOOST_MIN}` + }), forward: Yup.string() .test({ name: 'name', @@ -23,12 +30,14 @@ export function AdvPostSchema (client) { } } -export const AdvPostInitial = { - boost: '', - forward: '' +export function AdvPostInitial ({ forward }) { + return { + boost: '', + forward: forward || '' + } } -export default function AdvPostForm () { +export default function AdvPostForm ({ edit }) { return ( options
} @@ -36,7 +45,7 @@ export default function AdvPostForm () { <> boost +
{edit ? 'add boost' : 'boost'}
  1. Boost ranks posts higher temporarily based on the amount
  2. diff --git a/components/comment-edit.js b/components/comment-edit.js index a803f548..971fc609 100644 --- a/components/comment-edit.js +++ b/components/comment-edit.js @@ -3,17 +3,22 @@ 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({ text: Yup.string().required('required').trim() }) 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 } }) { @@ -22,6 +27,9 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc fields: { text () { return updateComment.text + }, + paidImgLink () { + return updateComment.paidImgLink } } }) @@ -51,9 +59,13 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc as={TextareaAutosize} minRows={6} autoFocus + setHasImgLink={setHasImgLink} required /> - save +
) diff --git a/components/discussion-form.js b/components/discussion-form.js index 43c1caf7..677fa585 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -7,7 +7,7 @@ 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 from './fee-button' +import FeeButton, { EditFeeButton } from './fee-button' export function DiscussionForm ({ item, editThreshold, titleLabel = 'title', @@ -41,7 +41,7 @@ export function DiscussionForm ({ initial={{ title: item?.title || '', text: item?.text || '', - ...AdvPostInitial + ...AdvPostInitial({ forward: item?.fwdUser?.name }) }} schema={DiscussionSchema} onSubmit={handleSubmit || (async ({ boost, ...values }) => { @@ -77,10 +77,13 @@ export function DiscussionForm ({ : null} setHasImgLink={setHasImgLink} /> - {!item && adv && } + {adv && }
{item - ? save + ? : ) } + +function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) { + return ( + + + {addImgLink && + <> + + + + + + + + + + + + + } + {boost > 0 && + + + + } + + + + + + + +
{paidSats} sats{parentId ? 'reply' : 'post'} fee
x 10image/link fee
- {paidSats} satsalready paid
+ {boost} satsboost
{cost} satstotal fee
+ ) +} + +export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton, variant, text, alwaysShow, parentId }) { + const formik = useFormikContext() + const boost = formik?.values?.boost || 0 + const addImgLink = hasImgLink && !hadImgLink + const cost = (addImgLink ? paidSats * 9 : 0) + Number(boost) + + const show = alwaysShow || !formik?.isSubmitting + return ( +
+ + {text}{cost > 0 && show && {cost} sats} + + {cost > 0 && show && + + + } +
+ ) +} diff --git a/components/footer.js b/components/footer.js index 10d1e675..37c6c295 100644 --- a/components/footer.js +++ b/components/footer.js @@ -173,6 +173,7 @@ export default function Footer ({ noLinks }) { size='sm' groupClassName='mb-0 w-100' readOnly + noForm placeholder={data.connectAddress} />
} diff --git a/components/form.js b/components/form.js index b2a5e0ea..c26aff8f 100644 --- a/components/form.js +++ b/components/form.js @@ -129,10 +129,10 @@ function FormGroup ({ className, label, children }) { function InputInner ({ prepend, append, hint, showValid, onChange, overrideValue, - innerRef, storageKeyPrefix, ...props + innerRef, storageKeyPrefix, noForm, ...props }) { - const [field, meta, helpers] = props.readOnly ? [{}, {}, {}] : useField(props) - const formik = props.readOnly ? null : useFormikContext() + const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props) + const formik = noForm ? null : useFormikContext() const storageKey = storageKeyPrefix ? storageKeyPrefix + '-' + props.name : undefined @@ -208,19 +208,19 @@ export function Input ({ label, groupClassName, ...props }) { ) } -export function VariableInput ({ label, groupClassName, name, hint, max, ...props }) { +export function VariableInput ({ label, groupClassName, name, hint, max, readOnlyLen, ...props }) { return ( {({ form, ...fieldArrayHelpers }) => { - const options = form.values.options + const options = form.values[name] return ( <> {options.map((_, i) => (
- 1 ? 'optional' : undefined} /> + 1 ? 'optional' : undefined} /> {options.length - 1 === i && options.length !== max ? fieldArrayHelpers.push('')} /> diff --git a/components/invite.js b/components/invite.js index af9e5212..c809819b 100644 --- a/components/invite.js +++ b/components/invite.js @@ -21,7 +21,7 @@ export default function Invite ({ invite, active }) {
{invite.gift} sat gift diff --git a/components/link-form.js b/components/link-form.js index 867b27b2..8dee34bf 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -8,7 +8,7 @@ import { ITEM_FIELDS } from '../fragments/items' import Item from './item' import AccordianItem from './accordian-item' import { MAX_TITLE_LENGTH } from '../lib/constants' -import FeeButton from './fee-button' +import FeeButton, { EditFeeButton } from './fee-button' // eslint-disable-next-line const URL = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i @@ -55,7 +55,7 @@ export function LinkForm ({ item, editThreshold }) { initial={{ title: item?.title || '', url: item?.url || '', - ...AdvPostInitial + ...AdvPostInitial({ forward: item?.fwdUser?.name }) }} schema={LinkSchema} onSubmit={async ({ boost, title, ...values }) => { @@ -98,10 +98,13 @@ export function LinkForm ({ item, editThreshold }) { }) }} /> - {!item && } +
{item - ? save + ? :
- +
diff --git a/components/poll-form.js b/components/poll-form.js index 766021e0..597f7bf5 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -2,15 +2,17 @@ import { Form, Input, MarkdownInput, SubmitButton, VariableInput } from '../comp import { useRouter } from 'next/router' import * as Yup from 'yup' import { gql, useApolloClient, useMutation } from '@apollo/client' -import ActionTooltip from '../components/action-tooltip' import Countdown from './countdown' import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' -import { MAX_TITLE_LENGTH, MAX_POLL_CHOICE_LENGTH } from '../lib/constants' +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` @@ -36,16 +38,19 @@ export function PollForm ({ item, editThreshold }) { ...AdvPostSchema(client) }) + const initialOptions = item?.poll?.options.map(i => i.option) + return (
{ - const optionsFiltered = options.filter(word => word.trim().length > 0) + const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0) const { error } = await upsertPoll({ variables: { id: item?.id, @@ -77,20 +82,29 @@ export function PollForm ({ item, editThreshold }) { name='text' as={TextareaAutosize} minRows={2} + setHasImgLink={setHasImgLink} />
: null} /> - {!item && } - - {item ? 'save' : 'post'} - - + +
+ {item + ? + : } +
) } diff --git a/fragments/comments.js b/fragments/comments.js index a116ca25..340b1e1c 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -17,6 +17,7 @@ export const COMMENT_FIELDS = gql` meComments path mine + paidImgLink ncomments root { id diff --git a/fragments/items.js b/fragments/items.js index 8be4c001..415058e0 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -34,6 +34,7 @@ export const ITEM_FIELDS = gql` status uploadId mine + paidImgLink root { id title @@ -64,12 +65,28 @@ export const ITEMS = gql` } }` +export const POLL_FIELDS = gql` + fragment PollFields on Item { + poll { + meVoted + count + options { + id + option + count + meVoted + } + } + }` + export const ITEM = gql` ${ITEM_FIELDS} + ${POLL_FIELDS} query Item($id: ID!) { item(id: $id) { ...ItemFields + ...PollFields text } }` @@ -86,6 +103,7 @@ export const COMMENTS_QUERY = gql` export const ITEM_FULL = gql` ${ITEM_FIELDS} + ${POLL_FIELDS} ${COMMENTS} query Item($id: ID!) { item(id: $id) { @@ -94,16 +112,7 @@ export const ITEM_FULL = gql` meComments position text - poll { - meVoted - count - options { - id - option - count - meVoted - } - } + ...PollFields comments { ...CommentsRecursive } diff --git a/lib/constants.js b/lib/constants.js index f6fb514b..bce39bdc 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -13,3 +13,4 @@ export const COMMENT_DEPTH_LIMIT = 10 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 diff --git a/pages/[name]/index.js b/pages/[name]/index.js index e88dafd2..5788b0e5 100644 --- a/pages/[name]/index.js +++ b/pages/[name]/index.js @@ -13,7 +13,7 @@ import { useMe } from '../../components/me' import { USER_FULL } from '../../fragments/users' import { ITEM_FIELDS } from '../../fragments/items' import { getGetServerSideProps } from '../../api/ssrApollo' -import FeeButton from '../../components/fee-button' +import FeeButton, { EditFeeButton } from '../../components/fee-button' export const getServerSideProps = getGetServerSideProps(USER_FULL, null, data => !data.user) @@ -74,7 +74,10 @@ export function BioForm ({ handleSuccess, bio }) { />
{bio?.text - ? save + ? : !data.item) @@ -16,8 +17,10 @@ export default function PostEdit ({ data: { item } }) { {item.maxBid ? : (item.url - ? - : )} + ? + : (item.pollCost + ? + : ))} ) } diff --git a/pages/settings.js b/pages/settings.js index 7105beae..9ecc16ed 100644 --- a/pages/settings.js +++ b/pages/settings.js @@ -241,6 +241,7 @@ function AuthMethods ({ methods }) { placeholder={methods.email} groupClassName='mb-0' readOnly + noForm />
sats} />
diff --git a/prisma/migrations/20220815195309_edit_funcs/migration.sql b/prisma/migrations/20220815195309_edit_funcs/migration.sql new file mode 100644 index 00000000..48606967 --- /dev/null +++ b/prisma/migrations/20220815195309_edit_funcs/migration.sql @@ -0,0 +1,107 @@ +CREATE OR REPLACE FUNCTION update_item(item_id INTEGER, + item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER, + fwd_user_id INTEGER, has_img_link BOOLEAN) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + prior_cost INTEGER; + prior_act_id INTEGER; + cost INTEGER; + item "Item"; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT * INTO item FROM "Item" WHERE id = item_id; + + -- if has_img_link we need to figure out new costs, which is their prior_cost * 9 + IF has_img_link AND NOT item."paidImgLink" THEN + SELECT sats * 1000, id INTO prior_cost, prior_act_id + FROM "ItemAct" + WHERE act = 'VOTE' AND "itemId" = item.id AND "userId" = item."userId"; + + cost := prior_cost * 9; + + IF cost > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + UPDATE users SET msats = msats - cost WHERE id = item."userId"; + + UPDATE "ItemAct" SET sats = (prior_cost + cost) / 1000 WHERE id = prior_act_id; + END IF; + + UPDATE "Item" set title = item_title, url = item_url, text = item_text, "fwdUserId" = fwd_user_id, "paidImgLink" = has_img_link + 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; +$$; + +CREATE OR REPLACE FUNCTION 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) +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, has_img_link, 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; +$$; + +CREATE OR REPLACE FUNCTION update_poll( + id INTEGER, title TEXT, text TEXT, boost INTEGER, + options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN) +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, has_img_link); + + 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; +$$; + +CREATE OR REPLACE FUNCTION create_bio(title TEXT, text TEXT, user_id INTEGER, has_img_link BOOLEAN) +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, has_img_link, '0'); + + UPDATE users SET "bioId" = item.id WHERE id = user_id; + + RETURN item; +END; +$$; \ No newline at end of file From 4d444d1286c432700756c168ea8a9aa821dff07f Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 18 Aug 2022 14:19:01 -0500 Subject: [PATCH 11/64] fix notification spacing --- components/comment.module.css | 1 + components/item-job.js | 2 +- components/item.module.css | 2 +- styles/invites.module.css | 8 ++++---- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/components/comment.module.css b/components/comment.module.css index 6e80226f..ea9d316d 100644 --- a/components/comment.module.css +++ b/components/comment.module.css @@ -1,6 +1,7 @@ .item { align-items: flex-start; margin-bottom: 0 !important; + padding-bottom: 0 !important; } .upvote { diff --git a/components/item-job.js b/components/item-job.js index 4f3fd0ea..5c55fc36 100644 --- a/components/item-job.js +++ b/components/item-job.js @@ -14,7 +14,7 @@ export default function ItemJob ({ item, toc, rank, children }) { <> {rank ? ( -
+
{rank}
) :
} diff --git a/components/item.module.css b/components/item.module.css index 2f6e3c75..15e08fde 100644 --- a/components/item.module.css +++ b/components/item.module.css @@ -47,7 +47,7 @@ a.link:visited { display: flex; justify-content: flex-start; min-width: 0; - margin-bottom: .45rem; + padding-bottom: .45rem; } .item .companyImage { diff --git a/styles/invites.module.css b/styles/invites.module.css index d7fd2031..1709b9c5 100644 --- a/styles/invites.module.css +++ b/styles/invites.module.css @@ -19,9 +19,9 @@ } .invite { - margin-bottom: .5rem; -}Í + padding-bottom: .5rem; +} -.invite:last-child { - margin-bottom: 0; +.invites .invite:last-child { + padding-bottom: 0; } \ No newline at end of file From 03723bd5ee71438b2aab18811256cfa1de2de127 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 18 Aug 2022 15:57:48 -0500 Subject: [PATCH 12/64] fix bad earn link it satistics --- pages/satistics.js | 30 +++++++++++++++++++----------- worker/earn.js | 2 +- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/pages/satistics.js b/pages/satistics.js index 66f80dde..b4decfb7 100644 --- a/pages/satistics.js +++ b/pages/satistics.js @@ -14,6 +14,7 @@ import { Checkbox, Form } from '../components/form' import { useRouter } from 'next/router' import Item from '../components/item' import Comment from '../components/comment' +import React from 'react' export const getServerSideProps = getGetServerSideProps(WALLET_HISTORY) @@ -142,6 +143,8 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor case 'withdrawal': case 'invoice': return `/${fact.type}s/${fact.factId}` + case 'earn': + return default: return `/items/${fact.factId}` } @@ -200,17 +203,22 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor - {facts.map((f, i) => ( - - - {f.type} - - - - {f.msats / 1000} - - - ))} + {facts.map((f, i) => { + const uri = href(f) + const Wrapper = uri ? Link : React.Fragment + + return ( + + + {f.type} + + + + {Math.floor(f.msats / 1000)} + + + ) + })} diff --git a/worker/earn.js b/worker/earn.js index 1e7c892c..871a95f0 100644 --- a/worker/earn.js +++ b/worker/earn.js @@ -34,7 +34,7 @@ function earn ({ models }) { const earners = await models.$queryRaw(` WITH item_ratios AS ( SELECT *, - "weightedVotes"/(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL)) AS ratio + "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 a714a13029c9f89c8f920d50f79c84d1eca7f9a6 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 18 Aug 2022 16:19:37 -0500 Subject: [PATCH 13/64] fix nested anchor in navbar error --- components/header.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/header.js b/components/header.js index 85603c2a..af513114 100644 --- a/components/header.js +++ b/components/header.js @@ -65,7 +65,7 @@ export default function Header ({ sub }) { - e.preventDefault()}>{`@${me?.name}`} + e.preventDefault()}>{`@${me?.name}`} } alignRight > From db953bd41b9ace865ff1cf387eb269e4103a481d Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 18 Aug 2022 16:23:12 -0500 Subject: [PATCH 14/64] link to weekly stats rather than forever --- components/footer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/footer.js b/components/footer.js index 37c6c295..b7a98632 100644 --- a/components/footer.js +++ b/components/footer.js @@ -96,7 +96,7 @@ const AnalyticsPopover = ( visitors \ - + users From ccb5a81dd575dcef19f68b197b9d28b3c9ec7e7e Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 18 Aug 2022 17:05:58 -0500 Subject: [PATCH 15/64] add recent comments --- api/resolvers/item.js | 9 +++++++++ pages/recent/comments.js | 18 ++++++++++++++++++ pages/{recent.js => recent/index.js} | 8 ++++---- 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 pages/recent/comments.js rename pages/{recent.js => recent/index.js} (62%) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index f64f7ded..8ea43d4a 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -236,6 +236,15 @@ export default { let comments, user switch (sort) { + case 'recent': + comments = await models.$queryRaw(` + ${SELECT} + FROM "Item" + WHERE "parentId" IS NOT NULL AND created_at <= $1 + ORDER BY created_at DESC + OFFSET $2 + LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + break case 'user': if (!name) { throw new UserInputError('must supply name', { argumentName: 'name' }) diff --git a/pages/recent/comments.js b/pages/recent/comments.js new file mode 100644 index 00000000..f2179b94 --- /dev/null +++ b/pages/recent/comments.js @@ -0,0 +1,18 @@ +import Layout from '../../components/layout' +import { getGetServerSideProps } from '../../api/ssrApollo' +import { MORE_FLAT_COMMENTS } from '../../fragments/comments' +import CommentsFlat from '../../components/comments-flat' + +const variables = { sort: 'recent' } +export const getServerSideProps = getGetServerSideProps(MORE_FLAT_COMMENTS, variables) + +export default function Index ({ data: { moreFlatComments: { comments, cursor } } }) { + return ( + + + + ) +} diff --git a/pages/recent.js b/pages/recent/index.js similarity index 62% rename from pages/recent.js rename to pages/recent/index.js index 1c8f0b25..e1db1e7c 100644 --- a/pages/recent.js +++ b/pages/recent/index.js @@ -1,7 +1,7 @@ -import Layout from '../components/layout' -import Items from '../components/items' -import { getGetServerSideProps } from '../api/ssrApollo' -import { ITEMS } from '../fragments/items' +import Layout from '../../components/layout' +import Items from '../../components/items' +import { getGetServerSideProps } from '../../api/ssrApollo' +import { ITEMS } from '../../fragments/items' const variables = { sort: 'recent' } export const getServerSideProps = getGetServerSideProps(ITEMS, variables) From fba0187b2cfc49684ec956ac88df4cc70332c337 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 23 Aug 2022 17:34:51 -0500 Subject: [PATCH 16/64] add link to recent/comments --- components/header.js | 28 +++++++++++++++------------- components/recent-header.js | 35 +++++++++++++++++++++++++++++++++++ package.json | 4 ++-- pages/recent/comments.js | 2 ++ pages/recent/index.js | 2 ++ 5 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 components/recent-header.js diff --git a/components/header.js b/components/header.js index af513114..66593564 100644 --- a/components/header.js +++ b/components/header.js @@ -27,6 +27,8 @@ export default function Header ({ sub }) { const [fired, setFired] = useState() const me = useMe() const prefix = sub ? `/~${sub}` : '' + // there's always at least 2 on the split, e.g. '/' yields ['',''] + const topNavKey = path.split('/')[sub ? 2 : 1] const { data: subLatestPost } = useQuery(gql` query subLatestPost($name: ID!) { subLatestPost(name: $name) @@ -53,7 +55,7 @@ export default function Header ({ sub }) { - + {me?.hasNewNotes && @@ -65,12 +67,12 @@ export default function Header ({ sub }) { - e.preventDefault()}>{`@${me?.name}`} + e.preventDefault()}>{`@${me?.name}`} } alignRight > - + profile {me && !me.bioId &&
@@ -79,14 +81,14 @@ export default function Header ({ sub }) { - wallet + wallet - satistics + satistics - invites + invites {me && !me.hasInvites &&
{' '} @@ -96,7 +98,7 @@ export default function Header ({ sub }) {
- settings + settings
@@ -110,7 +112,7 @@ export default function Header ({ sub }) { {me && - + }
@@ -132,13 +134,13 @@ export default function Header ({ sub }) { <> - recent + recent {!prefix && - top + top } @@ -157,7 +159,7 @@ export default function Header ({ sub }) { {me && - post + post } @@ -170,7 +172,7 @@ export default function Header ({ sub }) {
)} {item.comments &&
- +
} ) diff --git a/fragments/items.js b/fragments/items.js index 415058e0..c3981a11 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -22,6 +22,8 @@ export const ITEM_FIELDS = gql` path meSats ncomments + commentSats + lastCommentAt maxBid company location From f65f6c1b28cc5ee54d91ed40b7caa387fafdeee6 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 1 Sep 2022 16:20:20 -0500 Subject: [PATCH 30/64] remove meComments and clean up sats title --- api/resolvers/item.js | 5 ----- api/typeDefs/item.js | 1 - components/comment.js | 4 ++-- components/item-full.js | 4 ++-- components/item.js | 2 +- components/reply.js | 7 +------ fragments/comments.js | 1 - fragments/items.js | 2 -- 8 files changed, 6 insertions(+), 20 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 6a930e37..c4b01830 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -768,11 +768,6 @@ export default { return sats || 0 }, - meComments: async (item, args, { me, models }) => { - if (!me) return 0 - - return await models.item.count({ where: { userId: me.id, parentId: item.id } }) - }, mine: async (item, args, { me, models }) => { return me?.id === item.userId }, diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 1c6cb937..274b3b36 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -79,7 +79,6 @@ export default gql` upvotes: Int! meSats: Int! paidImgLink: Boolean - meComments: Int! ncomments: Int! comments: [Item!]! path: String diff --git a/components/comment.js b/components/comment.js index db840e80..a258e3a4 100644 --- a/components/comment.js +++ b/components/comment.js @@ -109,7 +109,7 @@ export default function Comment ({
- {item.sats} sats + {item.sats} sats \ {item.boost > 0 && <> @@ -186,7 +186,7 @@ export default function Comment ({
{!noReply && } {children}
diff --git a/components/item-full.js b/components/item-full.js index bf7ce2f3..30ce000f 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -31,7 +31,7 @@ function BioItem ({ item, handleClick }) { >edit bio
} - + ) } @@ -89,7 +89,7 @@ function TopLevelItem ({ item, noReply, ...props }) { {item.text && } {item.url && } {item.poll && } - {!noReply && } + {!noReply && } ) } diff --git a/components/item.js b/components/item.js index c9bf3ffe..0d57e74a 100644 --- a/components/item.js +++ b/components/item.js @@ -73,7 +73,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
{!item.position && <> - {item.sats} sats + {item.sats} sats \ } {item.boost > 0 && diff --git a/components/reply.js b/components/reply.js index d97d2c07..9eee8796 100644 --- a/components/reply.js +++ b/components/reply.js @@ -21,7 +21,7 @@ export function ReplyOnAnotherPage ({ parentId }) { ) } -export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) { +export default function Reply ({ parentId, onSuccess, replyOpen }) { const [reply, setReply] = useState(replyOpen) const me = useMe() const [hasImgLink, setHasImgLink] = useState() @@ -55,9 +55,6 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) { }, ncomments (existingNComments = 0) { return existingNComments + 1 - }, - meComments (existingMeComments = 0) { - return existingMeComments + 1 } } }) @@ -65,8 +62,6 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) { } ) - // const cost = me?.freeComments ? 0 : Math.pow(10, meComments) - return (
{replyOpen diff --git a/fragments/comments.js b/fragments/comments.js index 340b1e1c..58793a99 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -14,7 +14,6 @@ export const COMMENT_FIELDS = gql` upvotes boost meSats - meComments path mine paidImgLink diff --git a/fragments/items.js b/fragments/items.js index c3981a11..3196d896 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -111,7 +111,6 @@ export const ITEM_FULL = gql` item(id: $id) { ...ItemFields prior - meComments position text ...PollFields @@ -127,7 +126,6 @@ export const ITEM_WITH_COMMENTS = gql` fragment ItemWithComments on Item { ...ItemFields text - meComments comments { ...CommentsRecursive } From 04d7e9c0ee69f7725a8fe256654fcef83983edbf Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 1 Sep 2022 16:53:39 -0500 Subject: [PATCH 31/64] update cache of ancestors on comment and upvote --- components/comment.js | 2 +- components/item-full.js | 4 ++-- components/reply.js | 18 ++++++++++++++---- components/upvote.js | 13 +++++++++++++ 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/components/comment.js b/components/comment.js index a258e3a4..03819801 100644 --- a/components/comment.js +++ b/components/comment.js @@ -186,7 +186,7 @@ export default function Comment ({
{!noReply && } {children}
diff --git a/components/item-full.js b/components/item-full.js index 30ce000f..562d9e1a 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -31,7 +31,7 @@ function BioItem ({ item, handleClick }) { >edit bio
} - + ) } @@ -89,7 +89,7 @@ function TopLevelItem ({ item, noReply, ...props }) { {item.text && } {item.url && } {item.poll && } - {!noReply && } + {!noReply && } ) } diff --git a/components/reply.js b/components/reply.js index 9eee8796..4239e698 100644 --- a/components/reply.js +++ b/components/reply.js @@ -21,10 +21,11 @@ export function ReplyOnAnotherPage ({ parentId }) { ) } -export default function Reply ({ parentId, onSuccess, replyOpen }) { +export default function Reply ({ item, onSuccess, replyOpen }) { const [reply, setReply] = useState(replyOpen) const me = useMe() const [hasImgLink, setHasImgLink] = useState() + const parentId = item.id useEffect(() => { setReply(replyOpen || !!localStorage.getItem('reply-' + parentId + '-' + 'text')) @@ -52,12 +53,21 @@ export default function Reply ({ parentId, onSuccess, replyOpen }) { fragmentName: 'CommentsRecursive' }) return [newCommentRef, ...existingCommentRefs] - }, - ncomments (existingNComments = 0) { - return existingNComments + 1 } } }) + + // update all ancestors + item.path.split('.').forEach(id => { + cache.modify({ + id: `Item:${id}`, + fields: { + ncomments (existingNComments = 0) { + return existingNComments + 1 + } + } + }) + }) } } ) diff --git a/components/upvote.js b/components/upvote.js index 70ecfb2a..f1c44404 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -135,6 +135,19 @@ export default function UpVote ({ item, className }) { } } }) + + // update all ancestors + item.path.split('.').forEach(id => { + if (Number(id) === Number(item.id)) return + cache.modify({ + id: `Item:${id}`, + fields: { + commentSats (existingCommentSats = 0) { + return existingCommentSats + sats + } + } + }) + }) } } ) From 0f5fc3180339b06dfade2c41fe2314acd848d0cb Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 2 Sep 2022 08:19:25 -0500 Subject: [PATCH 32/64] show comment sats on hover --- components/comment.js | 2 +- components/item.js | 2 +- fragments/comments.js | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/components/comment.js b/components/comment.js index 03819801..f8bca31a 100644 --- a/components/comment.js +++ b/components/comment.js @@ -117,7 +117,7 @@ export default function Comment ({ \ } - {item.ncomments} replies + {item.ncomments} replies \ diff --git a/components/item.js b/components/item.js index 0d57e74a..a4b7c31c 100644 --- a/components/item.js +++ b/components/item.js @@ -82,7 +82,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) { \ } - {item.ncomments} comments + {item.ncomments} comments \ diff --git a/fragments/comments.js b/fragments/comments.js index 58793a99..d73fa49f 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -15,6 +15,7 @@ export const COMMENT_FIELDS = gql` boost meSats path + commentSats mine paidImgLink ncomments From 297270f34d5c678cad005d1fd5a660ce1a3518aa Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 2 Sep 2022 11:53:44 -0500 Subject: [PATCH 33/64] inform user when there are new comments --- components/header.js | 5 ++++- components/item-full.js | 7 ++++++- components/item.js | 13 ++++++++++++- components/item.module.css | 5 +++++ components/reply.js | 10 +++++++++- lib/new-comments.js | 28 ++++++++++++++++++++++++++++ 6 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 lib/new-comments.js diff --git a/components/header.js b/components/header.js index 66593564..30316f75 100644 --- a/components/header.js +++ b/components/header.js @@ -129,6 +129,9 @@ export default function Header ({ sub }) { } } + const showJobIndicator = sub !== 'jobs' && (!me || me.noteJobIndicator) && + (!lastCheckedJobs || lastCheckedJobs < subLatestPost?.subLatestPost) + const NavItems = ({ className }) => { return ( <> @@ -150,7 +153,7 @@ export default function Header ({ sub }) { jobs - {sub !== 'jobs' && (!me || me.noteJobIndicator) && (!lastCheckedJobs || lastCheckedJobs < subLatestPost?.subLatestPost) && + {showJobIndicator && {' '} } diff --git a/components/item-full.js b/components/item-full.js index 562d9e1a..60f6c1f2 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -11,8 +11,9 @@ import { Button } from 'react-bootstrap' import { TwitterTweetEmbed } from 'react-twitter-embed' import YouTube from 'react-youtube' import useDarkMode from 'use-dark-mode' -import { useState } from 'react' +import { useEffect, useState } from 'react' import Poll from './poll' +import { commentsViewed } from '../lib/new-comments' function BioItem ({ item, handleClick }) { const me = useMe() @@ -99,6 +100,10 @@ function ItemText ({ item }) { } export default function ItemFull ({ item, bio, ...props }) { + useEffect(() => { + commentsViewed(item) + }, [item.lastCommentAt]) + return ( <> {item.parentId diff --git a/components/item.js b/components/item.js index a4b7c31c..f41559c2 100644 --- a/components/item.js +++ b/components/item.js @@ -9,6 +9,8 @@ import Pin from '../svgs/pushpin-fill.svg' import reactStringReplace from 'react-string-replace' 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' export function SearchTitle ({ title }) { return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { @@ -34,6 +36,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) { useState(mine && (Date.now() < editThreshold)) const [wrap, setWrap] = useState(false) const titleRef = useRef() + const [hasNewComments, setHasNewComments] = useState(false) useEffect(() => { setWrap( @@ -41,6 +44,11 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) { titleRef.current.clientHeight) }, []) + useEffect(() => { + // if we are showing toc, then this is a full item + setHasNewComments(!toc && newComments(item)) + }, [item]) + return ( <> {rank @@ -82,7 +90,10 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) { \ } - {item.ncomments} comments + + {item.ncomments} comments + {hasNewComments && <>{' '}new} + \ diff --git a/components/item.module.css b/components/item.module.css index 75fe64ab..41b8515d 100644 --- a/components/item.module.css +++ b/components/item.module.css @@ -20,6 +20,11 @@ a.title:visited { flex: 1 0 128px; } +.newComment { + color: var(--theme-grey) !important; + background: var(--theme-clickToContextColor) !important; +} + .pin { fill: #a5a5a5; margin-right: .2rem; diff --git a/components/reply.js b/components/reply.js index 4239e698..44fade81 100644 --- a/components/reply.js +++ b/components/reply.js @@ -8,6 +8,7 @@ import TextareaAutosize from 'react-textarea-autosize' import { useEffect, useState } from 'react' import Link from 'next/link' import FeeButton from './fee-button' +import { commentsViewedAfterComment } from '../lib/new-comments' export const CommentSchema = Yup.object({ text: Yup.string().required('required').trim() @@ -57,8 +58,10 @@ export default function Reply ({ item, onSuccess, replyOpen }) { } }) + const ancestors = item.path.split('.') + // update all ancestors - item.path.split('.').forEach(id => { + ancestors.forEach(id => { cache.modify({ id: `Item:${id}`, fields: { @@ -68,6 +71,11 @@ export default function Reply ({ item, onSuccess, replyOpen }) { } }) }) + + // so that we don't see indicator for our own comments, we record this comments as the latest time + // but we also have record num comments, in case someone else commented when we did + const root = ancestors[0] + commentsViewedAfterComment(root, createComment.createdAt) } } ) diff --git a/lib/new-comments.js b/lib/new-comments.js new file mode 100644 index 00000000..9e63ad86 --- /dev/null +++ b/lib/new-comments.js @@ -0,0 +1,28 @@ +const COMMENTS_VIEW_PREFIX = 'commentsViewedAt' +const COMMENTS_NUM_PREFIX = 'commentsViewNum' + +export function commentsViewed (item) { + if (!item.parentId && item.lastCommentAt) { + localStorage.setItem(`${COMMENTS_VIEW_PREFIX}:${item.id}`, new Date(item.lastCommentAt).getTime()) + localStorage.setItem(`${COMMENTS_NUM_PREFIX}:${item.id}`, item.ncomments) + } +} + +export function commentsViewedAfterComment (rootId, createdAt) { + localStorage.setItem(`${COMMENTS_VIEW_PREFIX}:${rootId}`, new Date(createdAt).getTime()) + const existingRootComments = localStorage.getItem(`${COMMENTS_NUM_PREFIX}:${rootId}`) || 0 + localStorage.setItem(`${COMMENTS_NUM_PREFIX}:${rootId}`, existingRootComments + 1) +} + +export function newComments (item) { + if (!item.parentId) { + const commentsViewedAt = localStorage.getItem(`${COMMENTS_VIEW_PREFIX}:${item.id}`) + const commentsViewNum = localStorage.getItem(`${COMMENTS_NUM_PREFIX}:${item.id}`) + + if (commentsViewedAt && commentsViewNum) { + return commentsViewedAt < new Date(item.lastCommentAt).getTime() || commentsViewNum < item.ncomments + } + } + + return false +} From 58eb840adbe39bb77edfe65dd82e2574e9ce7df5 Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 2 Sep 2022 11:58:16 -0500 Subject: [PATCH 34/64] prevent default on privacy invoice info click --- components/info.js | 8 +++++++- pages/settings.js | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/components/info.js b/components/info.js index a40928e2..ed715f46 100644 --- a/components/info.js +++ b/components/info.js @@ -16,7 +16,13 @@ export default function Info ({ children }) { {children} - setInfo(true)} /> + { + e.preventDefault() + setInfo(true) + }} + /> ) } diff --git a/pages/settings.js b/pages/settings.js index 3ce803e4..1a8382e7 100644 --- a/pages/settings.js +++ b/pages/settings.js @@ -118,9 +118,9 @@ export default function Settings ({ data: { settings } }) {
  • Use this if you don't want funding sources to be linkable to your SN identity.
  • It makes your invoice descriptions blank.
  • -
  • This only applies invoices you create +
  • This only applies to invoices you create
      -
    • lnurl-pay or lightning addresses still reference your nym
    • +
    • lnurl-pay and lightning addresses still reference your nym
From 89a57749d4ca81bcdf59609cb994d70296db95ab Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 2 Sep 2022 14:05:44 -0500 Subject: [PATCH 35/64] fix ambiguous sats reference --- api/resolvers/growth.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/api/resolvers/growth.js b/api/resolvers/growth.js index 84b1ddcb..3b03b385 100644 --- a/api/resolvers/growth.js +++ b/api/resolvers/growth.js @@ -33,10 +33,10 @@ export default { return await models.$queryRaw( `SELECT date_trunc('month', "ItemAct".created_at) AS time, - sum(CASE WHEN act = 'STREAM' THEN sats ELSE 0 END) as jobs, - sum(CASE WHEN act IN ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END) as fees, - sum(CASE WHEN act = 'BOOST' THEN sats ELSE 0 END) as boost, - sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END) as tips + sum(CASE WHEN act = 'STREAM' THEN "ItemAct".sats ELSE 0 END) as jobs, + sum(CASE WHEN act IN ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN "ItemAct".sats ELSE 0 END) as fees, + sum(CASE WHEN act = 'BOOST' THEN "ItemAct".sats ELSE 0 END) as boost, + sum(CASE WHEN act = 'TIP' THEN "ItemAct".sats ELSE 0 END) as tips FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id WHERE date_trunc('month', now_utc()) <> date_trunc('month', "ItemAct".created_at) @@ -63,8 +63,8 @@ export default { `SELECT time, sum(airdrop) as rewards, sum(post) as posts, sum(comment) as comments FROM ((SELECT date_trunc('month', "ItemAct".created_at) AS time, 0 as airdrop, - CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE sats END as comment, - CASE WHEN "Item"."parentId" IS NULL THEN sats ELSE 0 END as post + CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".sats END as comment, + CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".sats ELSE 0 END as post FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId" WHERE date_trunc('month', now_utc()) <> date_trunc('month', "ItemAct".created_at) AND @@ -121,10 +121,10 @@ export default { spentWeekly: async (parent, args, { models }) => { const [stats] = await models.$queryRaw( `SELECT json_build_array( - json_build_object('name', 'jobs', 'value', sum(CASE WHEN act = 'STREAM' THEN sats ELSE 0 END)), - json_build_object('name', 'fees', 'value', sum(CASE WHEN act in ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN sats ELSE 0 END)), - json_build_object('name', 'boost', 'value', sum(CASE WHEN act = 'BOOST' THEN sats ELSE 0 END)), - json_build_object('name', 'tips', 'value', sum(CASE WHEN act = 'TIP' THEN sats ELSE 0 END))) as array + json_build_object('name', 'jobs', 'value', sum(CASE WHEN act = 'STREAM' THEN "ItemAct".sats ELSE 0 END)), + json_build_object('name', 'fees', 'value', sum(CASE WHEN act in ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN "ItemAct".sats ELSE 0 END)), + json_build_object('name', 'boost', 'value', sum(CASE WHEN act = 'BOOST' THEN "ItemAct".sats ELSE 0 END)), + json_build_object('name', 'tips', 'value', sum(CASE WHEN act = 'TIP' THEN "ItemAct".sats ELSE 0 END))) as array FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id WHERE "ItemAct".created_at >= now_utc() - interval '1 week'`) @@ -140,8 +140,8 @@ export default { ) as array FROM ((SELECT 0 as airdrop, - CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE sats END as comment, - CASE WHEN "Item"."parentId" IS NULL THEN sats ELSE 0 END as post + CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".sats END as comment, + CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".sats ELSE 0 END as post FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId" WHERE "ItemAct".created_at >= now_utc() - interval '1 week' AND From 1a0803a594392226a5366a716a5c9e23887d4182 Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 2 Sep 2022 18:01:58 -0500 Subject: [PATCH 36/64] add new columns to search --- worker/search.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/worker/search.js b/worker/search.js index a2a72745..7282f06d 100644 --- a/worker/search.js +++ b/worker/search.js @@ -25,6 +25,8 @@ const ITEM_SEARCH_FIELDS = gql` upvotes sats boost + lastCommentAt + commentSats path ncomments }` From 7efc86427d67820c66eb8e4e4edd9cca5fe975cb Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 6 Sep 2022 08:01:49 -0500 Subject: [PATCH 37/64] maintage pagination and other state on back button --- api/resolvers/user.js | 4 +-- api/ssrApollo.js | 19 ++++++++++-- api/typeDefs/user.js | 2 +- components/user-header.js | 4 ++- fragments/users.js | 32 +++++++++++++++++-- lib/apollo.js | 2 +- pages/[name]/posts.js | 2 +- pages/_app.js | 65 +++++++++++++++++++++++++++++++-------- pages/settings.js | 24 ++++++++------- 9 files changed, 117 insertions(+), 37 deletions(-) diff --git a/api/resolvers/user.js b/api/resolvers/user.js index e886e28a..222e9df0 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -169,9 +169,7 @@ export default { throw new AuthenticationError('you must be logged in') } - await models.user.update({ where: { id: me.id }, data }) - - return true + return await models.user.update({ where: { id: me.id }, data }) }, setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => { if (!me) { diff --git a/api/ssrApollo.js b/api/ssrApollo.js index ca9acb3c..d96f5ab8 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -35,8 +35,20 @@ export default async function getSSRApolloClient (req, me = null) { export function getGetServerSideProps (query, variables = null, notFoundFunc, requireVar) { return async function ({ req, query: params }) { - const client = await getSSRApolloClient(req) - const vars = { ...params, ...variables } + const { nodata, ...realParams } = params + const vars = { ...realParams, ...variables } + + // we want to use client-side cache + if (nodata && query) { + return { + props: { + apollo: { + query: print(query), + variables: vars + } + } + } + } if (requireVar && !vars[requireVar]) { return { @@ -44,6 +56,7 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re } } + const client = await getSSRApolloClient(req) let error = null; let data = null; let props = {} if (query) { ({ error, data } = await client.query({ @@ -60,7 +73,7 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re props = { apollo: { query: print(query), - variables: { ...params, ...variables } + variables: vars } } } diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 9c89b22b..86fde278 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!): Boolean + noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!): User setPhoto(photoId: ID!): Int! upsertBio(bio: String!): User! setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean diff --git a/components/user-header.js b/components/user-header.js index 525b0fb9..8e2d6885 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -96,9 +96,11 @@ export default function UserHeader ({ user }) { if (error) { throw new Error({ message: error.toString() }) } + + const { nodata, ...query } = router.query router.replace({ pathname: router.pathname, - query: { ...router.query, name } + query: { ...query, name } }) client.writeFragment({ diff --git a/fragments/users.js b/fragments/users.js index 480c6c69..93ae84a4 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -54,9 +54,8 @@ export const ME_SSR = gql` } }` -export const SETTINGS = gql` -{ - settings { +export const SETTINGS_FIELDS = gql` + fragment SettingsFields on User { tipDefault noteItemSats noteEarning @@ -72,9 +71,31 @@ export const SETTINGS = gql` twitter github } + }` + +export const SETTINGS = gql` +${SETTINGS_FIELDS} +{ + settings { + ...SettingsFields } }` +export const SET_SETTINGS = +gql` +${SETTINGS_FIELDS} +mutation setSettings($tipDefault: Int!, $noteItemSats: Boolean!, $noteEarning: Boolean!, + $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!, + $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!) { + setSettings(tipDefault: $tipDefault, noteItemSats: $noteItemSats, + noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants, + noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites, + noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc) { + ...SettingsFields + } + } +` + export const NAME_QUERY = gql` query nameAvailable($name: String!) { @@ -164,6 +185,11 @@ export const USER_WITH_POSTS = gql` cursor items { ...ItemFields + position + } + pins { + ...ItemFields + position } } }` diff --git a/lib/apollo.js b/lib/apollo.js index 6a3debec..dab833e1 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -79,7 +79,7 @@ export default function getApolloClient () { } }, notifications: { - keyArgs: false, + keyArgs: ['inc'], merge (existing, incoming) { if (isFirstPage(incoming.cursor, existing?.notifications)) { return incoming diff --git a/pages/[name]/posts.js b/pages/[name]/posts.js index 1501edc9..db412f86 100644 --- a/pages/[name]/posts.js +++ b/pages/[name]/posts.js @@ -10,7 +10,7 @@ export const getServerSideProps = getGetServerSideProps(USER_WITH_POSTS) export default function UserPosts ({ data: { user, items: { items, cursor } } }) { const { data } = useQuery(USER_WITH_POSTS, - { variables: { name: user.name } }) + { variables: { name: user.name, sort: 'user' } }) if (data) { ({ user, items: { items, cursor } } = data) diff --git a/pages/_app.js b/pages/_app.js index 28538ee0..e47989ee 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -1,5 +1,5 @@ import '../styles/globals.scss' -import { ApolloProvider, gql } from '@apollo/client' +import { ApolloProvider, gql, useQuery } from '@apollo/client' import { Provider } from 'next-auth/client' import { FundErrorModal, FundErrorProvider } from '../components/fund-error' import { MeProvider } from '../components/me' @@ -10,27 +10,64 @@ import getApolloClient from '../lib/apollo' import NextNProgress from 'nextjs-progressbar' import { PriceProvider } from '../components/price' import Head from 'next/head' +import { useRouter } from 'next/dist/client/router' +import { useEffect } from 'react' +import Moon from '../svgs/moon-fill.svg' +import Layout from '../components/layout' + +function CSRWrapper ({ Component, apollo, ...props }) { + const { data, error } = useQuery(gql`${apollo.query}`, { variables: apollo.variables, fetchPolicy: 'cache-first' }) + if (error) { + return ( +
+ {error.toString()} +
+ ) + } + + if (!data) { + return ( + +
+ +
+
+ ) + } + + return +} function MyApp ({ Component, pageProps: { session, ...props } }) { const client = getApolloClient() + const router = useRouter() + + useEffect(async () => { + // HACK: 'cause there's no way to tell Next to skip SSR + // So every page load, we modify the route in browser history + // to point to the same page but without SSR, ie ?nodata=true + // this nodata var will get passed to the server on back/foward and + // 1. prevent data from reloading and 2. perserve scroll + // (2) is not possible while intercepting nav with beforePopState + router.replace({ + pathname: router.pathname, + query: { ...router.query, nodata: true } + }, router.asPath, { ...router.options, scroll: false }) + }, [router.asPath]) /* If we are on the client, we populate the apollo cache with the ssr data */ - if (typeof window !== 'undefined') { - const { apollo, data } = props - if (apollo) { - client.writeQuery({ - query: gql`${apollo.query}`, - data: data, - variables: apollo.variables - }) - } + const { apollo, data, me, price } = props + if (typeof window !== 'undefined' && apollo && data) { + client.writeQuery({ + query: gql`${apollo.query}`, + data: data, + variables: apollo.variables + }) } - const { me, price } = props - return ( <> - + {data || !apollo?.query + ? + : } diff --git a/pages/settings.js b/pages/settings.js index 1a8382e7..2d260192 100644 --- a/pages/settings.js +++ b/pages/settings.js @@ -9,7 +9,7 @@ import LoginButton from '../components/login-button' import { signIn } from 'next-auth/client' import ModalButton from '../components/modal-button' import { LightningAuth } from '../components/lightning-auth' -import { SETTINGS } from '../fragments/users' +import { SETTINGS, SET_SETTINGS } from '../fragments/users' import { useRouter } from 'next/router' import Info from '../components/info' @@ -28,16 +28,18 @@ export const WarningSchema = Yup.object({ export default function Settings ({ data: { settings } }) { const [success, setSuccess] = useState() - const [setSettings] = useMutation( - gql` - mutation setSettings($tipDefault: Int!, $noteItemSats: Boolean!, $noteEarning: Boolean!, - $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!, - $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!) { - setSettings(tipDefault: $tipDefault, noteItemSats: $noteItemSats, - noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants, - noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites, - noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc) - }` + const [setSettings] = useMutation(SET_SETTINGS, { + update (cache, { data: { setSettings } }) { + cache.modify({ + id: 'ROOT_QUERY', + fields: { + settings () { + return setSettings + } + } + }) + } + } ) const { data } = useQuery(SETTINGS) From 52de4a253e9767eaad8ebda2e8be7c2c7aba2fb9 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 6 Sep 2022 09:37:53 -0500 Subject: [PATCH 38/64] max amount for alby --- pages/wallet.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pages/wallet.js b/pages/wallet.js index 18966628..c1cede09 100644 --- a/pages/wallet.js +++ b/pages/wallet.js @@ -153,7 +153,8 @@ export function WithdrawlForm () { try { const provider = await requestProvider() const { paymentRequest: invoice } = await provider.makeInvoice({ - defaultMemo: `Withdrawal for @${me.name} on SN` + defaultMemo: `Withdrawal for @${me.name} on SN`, + maximumAmount: Math.max(me.sats - MAX_FEE_DEFAULT, 0) }) const { data } = await createWithdrawl({ variables: { invoice, maxFee: MAX_FEE_DEFAULT } }) router.push(`/withdrawals/${data.createWithdrawl.id}`) From b468c033ca1531de2a398a3edd603da33fba0522 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 6 Sep 2022 09:48:37 -0500 Subject: [PATCH 39/64] better handling of comment sort loading state --- components/comments.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/components/comments.js b/components/comments.js index 9f5b6860..db5dfe2b 100644 --- a/components/comments.js +++ b/components/comments.js @@ -69,7 +69,8 @@ export default function Comments ({ parentId, commentSats, comments, ...props }) } catch {} } }, []) - const [getComments, { loading }] = useLazyQuery(COMMENTS_QUERY, { + const [loading, setLoading] = useState() + const [getComments] = useLazyQuery(COMMENTS_QUERY, { fetchPolicy: 'network-only', onCompleted: data => { client.writeFragment({ @@ -87,12 +88,20 @@ export default function Comments ({ parentId, commentSats, comments, ...props }) comments: data.comments } }) + setLoading(false) } }) return ( <> - {comments.length ? getComments({ variables: { id: parentId, sort } })} /> : null} + {comments.length + ? { + setLoading(true) + getComments({ variables: { id: parentId, sort } }) + }} + /> + : null} {loading ? : comments.map(item => ( From 69b5aed85d4442fd5e5af62eb7cc828375296ac8 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 6 Sep 2022 09:57:34 -0500 Subject: [PATCH 40/64] fix 'me' on refresh --- api/ssrApollo.js | 16 +++++++++------- components/me.js | 4 ++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/api/ssrApollo.js b/api/ssrApollo.js index d96f5ab8..3335cde1 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -37,11 +37,20 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re return async function ({ req, query: params }) { const { nodata, ...realParams } = params const vars = { ...realParams, ...variables } + const client = await getSSRApolloClient(req) + + const { data: { me } } = await client.query({ + query: ME_SSR + }) + + const price = await getPrice() // we want to use client-side cache if (nodata && query) { return { props: { + me, + price, apollo: { query: print(query), variables: vars @@ -56,7 +65,6 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re } } - const client = await getSSRApolloClient(req) let error = null; let data = null; let props = {} if (query) { ({ error, data } = await client.query({ @@ -78,12 +86,6 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re } } - const { data: { me } } = await client.query({ - query: ME_SSR - }) - - const price = await getPrice() - return { props: { ...props, diff --git a/components/me.js b/components/me.js index cab4ac84..065fd037 100644 --- a/components/me.js +++ b/components/me.js @@ -7,10 +7,10 @@ export const MeContext = React.createContext({ }) export function MeProvider ({ me, children }) { - const { data } = useQuery(ME, { pollInterval: 1000 }) + const { data } = useQuery(ME, { pollInterval: 1000, fetchPolicy: 'cache-and-network' }) const contextValue = { - me: data ? data.me : me + me: data?.me || me } return ( From 5fb310db5fd9e2ba511d0eaa98bdd299884c63b9 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 6 Sep 2022 12:07:12 -0500 Subject: [PATCH 41/64] log tor to own file --- .ebextensions/tor.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ebextensions/tor.config b/.ebextensions/tor.config index 93bf5146..73e19701 100644 --- a/.ebextensions/tor.config +++ b/.ebextensions/tor.config @@ -13,7 +13,7 @@ files: content: | HTTPTunnelPort 127.0.0.1:7050 SocksPort 0 - Log notice syslog + Log info file /var/log/tor/info.log HiddenServiceDir /var/lib/tor/sn/ HiddenServicePort 80 127.0.0.1:443 services: From b7132cc962bbee140b43908f87a0ffdba23e7e77 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 6 Sep 2022 16:10:13 -0500 Subject: [PATCH 42/64] 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 43/64] 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 44/64] 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 45/64] 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 46/64] 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 47/64] 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 48/64] 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 49/64] 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 50/64] 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 51/64] 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 52/64] 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 53/64] 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 54/64] 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 55/64] 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 56/64] 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 57/64] 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 58/64] 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 59/64] 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 60/64] 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 61/64] 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 62/64] 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 63/64] 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) { From 515f997147e6be8231969bf9c67fa880490e473b Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 4 Oct 2022 12:19:15 -0500 Subject: [PATCH 64/64] link to privacy --- components/footer.js | 61 ++++++++++++++++++++++++-------------------- next.config.js | 4 +++ 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/components/footer.js b/components/footer.js index 0ae596b2..7c043b79 100644 --- a/components/footer.js +++ b/components/footer.js @@ -129,41 +129,48 @@ export default function Footer ({ noLinks }) {