Merge branch 'master' into fix-magic-numbers-in-update-item

This commit is contained in:
Keyan 2023-10-20 19:04:27 -05:00 committed by GitHub
commit 8bbd480ca3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1909 additions and 4914 deletions

View File

@ -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=

View File

@ -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`
}

View File

@ -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

View File

@ -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>

View File

@ -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 &&

View File

@ -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}

View File

@ -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>
}

View File

@ -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()]
]

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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',

View File

@ -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'

View 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;

View File

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "Arc_toId_fromId_idx" ON "Arc"("toId", "fromId");

View 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();

View File

@ -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")

View File

@ -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 => {

View File

@ -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)
}

View File

@ -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 }
}

View File

@ -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"`
)
])
}

View File

@ -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}`)
}