Merge branch 'master' into fix-magic-numbers-in-update-item
This commit is contained in:
commit
8bbd480ca3
14
.env.sample
14
.env.sample
@ -14,6 +14,7 @@ TWITTER_SECRET=<YOUR TWITTER SECRET>
|
||||
# email
|
||||
LOGIN_EMAIL_SERVER=smtp://<YOUR EMAIL>:<YOUR PASSWORD>@<YOUR SMTP DOMAIN>:587
|
||||
LOGIN_EMAIL_FROM=<YOUR FROM ALIAS>
|
||||
LIST_MONK_AUTH=
|
||||
|
||||
#####################################################################
|
||||
# OTHER / OPTIONAL #
|
||||
@ -34,6 +35,11 @@ NEXT_PUBLIC_IMGPROXY_URL=
|
||||
IMGPROXY_KEY=
|
||||
IMGPROXY_SALT=
|
||||
|
||||
# search
|
||||
OPENSEARCH_URL=http://opensearch:9200
|
||||
OPENSEARCH_USERNAME=
|
||||
OPENSEARCH_PASSWORD=
|
||||
|
||||
#######################################################
|
||||
# WALLET / OPTIONAL #
|
||||
# if you want to work with payments you'll need these #
|
||||
@ -64,11 +70,6 @@ NEXTAUTH_SECRET=3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI
|
||||
JWT_SIGNING_PRIVATE_KEY={"kty":"oct","kid":"FvD__hmeKoKHu2fKjUrWbRKfhjimIM4IKshyrJG4KSM","alg":"HS512","k":"3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI"}
|
||||
INVOICE_HMAC_KEY=a4c1d9c81edb87b79d28809876a18cf72293eadb39f92f3f4f2f1cfbdf907c91
|
||||
|
||||
# OpenSearch
|
||||
OPENSEARCH_URL=http://opensearch:9200
|
||||
OPENSEARCH_USERNAME=
|
||||
OPENSEARCH_PASSWORD=
|
||||
|
||||
# imgproxy options
|
||||
IMGPROXY_ENABLE_WEBP_DETECTION=1
|
||||
IMGPROXY_ENABLE_AVIF_DETECTION=1
|
||||
@ -89,6 +90,3 @@ POSTGRES_PASSWORD=password
|
||||
POSTGRES_USER=sn
|
||||
POSTGRES_DB=stackernews
|
||||
|
||||
# slack
|
||||
SLACK_BOT_TOKEN=
|
||||
SLACK_CHANNEL_ID=
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
|
||||
DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
|
||||
ANON_COMMENT_FEE, ANON_USER_ID, ANON_POST_FEE, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
|
||||
ITEM_ALLOW_EDITS
|
||||
ITEM_ALLOW_EDITS, GLOBAL_SEED
|
||||
} from '../../lib/constants'
|
||||
import { msatsToSats } from '../../lib/format'
|
||||
import { parse } from 'tldts'
|
||||
@ -37,24 +37,39 @@ export async function commentFilterClause (me, models) {
|
||||
return clause
|
||||
}
|
||||
|
||||
async function comments (me, models, id, sort) {
|
||||
let orderBy
|
||||
switch (sort) {
|
||||
case 'top':
|
||||
orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
|
||||
break
|
||||
case 'recent':
|
||||
orderBy = 'ORDER BY "Item".created_at DESC, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC'
|
||||
break
|
||||
default:
|
||||
orderBy = `ORDER BY ${await orderByNumerator(me, models)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
|
||||
break
|
||||
function commentsOrderByClause (me, models, sort) {
|
||||
if (sort === 'recent') {
|
||||
return 'ORDER BY "Item".created_at DESC, "Item".id DESC'
|
||||
}
|
||||
|
||||
if (me) {
|
||||
if (sort === 'top') {
|
||||
return `ORDER BY COALESCE(
|
||||
personal_top_score,
|
||||
${orderByNumerator(models, 0)}) DESC NULLS LAST,
|
||||
"Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
|
||||
} else {
|
||||
return `ORDER BY COALESCE(
|
||||
personal_hot_score,
|
||||
${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST,
|
||||
"Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
|
||||
}
|
||||
} else {
|
||||
if (sort === 'top') {
|
||||
return `ORDER BY ${orderByNumerator(models, 0)} DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
|
||||
} else {
|
||||
return `ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function comments (me, models, id, sort) {
|
||||
const orderBy = commentsOrderByClause(me, models, sort)
|
||||
|
||||
const filter = await commentFilterClause(me, models)
|
||||
if (me) {
|
||||
const [{ item_comments_with_me: comments }] = await models.$queryRawUnsafe(
|
||||
'SELECT item_comments_with_me($1::INTEGER, $2::INTEGER, $3::INTEGER, $4, $5)', Number(id), Number(me.id), COMMENT_DEPTH_LIMIT, filter, orderBy)
|
||||
const [{ item_comments_zaprank_with_me: comments }] = await models.$queryRawUnsafe(
|
||||
'SELECT item_comments_zaprank_with_me($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5, $6)', Number(id), GLOBAL_SEED, Number(me.id), COMMENT_DEPTH_LIMIT, filter, orderBy)
|
||||
return comments
|
||||
}
|
||||
|
||||
@ -75,43 +90,35 @@ export async function getItem (parent, { id }, { me, models }) {
|
||||
return item
|
||||
}
|
||||
|
||||
const orderByClause = async (by, me, models, type) => {
|
||||
const orderByClause = (by, me, models, type) => {
|
||||
switch (by) {
|
||||
case 'comments':
|
||||
return 'ORDER BY "Item".ncomments DESC'
|
||||
case 'sats':
|
||||
return 'ORDER BY "Item".msats DESC'
|
||||
case 'zaprank':
|
||||
return await topOrderByWeightedSats(me, models)
|
||||
return topOrderByWeightedSats(me, models)
|
||||
default:
|
||||
return `ORDER BY ${type === 'bookmarks' ? '"bookmarkCreatedAt"' : '"Item".created_at'} DESC`
|
||||
}
|
||||
}
|
||||
|
||||
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)) + "Item"."weightedComments"/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))
|
||||
+ "Item"."weightedComments"/2)`
|
||||
export function orderByNumerator (models, commentScaler = 0.5) {
|
||||
return `(CASE WHEN "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0 THEN
|
||||
GREATEST("Item"."weightedVotes" - "Item"."weightedDownVotes", POWER("Item"."weightedVotes" - "Item"."weightedDownVotes", 1.2))
|
||||
ELSE
|
||||
"Item"."weightedVotes" - "Item"."weightedDownVotes"
|
||||
END + "Item"."weightedComments"*${commentScaler})`
|
||||
}
|
||||
|
||||
export async function joinSatRankView (me, models) {
|
||||
export function joinZapRankPersonalView (me, models) {
|
||||
let join = ` JOIN zap_rank_personal_view g ON g.id = "Item".id AND g."viewerId" = ${GLOBAL_SEED} `
|
||||
|
||||
if (me) {
|
||||
const user = await models.user.findUnique({ where: { id: me.id } })
|
||||
if (user.wildWestMode) {
|
||||
return 'JOIN zap_rank_wwm_view ON "Item".id = zap_rank_wwm_view.id'
|
||||
}
|
||||
join += ` LEFT JOIN zap_rank_personal_view l ON l.id = g.id AND l."viewerId" = ${me.id} `
|
||||
}
|
||||
|
||||
return 'JOIN zap_rank_tender_view ON "Item".id = zap_rank_tender_view.id'
|
||||
return join
|
||||
}
|
||||
|
||||
// this grabs all the stuff we need to display the item list and only
|
||||
@ -348,10 +355,10 @@ export default {
|
||||
await filterClause(me, models, type),
|
||||
typeClause(type),
|
||||
whenClause(when || 'forever', type))}
|
||||
${await orderByClause(by, me, models, type)}
|
||||
${orderByClause(by, me, models, type)}
|
||||
OFFSET $3
|
||||
LIMIT $4`,
|
||||
orderBy: await orderByClause(by, me, models, type)
|
||||
orderBy: orderByClause(by, me, models, type)
|
||||
}, decodedCursor.time, user.id, decodedCursor.offset, limit, ...subArr)
|
||||
break
|
||||
case 'recent':
|
||||
@ -376,10 +383,34 @@ export default {
|
||||
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
||||
break
|
||||
case 'top':
|
||||
items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
if (me && (!by || by === 'zaprank') && (when === 'day' || when === 'week')) {
|
||||
// personalized zaprank only goes back 7 days
|
||||
items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}, GREATEST(g.tf_top_score, l.tf_top_score) AS rank
|
||||
${relationClause(type)}
|
||||
${joinZapRankPersonalView(me, models)}
|
||||
${whereClause(
|
||||
'"Item".created_at <= $1',
|
||||
'"Item"."pinId" IS NULL',
|
||||
'"Item"."deletedAt" IS NULL',
|
||||
subClause(sub, 4, subClauseTable(type)),
|
||||
typeClause(type),
|
||||
whenClause(when, type),
|
||||
await filterClause(me, models, type),
|
||||
muteClause(me))}
|
||||
ORDER BY rank DESC
|
||||
OFFSET $2
|
||||
LIMIT $3`,
|
||||
orderBy: 'ORDER BY rank DESC'
|
||||
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
||||
} else {
|
||||
items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${selectClause(type)}
|
||||
${relationClause(type)}
|
||||
${whereClause(
|
||||
@ -391,11 +422,12 @@ export default {
|
||||
whenClause(when, type),
|
||||
await filterClause(me, models, type),
|
||||
muteClause(me))}
|
||||
${await orderByClause(by || 'zaprank', me, models, type)}
|
||||
${orderByClause(by || 'zaprank', me, models, type)}
|
||||
OFFSET $2
|
||||
LIMIT $3`,
|
||||
orderBy: await orderByClause(by || 'zaprank', me, models, type)
|
||||
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
||||
orderBy: orderByClause(by || 'zaprank', me, models, type)
|
||||
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
||||
}
|
||||
break
|
||||
default:
|
||||
// sub so we know the default ranking
|
||||
@ -434,18 +466,42 @@ export default {
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}, rank
|
||||
${SELECT}, ${me ? 'GREATEST(g.tf_hot_score, l.tf_hot_score)' : 'g.tf_hot_score'} AS rank
|
||||
FROM "Item"
|
||||
${await joinSatRankView(me, models)}
|
||||
${joinZapRankPersonalView(me, models)}
|
||||
${whereClause(
|
||||
'"Item"."pinId" IS NULL',
|
||||
'"Item"."deletedAt" IS NULL',
|
||||
'"Item"."parentId" IS NULL',
|
||||
'"Item".bio = false',
|
||||
subClause(sub, 3, 'Item', true),
|
||||
muteClause(me))}
|
||||
ORDER BY rank ASC
|
||||
ORDER BY rank DESC
|
||||
OFFSET $1
|
||||
LIMIT $2`,
|
||||
orderBy: 'ORDER BY rank ASC'
|
||||
orderBy: 'ORDER BY rank DESC'
|
||||
}, decodedCursor.offset, limit, ...subArr)
|
||||
|
||||
// XXX this is just for migration purposes ... can remove after initial deployment
|
||||
// and views have been populated
|
||||
if (items.length === 0) {
|
||||
items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}, rank
|
||||
FROM "Item"
|
||||
JOIN zap_rank_tender_view ON "Item".id = zap_rank_tender_view.id
|
||||
${whereClause(
|
||||
subClause(sub, 3, 'Item', true),
|
||||
muteClause(me))}
|
||||
ORDER BY rank ASC
|
||||
OFFSET $1
|
||||
LIMIT $2`,
|
||||
orderBy: 'ORDER BY rank ASC'
|
||||
}, decodedCursor.offset, limit, ...subArr)
|
||||
}
|
||||
|
||||
if (decodedCursor.offset === 0) {
|
||||
// get pins for the page and return those separately
|
||||
pins = await itemQueryWithMeta({
|
||||
@ -462,7 +518,7 @@ export default {
|
||||
FROM "Item"
|
||||
${whereClause(
|
||||
'"pinId" IS NOT NULL',
|
||||
subClause(sub, 1),
|
||||
sub ? '("subName" = $1 OR "subName" IS NULL)' : '"subName" IS NULL',
|
||||
muteClause(me))}
|
||||
) rank_filter WHERE RANK = 1`
|
||||
}, ...subArr)
|
||||
@ -972,7 +1028,9 @@ export const createMentions = async (item, models) => {
|
||||
if (mentions?.length > 0) {
|
||||
const users = await models.user.findMany({
|
||||
where: {
|
||||
name: { in: mentions }
|
||||
name: { in: mentions },
|
||||
// Don't create mentions when mentioning yourself
|
||||
id: { not: item.userId }
|
||||
}
|
||||
})
|
||||
|
||||
@ -1107,6 +1165,6 @@ export const SELECT =
|
||||
"Item"."weightedDownVotes", "Item".freebie, "Item"."otsHash", "Item"."bountyPaidTo",
|
||||
ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls"`
|
||||
|
||||
async function topOrderByWeightedSats (me, models) {
|
||||
return `ORDER BY ${await orderByNumerator(me, models)} DESC NULLS LAST, "Item".id DESC`
|
||||
function topOrderByWeightedSats (me, models) {
|
||||
return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC`
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import Info from './info'
|
||||
import { numWithUnits } from '../lib/format'
|
||||
import styles from './adv-post-form.module.css'
|
||||
import { useMe } from './me'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const EMPTY_FORWARD = { nym: '', pct: '' }
|
||||
|
||||
@ -19,6 +20,7 @@ export function AdvPostInitial ({ forward, boost }) {
|
||||
|
||||
export default function AdvPostForm () {
|
||||
const me = useMe()
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<AccordianItem
|
||||
@ -81,7 +83,7 @@ export default function AdvPostForm () {
|
||||
)
|
||||
}}
|
||||
</VariableInput>
|
||||
{me &&
|
||||
{me && router.query.type === 'discussion' &&
|
||||
<Checkbox
|
||||
label={
|
||||
<div className='d-flex align-items-center'>crosspost to nostr
|
||||
|
@ -91,6 +91,7 @@ export function InputSkeleton ({ label, hint }) {
|
||||
)
|
||||
}
|
||||
|
||||
const DEFAULT_MENTION_INDICES = { start: -1, end: -1 }
|
||||
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, onKeyDown, innerRef, ...props }) {
|
||||
const [tab, setTab] = useState('write')
|
||||
const [, meta, helpers] = useField(props)
|
||||
@ -122,7 +123,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||
}, [innerRef, selectionRange.start, selectionRange.end])
|
||||
|
||||
const [mentionQuery, setMentionQuery] = useState()
|
||||
const [mentionIndices, setMentionIndices] = useState({ start: -1, end: -1 })
|
||||
const [mentionIndices, setMentionIndices] = useState(DEFAULT_MENTION_INDICES)
|
||||
const [userSuggestDropdownStyle, setUserSuggestDropdownStyle] = useState({})
|
||||
const insertMention = useCallback((name) => {
|
||||
const { start, end } = mentionIndices
|
||||
@ -133,7 +134,79 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||
helpers.setValue(updatedValue)
|
||||
setSelectionRange({ start: first.length, end: first.length })
|
||||
innerRef.current.focus()
|
||||
}, [mentionIndices, innerRef, helpers])
|
||||
}, [mentionIndices, innerRef, helpers?.setValue])
|
||||
|
||||
const onChangeInner = useCallback((formik, e) => {
|
||||
if (onChange) onChange(formik, e)
|
||||
if (setHasImgLink) {
|
||||
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
|
||||
}
|
||||
// check for mention editing
|
||||
const { value, selectionStart } = e.target
|
||||
let priorSpace = -1
|
||||
for (let i = selectionStart - 1; i >= 0; i--) {
|
||||
if (/\s|\n/.test(value[i])) {
|
||||
priorSpace = i
|
||||
break
|
||||
}
|
||||
}
|
||||
let nextSpace = value.length
|
||||
for (let i = selectionStart; i <= value.length; i++) {
|
||||
if (/\s|\n/.test(value[i])) {
|
||||
nextSpace = i
|
||||
break
|
||||
}
|
||||
}
|
||||
const currentSegment = value.substring(priorSpace + 1, nextSpace)
|
||||
|
||||
// set the query to the current character segment and note where it appears
|
||||
if (/^@[\w_]*$/.test(currentSegment)) {
|
||||
setMentionQuery(currentSegment)
|
||||
setMentionIndices({ start: priorSpace + 1, end: nextSpace })
|
||||
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
|
||||
setUserSuggestDropdownStyle({
|
||||
position: 'absolute',
|
||||
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
|
||||
left: `${left}px`
|
||||
})
|
||||
} else {
|
||||
setMentionQuery(undefined)
|
||||
setMentionIndices(DEFAULT_MENTION_INDICES)
|
||||
}
|
||||
}, [onChange, setHasImgLink, setMentionQuery, setMentionIndices, setUserSuggestDropdownStyle])
|
||||
|
||||
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
|
||||
return (e) => {
|
||||
const metaOrCtrl = e.metaKey || e.ctrlKey
|
||||
if (metaOrCtrl) {
|
||||
if (e.key === 'k') {
|
||||
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
|
||||
e.preventDefault()
|
||||
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
if (e.key === 'b') {
|
||||
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
|
||||
e.preventDefault()
|
||||
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
if (e.key === 'i') {
|
||||
// some browsers might use CTRL+I to do something else so prevent that behavior too
|
||||
e.preventDefault()
|
||||
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
if (e.key === 'Tab' && e.altKey) {
|
||||
e.preventDefault()
|
||||
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
}
|
||||
|
||||
if (!metaOrCtrl) {
|
||||
userSuggestOnKeyDown(e)
|
||||
}
|
||||
|
||||
if (onKeyDown) onKeyDown(e)
|
||||
}
|
||||
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown])
|
||||
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
@ -157,78 +230,13 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||
query={mentionQuery}
|
||||
onSelect={insertMention}
|
||||
dropdownStyle={userSuggestDropdownStyle}
|
||||
>{({ onKeyDown: userSuggestOnKeyDown }) => (
|
||||
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions }) => (
|
||||
<InputInner
|
||||
{...props} onChange={(formik, e) => {
|
||||
if (onChange) onChange(formik, e)
|
||||
if (setHasImgLink) {
|
||||
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
|
||||
}
|
||||
// check for mention editing
|
||||
const { value, selectionStart } = e.target
|
||||
let priorSpace = -1
|
||||
for (let i = selectionStart - 1; i >= 0; i--) {
|
||||
if (/\s|\n/.test(value[i])) {
|
||||
priorSpace = i
|
||||
break
|
||||
}
|
||||
}
|
||||
let nextSpace = value.length
|
||||
for (let i = selectionStart; i <= value.length; i++) {
|
||||
if (/\s|\n/.test(value[i])) {
|
||||
nextSpace = i
|
||||
break
|
||||
}
|
||||
}
|
||||
const currentSegment = value.substring(priorSpace + 1, nextSpace)
|
||||
|
||||
// set the query to the current character segment and note where it appears
|
||||
if (/^@[\w_]*$/.test(currentSegment)) {
|
||||
setMentionQuery(currentSegment)
|
||||
setMentionIndices({ start: priorSpace + 1, end: nextSpace })
|
||||
} else {
|
||||
setMentionQuery(undefined)
|
||||
setMentionIndices({ start: -1, end: -1 })
|
||||
}
|
||||
|
||||
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
|
||||
setUserSuggestDropdownStyle({
|
||||
position: 'absolute',
|
||||
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
|
||||
left: `${left}px`
|
||||
})
|
||||
}}
|
||||
innerRef={innerRef}
|
||||
onKeyDown={(e) => {
|
||||
const metaOrCtrl = e.metaKey || e.ctrlKey
|
||||
if (metaOrCtrl) {
|
||||
if (e.key === 'k') {
|
||||
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
|
||||
e.preventDefault()
|
||||
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
if (e.key === 'b') {
|
||||
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
|
||||
e.preventDefault()
|
||||
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
if (e.key === 'i') {
|
||||
// some browsers might use CTRL+I to do something else so prevent that behavior too
|
||||
e.preventDefault()
|
||||
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
if (e.key === 'Tab' && e.altKey) {
|
||||
e.preventDefault()
|
||||
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
}
|
||||
|
||||
if (!metaOrCtrl) {
|
||||
userSuggestOnKeyDown(e)
|
||||
}
|
||||
|
||||
if (onKeyDown) onKeyDown(e)
|
||||
}}
|
||||
{...props}
|
||||
onChange={onChangeInner}
|
||||
onKeyDown={onKeyDownInner(userSuggestOnKeyDown)}
|
||||
onBlur={() => setTimeout(resetSuggestions, 100)}
|
||||
/>)}
|
||||
</UserSuggest>
|
||||
</div>
|
||||
@ -300,6 +308,32 @@ function InputInner ({
|
||||
|
||||
const storageKey = storageKeyPrefix ? storageKeyPrefix + '-' + props.name : undefined
|
||||
|
||||
const onKeyDownInner = useCallback((e) => {
|
||||
const metaOrCtrl = e.metaKey || e.ctrlKey
|
||||
if (metaOrCtrl) {
|
||||
if (e.key === 'Enter') formik?.submitForm()
|
||||
}
|
||||
|
||||
if (onKeyDown) onKeyDown(e)
|
||||
}, [formik?.submitForm, onKeyDown])
|
||||
|
||||
const onChangeInner = useCallback((e) => {
|
||||
field?.onChange(e)
|
||||
|
||||
if (storageKey) {
|
||||
window.localStorage.setItem(storageKey, e.target.value)
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
onChange(formik, e)
|
||||
}
|
||||
}, [field?.onChange, storageKey, onChange])
|
||||
|
||||
const onBlurInner = useCallback((e) => {
|
||||
field?.onBlur?.(e)
|
||||
onBlur && onBlur(e)
|
||||
}, [field?.onBlur, onBlur])
|
||||
|
||||
useEffect(() => {
|
||||
if (overrideValue) {
|
||||
helpers.setValue(overrideValue)
|
||||
@ -331,31 +365,12 @@ function InputInner ({
|
||||
<InputGroup hasValidation className={inputGroupClassName}>
|
||||
{prepend}
|
||||
<BootstrapForm.Control
|
||||
onKeyDown={(e) => {
|
||||
const metaOrCtrl = e.metaKey || e.ctrlKey
|
||||
if (metaOrCtrl) {
|
||||
if (e.key === 'Enter') formik?.submitForm()
|
||||
}
|
||||
|
||||
if (onKeyDown) onKeyDown(e)
|
||||
}}
|
||||
ref={innerRef}
|
||||
{...field} {...props}
|
||||
onChange={(e) => {
|
||||
field.onChange(e)
|
||||
|
||||
if (storageKey) {
|
||||
window.localStorage.setItem(storageKey, e.target.value)
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
onChange(formik, e)
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onBlur?.(e)
|
||||
onBlur && onBlur(e)
|
||||
}}
|
||||
{...field}
|
||||
{...props}
|
||||
onKeyDown={onKeyDownInner}
|
||||
onChange={onChangeInner}
|
||||
onBlur={onBlurInner}
|
||||
isInvalid={invalid}
|
||||
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
|
||||
/>
|
||||
@ -393,25 +408,39 @@ function InputInner ({
|
||||
)
|
||||
}
|
||||
|
||||
export function UserSuggest ({ query, onSelect, dropdownStyle, children }) {
|
||||
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
|
||||
export function UserSuggest ({
|
||||
query, onSelect, dropdownStyle, children,
|
||||
transformUser = user => user, selectWithTab = true, filterUsers = () => true
|
||||
}) {
|
||||
const [getUsers] = useLazyQuery(TOP_USERS, {
|
||||
onCompleted: data => {
|
||||
setSuggestions({ array: data.topUsers.users, index: 0 })
|
||||
setSuggestions({
|
||||
array: data.topUsers.users
|
||||
.filter((...args) => filterUsers(query, ...args))
|
||||
.map(transformUser),
|
||||
index: 0
|
||||
})
|
||||
}
|
||||
})
|
||||
const [getSuggestions] = useLazyQuery(USER_SEARCH, {
|
||||
onCompleted: data => {
|
||||
setSuggestions({ array: data.searchUsers, index: 0 })
|
||||
setSuggestions({
|
||||
array: data.searchUsers
|
||||
.filter((...args) => filterUsers(query, ...args))
|
||||
.map(transformUser),
|
||||
index: 0
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
|
||||
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
|
||||
const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (query !== undefined) {
|
||||
const q = query?.replace(/^[@ ]+|[ ]+$/g, '')
|
||||
// remove both the leading @ and any @domain after nym
|
||||
const q = query?.replace(/^[@ ]+|[ ]+$/g, '').replace(/@[^\s]*$/, '')
|
||||
if (q === '') {
|
||||
getUsers({ variables: { by: 'stacked', when: 'week', limit: 5 } })
|
||||
} else {
|
||||
@ -448,6 +477,9 @@ export function UserSuggest ({ query, onSelect, dropdownStyle, children }) {
|
||||
break
|
||||
case 'Tab':
|
||||
case 'Enter':
|
||||
if (e.code === 'Tab' && !selectWithTab) {
|
||||
break
|
||||
}
|
||||
if (suggestions.array?.length === 0) {
|
||||
break
|
||||
}
|
||||
@ -465,7 +497,7 @@ export function UserSuggest ({ query, onSelect, dropdownStyle, children }) {
|
||||
}, [onSelect, resetSuggestions, suggestions])
|
||||
return (
|
||||
<>
|
||||
{children?.({ onKeyDown })}
|
||||
{children?.({ onKeyDown, resetSuggestions })}
|
||||
<Dropdown show={suggestions.array.length > 0} style={dropdownStyle}>
|
||||
<Dropdown.Menu className={styles.suggestionsMenu}>
|
||||
{suggestions.array.map((v, i) =>
|
||||
@ -485,25 +517,37 @@ export function UserSuggest ({ query, onSelect, dropdownStyle, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function InputUserSuggest ({ label, groupClassName, ...props }) {
|
||||
export function InputUserSuggest ({
|
||||
label, groupClassName, transformUser, filterUsers,
|
||||
selectWithTab, onChange, transformQuery, ...props
|
||||
}) {
|
||||
const [ovalue, setOValue] = useState()
|
||||
const [query, setQuery] = useState()
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
<UserSuggest
|
||||
onSelect={setOValue}
|
||||
transformUser={transformUser}
|
||||
filterUsers={filterUsers}
|
||||
selectWithTab={selectWithTab}
|
||||
onSelect={(v) => {
|
||||
// HACK ... ovalue does not trigger onChange
|
||||
onChange && onChange(undefined, { target: { value: v } })
|
||||
setOValue(v)
|
||||
}}
|
||||
query={query}
|
||||
>
|
||||
{({ onKeyDown }) => (
|
||||
{({ onKeyDown, resetSuggestions }) => (
|
||||
<InputInner
|
||||
{...props}
|
||||
autoComplete='off'
|
||||
onChange={(_, e) => {
|
||||
onChange={(formik, e) => {
|
||||
onChange && onChange(formik, e)
|
||||
setOValue(e.target.value)
|
||||
setQuery(e.target.value.replace(/^[@ ]+|[ ]+$/g, ''))
|
||||
}}
|
||||
overrideValue={ovalue}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => setTimeout(resetSuggestions, 100)}
|
||||
/>
|
||||
)}
|
||||
</UserSuggest>
|
||||
@ -602,7 +646,7 @@ export function Form ({
|
||||
}
|
||||
}, [])
|
||||
|
||||
function clearLocalStorage (values) {
|
||||
const clearLocalStorage = useCallback((values) => {
|
||||
Object.keys(values).forEach(v => {
|
||||
window.localStorage.removeItem(storageKeyPrefix + '-' + v)
|
||||
if (Array.isArray(values[v])) {
|
||||
@ -615,7 +659,7 @@ export function Form ({
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [storageKeyPrefix])
|
||||
|
||||
// if `invoiceable` is set,
|
||||
// support for payment per invoice if they are lurking or don't have enough balance
|
||||
@ -627,6 +671,19 @@ export function Form ({
|
||||
onSubmit = useInvoiceable(onSubmit, { callback: clearLocalStorage, ...options })
|
||||
}
|
||||
|
||||
const onSubmitInner = useCallback(async (values, ...args) => {
|
||||
try {
|
||||
if (onSubmit) {
|
||||
const options = await onSubmit(values, ...args)
|
||||
if (!storageKeyPrefix || options?.keepLocalStorage) return
|
||||
clearLocalStorage(values)
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
toaster.danger(err.message || err.toString?.())
|
||||
}
|
||||
}, [onSubmit, toaster, clearLocalStorage, storageKeyPrefix])
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initial}
|
||||
@ -634,18 +691,7 @@ export function Form ({
|
||||
validationSchema={schema}
|
||||
initialTouched={validateImmediately && initial}
|
||||
validateOnBlur={false}
|
||||
onSubmit={async (values, ...args) => {
|
||||
try {
|
||||
if (onSubmit) {
|
||||
const options = await onSubmit(values, ...args)
|
||||
if (!storageKeyPrefix || options?.keepLocalStorage) return
|
||||
clearLocalStorage(values)
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
toaster.danger(err.message || err.toString?.())
|
||||
}
|
||||
}}
|
||||
onSubmit={onSubmitInner}
|
||||
innerRef={innerRef}
|
||||
>
|
||||
<FormikForm {...props} noValidate>
|
||||
|
@ -22,7 +22,7 @@ import MuteDropdownItem from './mute'
|
||||
export default function ItemInfo ({
|
||||
item, pendingSats, full, commentsText = 'comments',
|
||||
commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText,
|
||||
onQuoteReply
|
||||
onQuoteReply, nofollow
|
||||
}) {
|
||||
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
|
||||
const me = useMe()
|
||||
@ -64,6 +64,7 @@ export default function ItemInfo ({
|
||||
<span> \ </span>
|
||||
</>}
|
||||
<Link
|
||||
rel={nofollow}
|
||||
href={`/items/${item.id}`} onClick={(e) => {
|
||||
const viewedAt = commentsViewedAt(item)
|
||||
if (viewedAt) {
|
||||
@ -91,7 +92,7 @@ export default function ItemInfo ({
|
||||
{embellishUser}
|
||||
</Link>
|
||||
<span> </span>
|
||||
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
|
||||
<Link rel={nofollow} href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
|
||||
{timeSince(new Date(item.createdAt))}
|
||||
</Link>
|
||||
{item.prior &&
|
||||
|
@ -20,7 +20,7 @@ import AdIcon from '../svgs/advertisement-fill.svg'
|
||||
|
||||
export function SearchTitle ({ title }) {
|
||||
return reactStringReplace(title, /\*\*\*([^*]+)\*\*\*/g, (match, i) => {
|
||||
return <mark key={`strong-${match}`}>{match}</mark>
|
||||
return <mark key={`strong-${match}-${i}`}>{match}</mark>
|
||||
})
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
||||
const [pendingSats, setPendingSats] = useState(0)
|
||||
|
||||
const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL)
|
||||
const nofollow = item.sats + item.boost < NOFOLLOW_LIMIT && !item.position ? 'nofollow' : ''
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -50,6 +51,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
||||
<div className={styles.hunk}>
|
||||
<div className={`${styles.main} flex-wrap`}>
|
||||
<Link
|
||||
rel={nofollow}
|
||||
href={`/items/${item.id}`}
|
||||
onClick={(e) => {
|
||||
const viewedAt = commentsViewedAt(item)
|
||||
@ -76,7 +78,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
||||
<>
|
||||
<a
|
||||
className={styles.link} target='_blank' href={item.url}
|
||||
rel={`noreferrer ${item.sats + item.boost >= NOFOLLOW_LIMIT ? '' : 'nofollow'} noopener`}
|
||||
rel={`noreferrer ${nofollow} noopener`}
|
||||
>
|
||||
{item.url.replace(/(^https?:|^)\/\//, '')}
|
||||
</a>
|
||||
@ -85,6 +87,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
||||
<ItemInfo
|
||||
full={full} item={item} pendingSats={pendingSats}
|
||||
onQuoteReply={replyRef?.current?.quoteReply}
|
||||
nofollow={nofollow}
|
||||
embellishUser={Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>}
|
||||
/>
|
||||
{belowTitle}
|
||||
|
@ -20,7 +20,7 @@ export function SearchText ({ text }) {
|
||||
<div className={styles.text}>
|
||||
<p className={styles.p}>
|
||||
{reactStringReplace(text, /\*\*\*([^*]+)\*\*\*/g, (match, i) => {
|
||||
return <mark key={`strong-${match}`}>{match}</mark>
|
||||
return <mark key={`strong-${match}-${i}`}>{match}</mark>
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
@ -119,14 +119,15 @@ export default memo(function Text ({ nofollow, imgproxyUrls, children, tab, ...o
|
||||
p: P,
|
||||
code: Code,
|
||||
a: ({ node, href, children, ...props }) => {
|
||||
children = children ? Array.isArray(children) ? children : [children] : []
|
||||
// don't allow zoomable images to be wrapped in links
|
||||
if (children?.some(e => e?.props?.node?.tagName === 'img')) {
|
||||
if (children.some(e => e?.props?.node?.tagName === 'img')) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// If [text](url) was parsed as <a> and text is not empty and not a link itself,
|
||||
// we don't render it as an image since it was probably a concious choice to include text.
|
||||
const text = children?.[0]
|
||||
const text = children[0]
|
||||
if (!!text && !/^https?:\/\//.test(text)) {
|
||||
return <a target='_blank' rel={`noreferrer ${nofollow ? 'nofollow' : ''} noopener`} href={href}>{text}</a>
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ import { useQuery } from '@apollo/client'
|
||||
import { SETTINGS } from '../fragments/users'
|
||||
|
||||
async function discussionToEvent (item) {
|
||||
const pubkey = await window.nostr.getPublicKey()
|
||||
const createdAt = Math.floor(Date.now() / 1000)
|
||||
|
||||
return {
|
||||
@ -14,8 +13,7 @@ async function discussionToEvent (item) {
|
||||
kind: 30023,
|
||||
content: item.text,
|
||||
tags: [
|
||||
['d', `https://stacker.news/items/${item.id}`],
|
||||
['a', `30023:${pubkey}:https://stacker.news/items/${item.id}`, 'wss://relay.nostr.band'],
|
||||
['d', item.id.toString()],
|
||||
['title', item.title],
|
||||
['published_at', createdAt.toString()]
|
||||
]
|
||||
|
@ -52,6 +52,7 @@ export const ANON_COMMENT_FEE = 100
|
||||
export const SSR = typeof window === 'undefined'
|
||||
export const MAX_FORWARDS = 5
|
||||
export const LNURLP_COMMENT_MAX_LENGTH = 1000
|
||||
export const GLOBAL_SEED = 616
|
||||
|
||||
export const FOUND_BLURBS = [
|
||||
'The harsh frontier is no place for the unprepared. This hat will protect you from the sun, dust, and other elements Mother Nature throws your way.',
|
||||
|
5781
package-lock.json
generated
5781
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
67
package.json
67
package.json
@ -11,81 +11,79 @@
|
||||
"worker": "tsx --trace-warnings worker/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.8.1",
|
||||
"@apollo/server": "^4.9.1",
|
||||
"@as-integrations/next": "^2.0.1",
|
||||
"@auth/prisma-adapter": "^1.0.1",
|
||||
"@apollo/client": "^3.8.5",
|
||||
"@apollo/server": "^4.9.4",
|
||||
"@as-integrations/next": "^2.0.2",
|
||||
"@auth/prisma-adapter": "^1.0.3",
|
||||
"@graphql-tools/schema": "^10.0.0",
|
||||
"@noble/curves": "^1.1.0",
|
||||
"@opensearch-project/opensearch": "^2.3.1",
|
||||
"@prisma/client": "^5.1.1",
|
||||
"@noble/curves": "^1.2.0",
|
||||
"@opensearch-project/opensearch": "^2.4.0",
|
||||
"@prisma/client": "^5.4.2",
|
||||
"@slack/web-api": "^6.9.0",
|
||||
"acorn": "^8.10.0",
|
||||
"ajv": "^8.12.0",
|
||||
"async-retry": "^1.3.1",
|
||||
"aws-sdk": "^2.1437.0",
|
||||
"aws-sdk": "^2.1473.0",
|
||||
"babel-plugin-inline-react-svg": "^2.0.2",
|
||||
"bech32": "^2.0.0",
|
||||
"bolt11": "^1.4.1",
|
||||
"bootstrap": "^5.3.1",
|
||||
"bootstrap": "^5.3.2",
|
||||
"canonical-json": "0.0.4",
|
||||
"clipboard-copy": "^4.0.1",
|
||||
"cross-fetch": "^4.0.0",
|
||||
"domino": "^2.1.6",
|
||||
"formik": "^2.4.3",
|
||||
"formik": "^2.4.5",
|
||||
"github-slugger": "^2.0.0",
|
||||
"graphql": "^16.8.0",
|
||||
"graphql": "^16.8.1",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
"jose1": "npm:jose@^1.27.2",
|
||||
"ln-service": "^56.11.0",
|
||||
"mathjs": "^11.9.1",
|
||||
"mdast-util-find-and-replace": "^3.0.0",
|
||||
"ln-service": "^57.0.1",
|
||||
"mathjs": "^11.11.2",
|
||||
"mdast-util-find-and-replace": "^3.0.1",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-gfm": "^3.0.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"next": "^13.4.16",
|
||||
"next-auth": "^4.23.1",
|
||||
"next-plausible": "^3.10.2",
|
||||
"next": "^13.5.4",
|
||||
"next-auth": "^4.23.2",
|
||||
"next-plausible": "^3.11.1",
|
||||
"next-seo": "^6.1.0",
|
||||
"node-s3-url-encode": "^0.0.4",
|
||||
"nodemailer": "^6.9.4",
|
||||
"nodemailer": "^6.9.6",
|
||||
"nostr": "^0.2.8",
|
||||
"nprogress": "^0.2.0",
|
||||
"opentimestamps": "^0.4.9",
|
||||
"page-metadata-parser": "^1.1.4",
|
||||
"pageres": "^7.1.0",
|
||||
"pg-boss": "^9.0.3",
|
||||
"prisma": "^5.1.1",
|
||||
"prisma": "^5.4.2",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-avatar-editor": "^13.0.0",
|
||||
"react-bootstrap": "^2.8.0",
|
||||
"react-bootstrap": "^2.9.0",
|
||||
"react-countdown": "^2.3.5",
|
||||
"react-datepicker": "^4.18.0",
|
||||
"react-datepicker": "^4.20.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-longpressable": "^1.1.1",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-markdown": "^9.0.0",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-textarea-autosize": "^8.5.2",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-twitter-embed": "^4.0.4",
|
||||
"react-youtube": "^10.1.0",
|
||||
"recharts": "^2.7.3",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"recharts": "^2.9.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remove-markdown": "^0.5.0",
|
||||
"sass": "^1.65.1",
|
||||
"sass": "^1.69.3",
|
||||
"serviceworker-storage": "^0.1.0",
|
||||
"textarea-caret": "^3.1.0",
|
||||
"tldts": "^6.0.14",
|
||||
"tldts": "^6.0.16",
|
||||
"tsx": "^3.13.0",
|
||||
"typescript": "^5.1.6",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"url-unshort": "^6.1.0",
|
||||
"web-push": "^3.6.4",
|
||||
"web-push": "^3.6.6",
|
||||
"webln": "^0.3.2",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack": "^5.89.0",
|
||||
"workbox-navigation-preload": "^7.0.0",
|
||||
"workbox-precaching": "^7.0.0",
|
||||
"workbox-recipes": "^7.0.0",
|
||||
@ -93,7 +91,7 @@
|
||||
"workbox-strategies": "^7.0.0",
|
||||
"workbox-webpack-plugin": "^7.0.0",
|
||||
"workbox-window": "^7.0.0",
|
||||
"yup": "^1.2.0"
|
||||
"yup": "^1.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18.17.0"
|
||||
@ -107,9 +105,8 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.10",
|
||||
"@next/eslint-plugin-next": "^13.4.16",
|
||||
"eslint": "^8.47.0",
|
||||
"@next/eslint-plugin-next": "^13.5.4",
|
||||
"eslint": "^8.51.0",
|
||||
"standard": "^17.1.0"
|
||||
}
|
||||
}
|
||||
|
@ -7,9 +7,8 @@ import EmailProvider from 'next-auth/providers/email'
|
||||
import prisma from '../../../api/models'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter'
|
||||
import { decode, getToken } from 'next-auth/jwt'
|
||||
import { getToken } from 'next-auth/jwt'
|
||||
import { NodeNextRequest } from 'next/dist/server/base-http/node'
|
||||
import jose1 from 'jose1'
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { sendUserNotification } from '../../../api/webPush'
|
||||
|
||||
@ -48,7 +47,7 @@ function getCallbacks (req) {
|
||||
}
|
||||
|
||||
// sign them up for the newsletter
|
||||
if (profile.email) {
|
||||
if (user?.email && process.env.LIST_MONK_URL && process.env.LIST_MONK_AUTH) {
|
||||
fetch(process.env.LIST_MONK_URL + '/api/subscribers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -56,7 +55,7 @@ function getCallbacks (req) {
|
||||
Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: profile.email,
|
||||
email: user.email,
|
||||
name: 'blank',
|
||||
lists: [2],
|
||||
status: 'enabled',
|
||||
@ -196,38 +195,6 @@ export const getAuthOptions = req => ({
|
||||
session: {
|
||||
strategy: 'jwt'
|
||||
},
|
||||
jwt: {
|
||||
decode: async ({ token, secret }) => {
|
||||
// attempt to decode using new jwt decode
|
||||
try {
|
||||
const _token = await decode({ token, secret })
|
||||
if (_token) {
|
||||
return _token
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('next-auth v4 jwt decode failed', err)
|
||||
}
|
||||
|
||||
// attempt to decode using old jwt decode from next-auth v3
|
||||
// https://github.com/nextauthjs/next-auth/blob/ab764e379377f9ffd68ff984b163c0edb5fc4bda/src/lib/jwt.js#L52
|
||||
try {
|
||||
const signingKey = jose1.JWK.asKey(JSON.parse(process.env.JWT_SIGNING_PRIVATE_KEY))
|
||||
const verificationOptions = {
|
||||
maxTokenAge: '2592000s',
|
||||
algorithms: ['HS512']
|
||||
}
|
||||
const _token = jose1.JWT.verify(token, signingKey, verificationOptions)
|
||||
if (_token) {
|
||||
console.log('next-auth v3 jwt decode success')
|
||||
return _token
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('next-auth v3 jwt decode failed', err)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
verifyRequest: '/email',
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { Checkbox, Form, Input, SubmitButton } from '../components/form'
|
||||
import { Checkbox, Form, Input, InputUserSuggest, SubmitButton } from '../components/form'
|
||||
import Link from 'next/link'
|
||||
import Button from 'react-bootstrap/Button'
|
||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||
@ -311,14 +311,13 @@ export function LnAddrWithdrawal () {
|
||||
let options
|
||||
try {
|
||||
options = await lnAddrOptions(e.target.value)
|
||||
setAddrOptions(options)
|
||||
setFormSchema(lnAddrSchema(options))
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
setAddrOptions(defaultOptions)
|
||||
return
|
||||
setFormSchema(lnAddrSchema())
|
||||
}
|
||||
|
||||
setAddrOptions(options)
|
||||
setFormSchema(lnAddrSchema(options))
|
||||
}, 500, [setAddrOptions, setFormSchema])
|
||||
|
||||
return (
|
||||
@ -349,12 +348,18 @@ export function LnAddrWithdrawal () {
|
||||
router.push(`/withdrawals/${data.sendToLnAddr.id}`)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
<InputUserSuggest
|
||||
label='lightning address'
|
||||
name='addr'
|
||||
required
|
||||
autoFocus
|
||||
onChange={onAddrChange}
|
||||
transformUser={user => ({ ...user, name: `${user.name}@stacker.news` })}
|
||||
selectWithTab={false}
|
||||
filterUsers={(query) => {
|
||||
const [, domain] = query.split('@')
|
||||
return !domain || 'stacker.news'.startsWith(domain)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label='amount'
|
||||
|
14
prisma/migrations/20230817221949_trust_arcs/migration.sql
Normal file
14
prisma/migrations/20230817221949_trust_arcs/migration.sql
Normal file
@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Arc" (
|
||||
"fromId" INTEGER NOT NULL,
|
||||
"toId" INTEGER NOT NULL,
|
||||
"zapTrust" DOUBLE PRECISION NOT NULL,
|
||||
|
||||
CONSTRAINT "Arc_pkey" PRIMARY KEY ("fromId","toId")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Arc" ADD CONSTRAINT "Arc_fromId_fkey" FOREIGN KEY ("fromId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Arc" ADD CONSTRAINT "Arc_toId_fkey" FOREIGN KEY ("toId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Arc_toId_fromId_idx" ON "Arc"("toId", "fromId");
|
181
prisma/migrations/20231013002652_pwot_views/migration.sql
Normal file
181
prisma/migrations/20231013002652_pwot_views/migration.sql
Normal file
@ -0,0 +1,181 @@
|
||||
CREATE OR REPLACE VIEW zap_rank_personal_constants AS
|
||||
SELECT
|
||||
5000.0 AS boost_per_vote,
|
||||
1.2 AS vote_power,
|
||||
1.3 AS vote_decay,
|
||||
3.0 AS age_wait_hours,
|
||||
0.5 AS comment_scaler,
|
||||
1.2 AS boost_power,
|
||||
1.6 AS boost_decay,
|
||||
616 AS global_viewer_id,
|
||||
interval '7 days' AS item_age_bound,
|
||||
interval '7 days' AS user_last_seen_bound,
|
||||
0.9 AS max_personal_viewer_vote_ratio,
|
||||
0.1 AS min_viewer_votes;
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS zap_rank_personal_view;
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS zap_rank_personal_view AS
|
||||
WITH item_votes AS (
|
||||
SELECT "Item".id, "Item"."parentId", "Item".boost, "Item".created_at, "Item"."weightedComments", "ItemAct"."userId" AS "voterId",
|
||||
LOG((SUM("ItemAct".msats) FILTER (WHERE "ItemAct".act IN ('TIP', 'FEE'))) / 1000.0) AS "vote",
|
||||
GREATEST(LOG((SUM("ItemAct".msats) FILTER (WHERE "ItemAct".act = 'DONT_LIKE_THIS')) / 1000.0), 0) AS "downVote"
|
||||
FROM "Item"
|
||||
CROSS JOIN zap_rank_personal_constants
|
||||
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
|
||||
WHERE (
|
||||
"ItemAct"."userId" <> "Item"."userId" AND "ItemAct".act IN ('TIP', 'FEE', 'DONT_LIKE_THIS')
|
||||
OR "ItemAct".act = 'BOOST' AND "ItemAct"."userId" = "Item"."userId"
|
||||
)
|
||||
AND "Item".created_at >= now_utc() - item_age_bound
|
||||
GROUP BY "Item".id, "Item"."parentId", "Item".boost, "Item".created_at, "Item"."weightedComments", "ItemAct"."userId"
|
||||
HAVING SUM("ItemAct".msats) > 1000
|
||||
), viewer_votes AS (
|
||||
SELECT item_votes.id, item_votes."parentId", item_votes.boost, item_votes.created_at,
|
||||
item_votes."weightedComments", "Arc"."fromId" AS "viewerId",
|
||||
GREATEST("Arc"."zapTrust", g."zapTrust", 0) * item_votes."vote" AS "weightedVote",
|
||||
GREATEST("Arc"."zapTrust", g."zapTrust", 0) * item_votes."downVote" AS "weightedDownVote"
|
||||
FROM item_votes
|
||||
CROSS JOIN zap_rank_personal_constants
|
||||
LEFT JOIN "Arc" ON "Arc"."toId" = item_votes."voterId"
|
||||
LEFT JOIN "Arc" g ON g."fromId" = global_viewer_id AND g."toId" = item_votes."voterId"
|
||||
AND ("Arc"."zapTrust" IS NOT NULL OR g."zapTrust" IS NOT NULL)
|
||||
), viewer_weighted_votes AS (
|
||||
SELECT viewer_votes.id, viewer_votes."parentId", viewer_votes.boost, viewer_votes.created_at, viewer_votes."viewerId",
|
||||
viewer_votes."weightedComments", SUM(viewer_votes."weightedVote") AS "weightedVotes",
|
||||
SUM(viewer_votes."weightedDownVote") AS "weightedDownVotes"
|
||||
FROM viewer_votes
|
||||
GROUP BY viewer_votes.id, viewer_votes."parentId", viewer_votes.boost, viewer_votes.created_at, viewer_votes."viewerId", viewer_votes."weightedComments"
|
||||
), viewer_zaprank AS (
|
||||
SELECT l.id, l."parentId", l.boost, l.created_at, l."viewerId", l."weightedComments",
|
||||
GREATEST(l."weightedVotes", g."weightedVotes") AS "weightedVotes", GREATEST(l."weightedDownVotes", g."weightedDownVotes") AS "weightedDownVotes"
|
||||
FROM viewer_weighted_votes l
|
||||
CROSS JOIN zap_rank_personal_constants
|
||||
JOIN users ON users.id = l."viewerId"
|
||||
JOIN viewer_weighted_votes g ON l.id = g.id AND g."viewerId" = global_viewer_id
|
||||
WHERE (l."weightedVotes" > min_viewer_votes
|
||||
AND g."weightedVotes" / l."weightedVotes" <= max_personal_viewer_vote_ratio
|
||||
AND users."lastSeenAt" >= now_utc() - user_last_seen_bound)
|
||||
OR l."viewerId" = global_viewer_id
|
||||
GROUP BY l.id, l."parentId", l.boost, l.created_at, l."viewerId", l."weightedVotes", l."weightedComments",
|
||||
g."weightedVotes", l."weightedDownVotes", g."weightedDownVotes", min_viewer_votes
|
||||
HAVING GREATEST(l."weightedVotes", g."weightedVotes") > min_viewer_votes OR l.boost > 0
|
||||
), viewer_fractions_zaprank AS (
|
||||
SELECT z.*,
|
||||
(CASE WHEN z."weightedVotes" - z."weightedDownVotes" > 0 THEN
|
||||
GREATEST(z."weightedVotes" - z."weightedDownVotes", POWER(z."weightedVotes" - z."weightedDownVotes", vote_power))
|
||||
ELSE
|
||||
z."weightedVotes" - z."weightedDownVotes"
|
||||
END + z."weightedComments" * CASE WHEN z."parentId" IS NULL THEN comment_scaler ELSE 0 END) AS tf_numerator,
|
||||
POWER(GREATEST(age_wait_hours, EXTRACT(EPOCH FROM (now_utc() - z.created_at))/3600), vote_decay) AS decay_denominator,
|
||||
(POWER(z.boost/boost_per_vote, boost_power)
|
||||
/
|
||||
POWER(GREATEST(age_wait_hours, EXTRACT(EPOCH FROM (now_utc() - z.created_at))/3600), boost_decay)) AS boost_addend
|
||||
FROM viewer_zaprank z, zap_rank_personal_constants
|
||||
)
|
||||
SELECT z.id, z."parentId", z."viewerId",
|
||||
COALESCE(tf_numerator, 0) / decay_denominator + boost_addend AS tf_hot_score,
|
||||
COALESCE(tf_numerator, 0) AS tf_top_score
|
||||
FROM viewer_fractions_zaprank z
|
||||
WHERE tf_numerator > 0 OR boost_addend > 0;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS zap_rank_personal_view_viewer_id_idx ON zap_rank_personal_view("viewerId", id);
|
||||
CREATE INDEX IF NOT EXISTS hot_tf_zap_rank_personal_view_idx ON zap_rank_personal_view("viewerId", tf_hot_score DESC NULLS LAST, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS top_tf_zap_rank_personal_view_idx ON zap_rank_personal_view("viewerId", tf_top_score DESC NULLS LAST, id DESC);
|
||||
|
||||
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"."meDontLike", false) AS "meDontLike", '
|
||||
|| ' "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", '
|
||||
|| ' bool_or(act = ''DONT_LIKE_THIS'') AS "meDontLike" '
|
||||
|| ' 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 <@ $1::TEXT::LTREE ' || _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 <@ $1::TEXT::LTREE ' || _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
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_ranked_views_jobs()
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
INSERT INTO pgboss.job (name) values ('trust');
|
||||
UPDATE pgboss.schedule SET cron = '*/5 * * * *' WHERE name = 'rankViews';
|
||||
return 0;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
return 0;
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT update_ranked_views_jobs();
|
||||
|
@ -95,6 +95,8 @@ model User {
|
||||
hideIsContributor Boolean @default(false)
|
||||
muters Mute[] @relation("muter")
|
||||
muteds Mute[] @relation("muted")
|
||||
ArcOut Arc[] @relation("fromUser")
|
||||
ArcIn Arc[] @relation("toUser")
|
||||
|
||||
@@index([createdAt], map: "users.created_at_index")
|
||||
@@index([inviteId], map: "users.inviteId_index")
|
||||
@ -113,6 +115,17 @@ model Mute {
|
||||
@@index([mutedId, muterId])
|
||||
}
|
||||
|
||||
model Arc {
|
||||
fromId Int
|
||||
fromUser User @relation("fromUser", fields: [fromId], references: [id], onDelete: Cascade)
|
||||
toId Int
|
||||
toUser User @relation("toUser", fields: [toId], references: [id], onDelete: Cascade)
|
||||
zapTrust Float
|
||||
|
||||
@@id([fromId, toId])
|
||||
@@index([toId, fromId])
|
||||
}
|
||||
|
||||
model Streak {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
@ -1,5 +1,27 @@
|
||||
const { PrismaClient } = require('@prisma/client')
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
function selectRandomly (items) {
|
||||
return items[Math.floor(Math.random() * items.length)]
|
||||
}
|
||||
|
||||
async function addComments (parentIds, nComments, userIds, commentText) {
|
||||
const clonedParentIds = [...parentIds]
|
||||
const clonedUserIds = [...userIds]
|
||||
for (let i = 0; i < nComments; i++) {
|
||||
const selectedParent = selectRandomly(clonedParentIds)
|
||||
const selectedUserId = selectRandomly(clonedUserIds)
|
||||
const newComment = await prisma.item.create({
|
||||
data: {
|
||||
parentId: selectedParent,
|
||||
userId: selectedUserId,
|
||||
text: commentText
|
||||
}
|
||||
})
|
||||
clonedParentIds.push(newComment.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function main () {
|
||||
const k00b = await prisma.user.upsert({
|
||||
where: { name: 'k00b' },
|
||||
@ -155,6 +177,17 @@ async function main () {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const bigCommentPost = await prisma.item.create({
|
||||
data: {
|
||||
title: 'a discussion post with a lot of comments',
|
||||
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
|
||||
userId: k00b.id,
|
||||
subName: 'bitcoin'
|
||||
}
|
||||
})
|
||||
|
||||
addComments([bigCommentPost.id], 200, [k00b.id, anon.id, satoshi.id, greg.id, stan.id], 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.')
|
||||
}
|
||||
main()
|
||||
.catch(e => {
|
||||
|
30
sw/index.js
30
sw/index.js
@ -33,10 +33,36 @@ self.addEventListener('install', () => {
|
||||
// The browser may use own caching (HTTP cache).
|
||||
// Also, the offline fallback only works if request matched a route
|
||||
setDefaultHandler(new NetworkOnly({
|
||||
// tell us why a request failed in dev
|
||||
plugins: [{
|
||||
fetchDidFail: async (args) => {
|
||||
// tell us why a request failed in dev
|
||||
process.env.NODE_ENV !== 'production' && console.log('fetch did fail', ...args)
|
||||
},
|
||||
fetchDidSucceed: async ({ request, response, event, state }) => {
|
||||
if (
|
||||
response.ok &&
|
||||
request.headers.get('x-nextjs-data') &&
|
||||
response.headers.get('x-nextjs-matched-path') &&
|
||||
response.headers.get('content-type') === 'application/json' &&
|
||||
response.headers.get('content-length') === '2' &&
|
||||
response.status === 200) {
|
||||
console.log('service worker detected a successful yet empty nextjs SSR data response')
|
||||
console.log('nextjs has a bug where it returns a 200 with empty data when it should return a 404')
|
||||
console.log('see https://github.com/vercel/next.js/issues/56852')
|
||||
console.log('HACK ... intercepting response and returning 404')
|
||||
|
||||
const headers = new Headers(response.headers)
|
||||
headers.delete('x-nextjs-matched-path')
|
||||
headers.delete('content-type')
|
||||
headers.delete('content-length')
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers,
|
||||
ok: false
|
||||
})
|
||||
}
|
||||
return response
|
||||
}
|
||||
}]
|
||||
}))
|
||||
@ -51,7 +77,7 @@ self.addEventListener('push', async function (event) {
|
||||
const { tag } = payload.options
|
||||
event.waitUntil((async () => {
|
||||
// TIP and EARN notifications simply replace the previous notifications
|
||||
if (!tag || ['TIP', 'EARN'].includes(tag.split('-')[0])) {
|
||||
if (!tag || ['TIP', 'FORWARDEDTIP', 'EARN'].includes(tag.split('-')[0])) {
|
||||
return self.registration.showNotification(payload.title, payload.options)
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import serialize from '../api/resolvers/serial.js'
|
||||
// import { sendUserNotification } from '../api/webPush/index.js'
|
||||
import { sendUserNotification } from '../api/webPush/index.js'
|
||||
import { ANON_USER_ID } from '../lib/constants.js'
|
||||
// import { msatsToSats, numWithUnits } from '../lib/format.js'
|
||||
import { msatsToSats, numWithUnits } from '../lib/format.js'
|
||||
|
||||
const ITEM_EACH_REWARD = 4.0
|
||||
const UPVOTE_EACH_REWARD = 4.0
|
||||
@ -145,10 +145,8 @@ export function earn ({ models }) {
|
||||
// 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 => {
|
||||
const notifications = {}
|
||||
for (const earner of earners) {
|
||||
const earnings = Math.floor(parseFloat(earner.proportion) * sum)
|
||||
total += earnings
|
||||
if (total > sum) {
|
||||
@ -162,13 +160,32 @@ export function earn ({ models }) {
|
||||
await serialize(models,
|
||||
models.$executeRaw`SELECT earn(${earner.userId}::INTEGER, ${earnings},
|
||||
${now}::timestamp without time zone, ${earner.type}::"EarnType", ${earner.id}::INTEGER, ${earner.rank}::INTEGER)`)
|
||||
// sendUserNotification(earner.userId, {
|
||||
// title: `you stacked ${numWithUnits(msatsToSats(earnings), { abbreviate: false })} in rewards`,
|
||||
// tag: 'EARN'
|
||||
// }).catch(console.error)
|
||||
notifications[earner.userId] = {
|
||||
...notifications[earner.userId],
|
||||
total: earnings + (notifications[earner.userId]?.total || 0),
|
||||
[earner.type]: { msats: earnings, rank: earner.rank }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Promise.allSettled(Object.entries(notifications).map(([userId, earnings]) =>
|
||||
sendUserNotification(parseInt(userId, 10), buildUserNotification(earnings))
|
||||
)).catch(console.error)
|
||||
|
||||
console.log('done', name)
|
||||
}
|
||||
}
|
||||
|
||||
function buildUserNotification (earnings) {
|
||||
const fmt = msats => numWithUnits(msatsToSats(msats, { abbreviate: false }))
|
||||
|
||||
const title = `you stacked ${fmt(earnings.total)} in rewards`
|
||||
const tag = 'EARN'
|
||||
let body = ''
|
||||
if (earnings.POST) body += `#${earnings.POST.rank} among posts for ${fmt(earnings.POST.msats)}\n`
|
||||
if (earnings.COMMENT) body += `#${earnings.COMMENT.rank} among comments for ${fmt(earnings.COMMENT.msats)}\n`
|
||||
if (earnings.TIP_POST) body += `#${earnings.TIP_POST.rank} in post zapping for ${fmt(earnings.TIP_POST.msats)}\n`
|
||||
if (earnings.TIP_COMMENT) body += `#${earnings.TIP_COMMENT.rank} in comment zapping for ${fmt(earnings.TIP_COMMENT.msats)}\n`
|
||||
|
||||
return { title, tag, body }
|
||||
}
|
||||
|
111
worker/trust.js
111
worker/trust.js
@ -8,13 +8,14 @@ export function trust ({ boss, models }) {
|
||||
console.timeLog('trust', 'getting graph')
|
||||
const graph = await getGraph(models)
|
||||
console.timeLog('trust', 'computing trust')
|
||||
const trust = await trustGivenGraph(graph)
|
||||
const [vGlobal, mPersonal] = await trustGivenGraph(graph)
|
||||
console.timeLog('trust', 'storing trust')
|
||||
await storeTrust(models, trust)
|
||||
console.timeEnd('trust')
|
||||
await storeTrust(models, graph, vGlobal, mPersonal)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw e
|
||||
} finally {
|
||||
console.timeEnd('trust')
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -28,9 +29,11 @@ const DISAGREE_MULT = 10
|
||||
// https://en.wikipedia.org/wiki/Normal_distribution#Quantile_function
|
||||
const Z_CONFIDENCE = 6.109410204869 // 99.9999999% confidence
|
||||
const SEEDS = [616, 6030, 946, 4502]
|
||||
const GLOBAL_ROOT = 616
|
||||
const SEED_WEIGHT = 0.25
|
||||
const AGAINST_MSAT_MIN = 1000
|
||||
const MSAT_MIN = 1000
|
||||
const SIG_DIFF = 0.1 // need to differ by at least 10 percent
|
||||
|
||||
/*
|
||||
Given a graph and start this function returns an object where
|
||||
@ -38,7 +41,7 @@ const MSAT_MIN = 1000
|
||||
*/
|
||||
function trustGivenGraph (graph) {
|
||||
// empty matrix of proper size nstackers x nstackers
|
||||
const mat = math.zeros(graph.length, graph.length, 'sparse')
|
||||
let mat = math.zeros(graph.length, graph.length, 'sparse')
|
||||
|
||||
// create a map of user id to position in matrix
|
||||
const posByUserId = {}
|
||||
@ -69,34 +72,45 @@ function trustGivenGraph (graph) {
|
||||
matT = math.add(math.multiply(1 - SEED_WEIGHT, matT), math.multiply(SEED_WEIGHT, original))
|
||||
}
|
||||
|
||||
console.timeLog('trust', 'normalizing result')
|
||||
// we normalize the result taking the z-score, then min-max to [0,1]
|
||||
// we remove seeds and 0 trust people from the result because they are known outliers
|
||||
// but we need to keep them in the result to keep positions correct
|
||||
function resultForId (id) {
|
||||
let result = math.squeeze(math.subset(math.transpose(matT), math.index(posByUserId[id], math.range(0, graph.length))))
|
||||
const outliers = SEEDS.concat([id])
|
||||
outliers.forEach(id => result.set([posByUserId[id]], 0))
|
||||
const withoutZero = math.filter(result, val => val > 0)
|
||||
// NOTE: this might be improved by using median and mad (modified z score)
|
||||
// given the distribution is skewed
|
||||
const mean = math.mean(withoutZero)
|
||||
const std = math.std(withoutZero)
|
||||
result = result.map(val => val >= 0 ? (val - mean) / std : 0)
|
||||
const min = math.min(result)
|
||||
const max = math.max(result)
|
||||
result = math.map(result, val => (val - min) / (max - min))
|
||||
outliers.forEach(id => result.set([posByUserId[id]], MAX_TRUST))
|
||||
return result
|
||||
console.timeLog('trust', 'transforming result')
|
||||
|
||||
const seedIdxs = SEEDS.map(id => posByUserId[id])
|
||||
const isOutlier = (fromIdx, idx) => [...seedIdxs, fromIdx].includes(idx)
|
||||
const sqapply = (mat, fn) => {
|
||||
let idx = 0
|
||||
return math.squeeze(math.apply(mat, 1, d => {
|
||||
const filtered = math.filter(d, (val, fidx) => {
|
||||
return val !== 0 && !isOutlier(idx, fidx[0])
|
||||
})
|
||||
idx++
|
||||
if (filtered.length === 0) return 0
|
||||
return fn(filtered)
|
||||
}))
|
||||
}
|
||||
|
||||
// turn the result vector into an object
|
||||
const result = {}
|
||||
resultForId(616).forEach((val, idx) => {
|
||||
result[graph[idx].id] = val
|
||||
console.timeLog('trust', 'normalizing')
|
||||
console.timeLog('trust', 'stats')
|
||||
mat = math.transpose(matT)
|
||||
const std = sqapply(mat, math.std) // math.squeeze(math.std(mat, 1))
|
||||
const mean = sqapply(mat, math.mean) // math.squeeze(math.mean(mat, 1))
|
||||
const zscore = math.map(mat, (val, idx) => {
|
||||
const zstd = math.subset(std, math.index(idx[0]))
|
||||
const zmean = math.subset(mean, math.index(idx[0]))
|
||||
return zstd ? (val - zmean) / zstd : 0
|
||||
})
|
||||
console.timeLog('trust', 'minmax')
|
||||
const min = sqapply(zscore, math.min) // math.squeeze(math.min(zscore, 1))
|
||||
const max = sqapply(zscore, math.max) // math.squeeze(math.max(zscore, 1))
|
||||
const mPersonal = math.map(zscore, (val, idx) => {
|
||||
const zmin = math.subset(min, math.index(idx[0]))
|
||||
const zmax = math.subset(max, math.index(idx[0]))
|
||||
const zrange = zmax - zmin
|
||||
if (val > zmax) return MAX_TRUST
|
||||
return zrange ? (val - zmin) / zrange : 0
|
||||
})
|
||||
const vGlobal = math.squeeze(math.row(mPersonal, posByUserId[GLOBAL_ROOT]))
|
||||
|
||||
return result
|
||||
return [vGlobal, mPersonal]
|
||||
}
|
||||
|
||||
/*
|
||||
@ -108,7 +122,7 @@ function trustGivenGraph (graph) {
|
||||
*/
|
||||
async function getGraph (models) {
|
||||
return await models.$queryRaw`
|
||||
SELECT id, array_agg(json_build_object(
|
||||
SELECT id, json_agg(json_build_object(
|
||||
'node', oid,
|
||||
'trust', CASE WHEN total_trust > 0 THEN trust / total_trust::float ELSE 0 END)) AS hops
|
||||
FROM (
|
||||
@ -144,9 +158,12 @@ async function getGraph (models) {
|
||||
FROM user_pair
|
||||
WHERE b_id <> ANY (${SEEDS})
|
||||
UNION ALL
|
||||
SELECT a_id AS id, seed_id AS oid, ${MAX_TRUST}::numeric/ARRAY_LENGTH(${SEEDS}::int[], 1) as trust
|
||||
SELECT a_id AS id, seed_id AS oid, ${MAX_TRUST}::numeric as trust
|
||||
FROM user_pair, unnest(${SEEDS}::int[]) seed_id
|
||||
GROUP BY a_id, a_total, seed_id
|
||||
UNION ALL
|
||||
SELECT a_id AS id, a_id AS oid, ${MAX_TRUST}::float as trust
|
||||
FROM user_pair
|
||||
)
|
||||
SELECT id, oid, trust, sum(trust) OVER (PARTITION BY id) AS total_trust
|
||||
FROM trust_pairs
|
||||
@ -155,13 +172,24 @@ async function getGraph (models) {
|
||||
ORDER BY id ASC`
|
||||
}
|
||||
|
||||
async function storeTrust (models, nodeTrust) {
|
||||
async function storeTrust (models, graph, vGlobal, mPersonal) {
|
||||
// convert nodeTrust into table literal string
|
||||
let values = ''
|
||||
for (const [id, trust] of Object.entries(nodeTrust)) {
|
||||
if (values) values += ','
|
||||
values += `(${id}, ${trust})`
|
||||
}
|
||||
let globalValues = ''
|
||||
let personalValues = ''
|
||||
vGlobal.forEach((val, [idx]) => {
|
||||
if (isNaN(val)) return
|
||||
if (globalValues) globalValues += ','
|
||||
globalValues += `(${graph[idx].id}, ${val}::FLOAT)`
|
||||
if (personalValues) personalValues += ','
|
||||
personalValues += `(${GLOBAL_ROOT}, ${graph[idx].id}, ${val}::FLOAT)`
|
||||
})
|
||||
|
||||
math.forEach(mPersonal, (val, [fromIdx, toIdx]) => {
|
||||
const globalVal = vGlobal.get([toIdx])
|
||||
if (isNaN(val) || val - globalVal <= SIG_DIFF) return
|
||||
if (personalValues) personalValues += ','
|
||||
personalValues += `(${graph[fromIdx].id}, ${graph[toIdx].id}, ${val}::FLOAT)`
|
||||
})
|
||||
|
||||
// update the trust of each user in graph
|
||||
await models.$transaction([
|
||||
@ -169,6 +197,13 @@ async function storeTrust (models, nodeTrust) {
|
||||
models.$executeRawUnsafe(
|
||||
`UPDATE users
|
||||
SET trust = g.trust
|
||||
FROM (values ${values}) g(id, trust)
|
||||
WHERE users.id = g.id`)])
|
||||
FROM (values ${globalValues}) g(id, trust)
|
||||
WHERE users.id = g.id`),
|
||||
models.$executeRawUnsafe(
|
||||
`INSERT INTO "Arc" ("fromId", "toId", "zapTrust")
|
||||
SELECT id, oid, trust
|
||||
FROM (values ${personalValues}) g(id, oid, trust)
|
||||
ON CONFLICT ("fromId", "toId") DO UPDATE SET "zapTrust" = EXCLUDED."zapTrust"`
|
||||
)
|
||||
])
|
||||
}
|
||||
|
@ -13,12 +13,12 @@ export function views ({ models }) {
|
||||
}
|
||||
}
|
||||
|
||||
// this should be run regularly ... like, every 1-5 minutes
|
||||
// this should be run regularly ... like, every 5 minutes
|
||||
export function rankViews ({ models }) {
|
||||
return async function () {
|
||||
console.log('refreshing rank views')
|
||||
|
||||
for (const view of ['zap_rank_wwm_view', 'zap_rank_tender_view']) {
|
||||
for (const view of ['zap_rank_personal_view']) {
|
||||
await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`)
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user