Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 85 additions & 2 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { verifyHmac } from './wallet'
import { parse } from 'tldts'
import { shuffleArray } from '@/lib/rand'
import pay from '../payIn'
import { lexicalHTMLGenerator } from '@/lib/lexical/server/html'
import { prepareLexicalState } from '@/lib/lexical/server/interpolator'

function commentsOrderByClause (me, models, sort) {
const sharedSortsArray = []
Expand Down Expand Up @@ -1150,6 +1152,62 @@ export default {
})

return result.lastViewedAt
},
executeConversion: async (parent, { itemId, fullRefresh }, { models, me }) => {
if (process.env.NODE_ENV !== 'development') {
throw new GqlAuthenticationError()
}

console.log(`[executeConversion] scheduling conversion for item ${itemId}`)

// check if job is already scheduled or running
const alreadyScheduled = await models.$queryRaw`
SELECT state
FROM pgboss.job
WHERE name = 'migrateLegacyContent'
AND data->>'itemId' = ${itemId}::TEXT
AND state IN ('created', 'active', 'retry')
LIMIT 1
`

if (alreadyScheduled.length > 0) {
console.log(`[executeConversion] item ${itemId} already has active job`)
return {
success: false,
message: `migration already ${alreadyScheduled[0].state} for this item`
}
}

// schedule the migration job
await models.$executeRaw`
INSERT INTO pgboss.job (
name,
data,
retrylimit,
retrybackoff,
startafter,
keepuntil,
singletonKey
)
VALUES (
'migrateLegacyContent',
jsonb_build_object(
'itemId', ${itemId}::INTEGER,
'fullRefresh', ${fullRefresh}::BOOLEAN,
'checkMedia', true
),
3, -- reduced retry limit for manual conversions
true,
now(),
now() + interval '1 hour',
'migrateLegacyContent:' || ${itemId}::TEXT
)
`

return {
success: true,
message: 'migration scheduled successfully'
}
}
},
Item: {
Expand Down Expand Up @@ -1584,21 +1642,33 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
item.url = removeTracking(item.url)
}

// create markdown from a lexical state
item.lexicalState = await prepareLexicalState({ text: item.text })
if (!item.lexicalState) {
throw new GqlInputError('failed to process content')
}

if (old.bio) {
// prevent editing a bio like a regular item
item = { id: Number(item.id), text: item.text, title: `@${user.name}'s bio` }
item = { id: Number(item.id), text: item.text, lexicalState: item.lexicalState, title: `@${user.name}'s bio` }
} else if (old.parentId) {
// prevent editing a comment like a post
item = { id: Number(item.id), text: item.text, boost: item.boost }
item = { id: Number(item.id), text: item.text, lexicalState: item.lexicalState, boost: item.boost }
} else {
item = { subName, ...item }
item.forwardUsers = await getForwardUsers(models, forward)
}
// todo: refactor to use uploadIdsFromLexicalState
// it should be way faster and more reliable
// by checking MediaNodes directly.
item.uploadIds = uploadIdsFromText(item.text)

// never change author of item
item.userId = old.userId

// generate sanitized html from lexical state
item.html = lexicalHTMLGenerator(item.lexicalState)

return await pay('ITEM_UPDATE', item, { models, me, lnd })
}

Expand All @@ -1610,6 +1680,16 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd
item.userId = me ? Number(me.id) : USER_ID.anon

item.forwardUsers = await getForwardUsers(models, forward)

// create markdown from a lexical state
item.lexicalState = await prepareLexicalState({ text: item.text })
if (!item.lexicalState) {
throw new GqlInputError('failed to process content')
}

// todo: refactor to use uploadIdsFromLexicalState
// it should be way faster and more reliable
// by checking MediaNodes directly.
item.uploadIds = uploadIdsFromText(item.text)

if (item.url && !isJob(item)) {
Expand All @@ -1627,6 +1707,9 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd
// mark item as created with API key
item.apiKey = me?.apiKey

// generate sanitized html from lexical state
item.html = lexicalHTMLGenerator(item.lexicalState)

return await pay('ITEM_CREATE', item, { models, me, lnd })
}

Expand Down
8 changes: 8 additions & 0 deletions api/resolvers/sub.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import pay from '../payIn'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { uploadIdsFromText } from './upload'
import { Prisma } from '@prisma/client'
import { prepareLexicalState } from '@/lib/lexical/server/interpolator'

