undo zap/downzap and improve downzap ux

This commit is contained in:
keyan 2023-12-19 19:55:19 -06:00
parent 7e0da18878
commit 65744364f1
13 changed files with 228 additions and 85 deletions

View File

@ -141,7 +141,7 @@ async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ...args)
return await models.$queryRawUnsafe(`
SELECT "Item".*, to_jsonb(users.*) || jsonb_build_object('meMute', "Mute"."mutedId" IS NOT NULL) as user,
COALESCE("ItemAct"."meMsats", 0) as "meMsats",
COALESCE("ItemAct"."meDontLike", false) as "meDontLike", b."itemId" IS NOT NULL AS "meBookmark",
COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark",
"ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward"
FROM (
${query}
@ -153,7 +153,7 @@ async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ...args)
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "ItemForward"."userId" = ${me.id}
LEFT JOIN LATERAL (
SELECT "itemId", sum("ItemAct".msats) FILTER (WHERE act = 'FEE' OR act = 'TIP') AS "meMsats",
bool_or(act = 'DONT_LIKE_THIS') AS "meDontLike"
sum("ItemAct".msats) FILTER (WHERE act = 'DONT_LIKE_THIS') AS "meDontLikeMsats"
FROM "ItemAct"
WHERE "ItemAct"."userId" = ${me.id}
AND "ItemAct"."itemId" = "Item".id
@ -805,7 +805,7 @@ export default {
{ me, models, lnd, hash, hmac }
)
return true
return sats
}
},
Item: {
@ -933,11 +933,16 @@ export default {
return (msats && msatsToSats(msats)) || 0
},
meDontLike: async (item, args, { me, models }) => {
meDontLikeSats: async (item, args, { me, models }) => {
if (!me) return false
if (typeof item.meDontLike !== 'undefined') return item.meDontLike
if (typeof item.meMsats !== 'undefined') {
return msatsToSats(item.meDontLikeMsats)
}
const dontLike = await models.itemAct.findFirst({
const { _sum: { msats } } = await models.itemAct.aggregate({
_sum: {
msats: true
},
where: {
itemId: Number(item.id),
userId: me.id,
@ -945,7 +950,7 @@ export default {
}
})
return !!dontLike
return (msats && msatsToSats(msats)) || 0
},
meBookmark: async (item, args, { me, models }) => {
if (!me) return false

View File

@ -34,7 +34,7 @@ export default gql`
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!
updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item!
dontLikeThis(id: ID!, sats: Int, hash: String, hmac: String): Boolean!
dontLikeThis(id: ID!, sats: Int, hash: String, hmac: String): Int!
act(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult!
pollVote(id: ID!, hash: String, hmac: String): ID!
}
@ -90,7 +90,7 @@ export default gql`
lastCommentAt: Date
upvotes: Int!
meSats: Int!
meDontLike: Boolean!
meDontLikeSats: Int!
meBookmark: Boolean!
meSubscription: Boolean!
meForward: Boolean

View File

@ -14,7 +14,6 @@ import { ignoreClick } from '../lib/clicks'
import PayBounty from './pay-bounty'
import BountyIcon from '../svgs/bounty-bag.svg'
import ActionTooltip from './action-tooltip'
import Flag from '../svgs/flag-fill.svg'
import { numWithUnits } from '../lib/format'
import Share from './share'
import ItemInfo from './item-info'
@ -22,6 +21,7 @@ import Badge from 'react-bootstrap/Badge'
import { RootProvider, useRoot } from './root'
import { useMe } from './me'
import { useQuoteReply } from './use-quote-reply'
import { DownZap } from './dont-link-this'
function Parent ({ item, rootText }) {
const root = useRoot()
@ -146,8 +146,8 @@ export default function Comment ({
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
>
<div className={`${itemStyles.item} ${styles.item}`}>
{item.meDontLike
? <Flag width={24} height={24} className={styles.dontLike} />
{item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} />
: <UpVote item={item} className={styles.upvote} pendingSats={pendingSats} setPendingSats={setPendingSats} />}
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'>

View File

@ -16,6 +16,7 @@
padding: 2px;
margin-left: 1px;
margin-top: 9px;
cursor: pointer;
}
.text {

View File

@ -4,8 +4,23 @@ import { useShowModal } from './modal'
import { useToast } from './toast'
import ItemAct from './item-act'
import AccordianItem from './accordian-item'
import Flag from '../svgs/flag-fill.svg'
import { useMemo } from 'react'
import getColor from '../lib/rainbow'
export default function DontLikeThisDropdownItem ({ id }) {
export function DownZap ({ id, meDontLikeSats, ...props }) {
const style = useMemo(() => (meDontLikeSats
? {
fill: getColor(meDontLikeSats),
filter: `drop-shadow(0 0 6px ${getColor(meDontLikeSats)}90)`
}
: undefined), [meDontLikeSats])
return (
<DownZapper id={id} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
)
}
function DownZapper ({ id, As, children }) {
const toaster = useToast()
const showModal = useShowModal()
@ -14,12 +29,12 @@ export default function DontLikeThisDropdownItem ({ id }) {
mutation dontLikeThis($id: ID!, $sats: Int, $hash: String, $hmac: String) {
dontLikeThis(id: $id, sats: $sats, hash: $hash, hmac: $hmac)
}`, {
update (cache) {
update (cache, { data: { dontLikeThis } }) {
cache.modify({
id: `Item:${id}`,
fields: {
meDontLike () {
return true
meDontLikeSats (existingSats = 0) {
return existingSats + dontLikeThis
}
}
})
@ -28,7 +43,7 @@ export default function DontLikeThisDropdownItem ({ id }) {
)
return (
<Dropdown.Item
<As
onClick={async () => {
try {
showModal(onClose =>
@ -53,7 +68,18 @@ export default function DontLikeThisDropdownItem ({ id }) {
}
}}
>
<span className='text-danger'>downzap</span>
</Dropdown.Item>
{children}
</As>
)
}
export default function DontLikeThisDropdownItem ({ id }) {
return (
<DownZapper
As={Dropdown.Item}
id={id}
>
<span className='text-danger'>downzap</span>
</DownZapper>
)
}

View File

@ -18,6 +18,7 @@ import Hat from './hat'
import { AD_USER_ID } from '../lib/constants'
import ActionDropdown from './action-dropdown'
import MuteDropdownItem from './mute'
import { DropdownItemUpVote } from './upvote'
export default function ItemInfo ({
item, pendingSats, full, commentsText = 'comments',
@ -39,7 +40,7 @@ export default function ItemInfo ({
}, [item])
useEffect(() => {
if (item) setMeTotalSats(item.meSats + item.meAnonSats + pendingSats)
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0) + (pendingSats || 0))
}, [item?.meSats, item?.meAnonSats, pendingSats])
return (
@ -52,7 +53,9 @@ export default function ItemInfo ({
unitPlural: 'stackers'
})} ${item.mine
? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post`
: `(${numWithUnits(meTotalSats, { abbreviate: false })} from me)`} `}
: `(${numWithUnits(meTotalSats, { abbreviate: false })}${item.meDontLikeSats
? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
: ''} from me)`} `}
>
{numWithUnits(item.sats + pendingSats)}
</span>
@ -143,8 +146,11 @@ export default function ItemInfo ({
<Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'>
opentimestamp
</Link>}
{me && !item.meSats && !item.position &&
!item.mine && !item.deletedAt && <DontLikeThisDropdownItem id={item.id} />}
{me && !item.position &&
!item.mine && !item.deletedAt &&
(item.meDontLikeSats > meTotalSats
? <DropdownItemUpVote item={item} />
: <DontLikeThisDropdownItem id={item.id} />)}
{me && item?.noteId && (
<Dropdown.Item onClick={() => window.open(`https://nostr.com/${item.noteId}`, '_blank', 'noopener')}>
nostr note

View File

@ -8,7 +8,6 @@ import reactStringReplace from 'react-string-replace'
import PollIcon from '../svgs/bar-chart-horizontal-fill.svg'
import BountyIcon from '../svgs/bounty-bag.svg'
import ActionTooltip from './action-tooltip'
import Flag from '../svgs/flag-fill.svg'
import ImageIcon from '../svgs/image-fill.svg'
import { numWithUnits } from '../lib/format'
import ItemInfo from './item-info'
@ -17,6 +16,7 @@ import { commentsViewedAt } from '../lib/new-comments'
import { useRouter } from 'next/router'
import { Badge } from 'react-bootstrap'
import AdIcon from '../svgs/advertisement-fill.svg'
import { DownZap } from './dont-link-this'
export function SearchTitle ({ title }) {
return reactStringReplace(title, /\*\*\*([^*]+)\*\*\*/g, (match, i) => {
@ -43,8 +43,8 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
<div className={`${styles.item} ${siblingComments ? 'pt-3' : ''}`}>
{item.position
? <Pin width={24} height={24} className={styles.pin} />
: item.meDontLike
? <Flag width={24} height={24} className={styles.dontLike} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} />
: Number(item.user?.id) === AD_USER_ID
? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} pendingSats={pendingSats} setPendingSats={setPendingSats} />}

View File

@ -65,6 +65,7 @@ a.title:visited {
margin-right: .35rem;
margin-left: -.2rem;
flex-shrink: 0;
cursor: pointer;
}
.case {

View File

@ -4,7 +4,7 @@ import { gql, useMutation } from '@apollo/client'
import ActionTooltip from './action-tooltip'
import ItemAct from './item-act'
import { useMe } from './me'
import Rainbow from '../lib/rainbow'
import getColor from '../lib/rainbow'
import { useCallback, useMemo, useRef, useState } from 'react'
import LongPressable from 'react-longpressable'
import Overlay from 'react-bootstrap/Overlay'
@ -15,17 +15,7 @@ import { numWithUnits } from '../lib/format'
import { payOrLoginError, useInvoiceModal } from './invoice'
import useDebounceCallback from './use-debounce-callback'
import { useToast } from './toast'
const getColor = (meSats) => {
if (!meSats || meSats <= 10) {
return 'var(--bs-secondary)'
}
const idx = Math.min(
Math.floor((Math.log(meSats) / Math.log(10000)) * (Rainbow.length - 1)),
Rainbow.length - 1)
return Rainbow[idx]
}
import { Dropdown } from 'react-bootstrap'
const UpvotePopover = ({ target, show, handleClose }) => {
const me = useMe()
@ -66,6 +56,72 @@ const TipPopover = ({ target, show, handleClose }) => (
</Overlay>
)
function useAct ({ item, setVoteShow = () => {}, setTipShow = () => {} }) {
const me = useMe()
return useMutation(
gql`
mutation act($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, hash: $hash, hmac: $hmac) {
sats
}
}`, {
update (cache, { data: { act: { sats } } }) {
cache.modify({
id: `Item:${item.id}`,
fields: {
sats (existingSats = 0) {
return existingSats + sats
},
meSats: me
? (existingSats = 0) => {
if (sats <= me.privates?.sats) {
if (existingSats === 0) {
setVoteShow(true)
} else {
setTipShow(true)
}
}
return existingSats + sats
}
: undefined
}
})
// 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
}
}
})
})
}
}
)
}
export function DropdownItemUpVote ({ item }) {
const showModal = useShowModal()
const [act] = useAct({ item })
return (
<Dropdown.Item
onClick={async () => {
showModal(onClose =>
<ItemAct onClose={onClose} itemId={item.id} act={act} />)
}}
>
<span className='text-success'>zap</span>
</Dropdown.Item>
)
}
export default function UpVote ({ item, className, pendingSats, setPendingSats }) {
const showModal = useShowModal()
const [voteShow, _setVoteShow] = useState(false)
@ -110,51 +166,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
}
}, [me, tipShow, setWalkthrough])
const [act] = useMutation(
gql`
mutation act($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, hash: $hash, hmac: $hmac) {
sats
}
}`, {
update (cache, { data: { act: { sats } } }) {
cache.modify({
id: `Item:${item.id}`,
fields: {
sats (existingSats = 0) {
return existingSats + sats
},
meSats: me
? (existingSats = 0) => {
if (sats <= me.privates?.sats) {
if (existingSats === 0) {
setVoteShow(true)
} else {
setTipShow(true)
}
}
const [act] = useAct({ item, setVoteShow, setTipShow })
return existingSats + sats
}
: undefined
}
})
// 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
}
}
})
})
}
}
)
const showInvoiceModal = useInvoiceModal(
async ({ hash, hmac }, { variables }) => {
await act({ variables: { ...variables, hash, hmac } })

View File

@ -21,7 +21,7 @@ export const COMMENT_FIELDS = gql`
freedFreebie
boost
meSats
meDontLike
meDontLikeSats
meBookmark
meSubscription
outlawed

View File

@ -28,7 +28,7 @@ export const ITEM_FIELDS = gql`
path
upvotes
meSats
meDontLike
meDontLikeSats
meBookmark
meSubscription
meForward

View File

@ -1,4 +1,15 @@
export default [
export default function getColor (meSats) {
if (!meSats || meSats <= 10) {
return 'var(--bs-secondary)'
}
const idx = Math.min(
Math.floor((Math.log(meSats) / Math.log(10000)) * (Rainbow.length - 1)),
Rainbow.length - 1)
return Rainbow[idx]
}
const Rainbow = [
'#f6911d',
'#f6921e',
'#f6931f',

View File

@ -0,0 +1,80 @@
CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me(_item_id int, _global_seed int, _me_id int, _level int, _where text, _order_by text)
RETURNS jsonb
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
$$
DECLARE
result jsonb;
BEGIN
IF _level < 1 THEN
RETURN '[]'::jsonb;
END IF;
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS'
|| ' SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|| ' to_jsonb(users.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, '
|| ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", COALESCE("ItemAct"."meDontLikeMsats", 0) AS "meDontLikeMsats", '
|| ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", '
|| ' GREATEST(g.tf_hot_score, l.tf_hot_score) AS personal_hot_score, GREATEST(g.tf_top_score, l.tf_top_score) AS personal_top_score '
|| ' FROM "Item" '
|| ' JOIN users ON users.id = "Item"."userId" '
|| ' LEFT JOIN "Mute" ON "Mute"."muterId" = $5 AND "Mute"."mutedId" = "Item"."userId"'
|| ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $5 AND "Bookmark"."itemId" = "Item".id '
|| ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $5 AND "ThreadSubscription"."itemId" = "Item".id '
|| ' LEFT JOIN LATERAL ( '
|| ' SELECT "itemId", sum("ItemAct".msats) FILTER (WHERE act = ''FEE'' OR act = ''TIP'') AS "meMsats", '
|| ' sum("ItemAct".msats) FILTER (WHERE act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
|| ' FROM "ItemAct" '
|| ' WHERE "ItemAct"."userId" = $5 '
|| ' AND "ItemAct"."itemId" = "Item".id '
|| ' GROUP BY "ItemAct"."itemId" '
|| ' ) "ItemAct" ON true '
|| ' LEFT JOIN zap_rank_personal_view g ON g."viewerId" = $6 AND g.id = "Item".id '
|| ' LEFT JOIN zap_rank_personal_view l ON l."viewerId" = $5 AND l.id = g.id '
|| ' WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) ' || _where || ' '
USING _item_id, _level, _where, _order_by, _me_id, _global_seed;
EXECUTE ''
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|| 'FROM ( '
|| ' SELECT "Item".*, item_comments_zaprank_with_me("Item".id, $6, $5, $2 - 1, $3, $4) AS comments '
|| ' FROM t_item "Item" '
|| ' WHERE "Item"."parentId" = $1 '
|| _order_by
|| ' ) sub'
INTO result USING _item_id, _level, _where, _order_by, _me_id, _global_seed;
RETURN result;
END
$$;
CREATE OR REPLACE FUNCTION item_comments(_item_id int, _level int, _where text, _order_by text)
RETURNS jsonb
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
$$
DECLARE
result jsonb;
BEGIN
IF _level < 1 THEN
RETURN '[]'::jsonb;
END IF;
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS'
|| ' SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|| ' to_jsonb(users.*) as user '
|| ' FROM "Item" '
|| ' JOIN users ON users.id = "Item"."userId" '
|| ' WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) ' || _where
USING _item_id, _level, _where, _order_by;
EXECUTE ''
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|| 'FROM ( '
|| ' SELECT "Item".*, item_comments("Item".id, $2 - 1, $3, $4) AS comments '
|| ' FROM t_item "Item"'
|| ' WHERE "Item"."parentId" = $1 '
|| _order_by
|| ' ) sub'
INTO result USING _item_id, _level, _where, _order_by;
RETURN result;
END
$$;