export async function getSub (parent, { name }, { models, me }) {
if (!name) return null
Expand Down Expand Up @@ -211,6 +212,13 @@ export default {

await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } })

// QUIRK
// if we have a lexicalState, we'll convert it to markdown to fit the schema
data.lexicalState = await prepareLexicalState({ text: data.desc })
if (!data.lexicalState) {
throw new GqlInputError('failed to process content')
}

data.uploadIds = uploadIdsFromText(data.desc)

if (data.oldName) {
Expand Down
8 changes: 8 additions & 0 deletions api/typeDefs/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export default gql`
pollVote(id: ID!): PayIn!
toggleOutlaw(id: ID!): Item!
updateCommentsViewAt(id: ID!, meCommentsViewedAt: Date!): Date
executeConversion(itemId: ID!, fullRefresh: Boolean): ConversionResult!
}

type ConversionResult {
success: Boolean!
message: String!
}

type PollOption {
Expand Down Expand Up @@ -105,6 +111,8 @@ export default gql`
url: String
searchText: String
text: String
lexicalState: JSONObject
html: String
parentId: Int
parent: Item
root: Item
Expand Down
6 changes: 3 additions & 3 deletions components/comment-edit.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Form, MarkdownInput } from '@/components/form'
import { Form, SNInput } from '@/components/form'
import styles from './reply.module.css'
import { commentSchema } from '@/lib/validate'
import { FeeButtonProvider } from './fee-button'
Expand Down Expand Up @@ -39,9 +39,9 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
schema={commentSchema}
onSubmit={onSubmit}
>
<MarkdownInput
{/* what does minRows and required do? */}
<SNInput
name='text'
minRows={6}
autoFocus
required
/>
Expand Down
16 changes: 9 additions & 7 deletions components/comment.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import itemStyles from './item.module.css'
import styles from './comment.module.css'
import Text, { SearchText } from './text'
import Text, { LegacyText, SearchText } from './text'
import Link from 'next/link'
import Reply from './reply'
import { useEffect, useMemo, useRef, useState } from 'react'
Expand Down Expand Up @@ -286,12 +286,14 @@ export default function Comment ({
<div className={styles.text} ref={textRef}>
{item.searchText
? <SearchText text={item.searchText} />
: (
<Text itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>
{item.outlawed && !me?.privates?.wildWestMode
? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*'
: truncate ? truncateString(item.text) : item.text}
</Text>)}
: item.lexicalState
? <Text lexicalState={item.lexicalState} html={item.html} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls} />
: (
<LegacyText itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>
{item.outlawed && !me?.privates?.wildWestMode
? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*'
: truncate ? truncateString(item.text) : item.text}
</LegacyText>)}
</div>
)}
</div>
Expand Down
28 changes: 28 additions & 0 deletions components/editor/contexts/item.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { createContext, useContext, useMemo } from 'react'
import { UNKNOWN_LINK_REL } from '@/lib/constants'

const LexicalItemContext = createContext({
imgproxyUrls: null,
topLevel: false,
outlawed: false,
rel: UNKNOWN_LINK_REL
})

export function LexicalItemContextProvider ({ imgproxyUrls, topLevel, outlawed, rel, children }) {
const value = useMemo(() => ({
imgproxyUrls,
topLevel,
outlawed,
rel
}), [imgproxyUrls, topLevel, outlawed, rel])

return (
<LexicalItemContext.Provider value={value}>
{children}
</LexicalItemContext.Provider>
)
}

export function useLexicalItemContext () {
return useContext(LexicalItemContext)
}
36 changes: 36 additions & 0 deletions components/editor/contexts/toolbar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createContext, useContext, useMemo, useState, useCallback } from 'react'

const INITIAL_STATE = {
previewMode: false
}

const ToolbarContext = createContext()

export const ToolbarContextProvider = ({ children }) => {
const [toolbarState, setToolbarState] = useState(INITIAL_STATE)

const batchUpdateToolbarState = useCallback((updates) => {
setToolbarState((prev) => ({ ...prev, ...updates }))
}, [])

const updateToolbarState = useCallback((key, value) => {
setToolbarState((prev) => ({
...prev,
[key]: value
}))
}, [])

const contextValue = useMemo(() => {
return { toolbarState, updateToolbarState, batchUpdateToolbarState }
}, [toolbarState, updateToolbarState])

return (
<ToolbarContext.Provider value={contextValue}>
{children}
</ToolbarContext.Provider>
)
}

export const useToolbarState = () => {
return useContext(ToolbarContext)
}
Loading