Merge branch 'master' into 190-strip-tracking-info
This commit is contained in:
		
						commit
						10e5257375
					
				
							
								
								
									
										9
									
								
								.puppeteerrc.cjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								.puppeteerrc.cjs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| const {join} = require('path'); | ||||
| 
 | ||||
| /** | ||||
|  * @type {import("puppeteer").Configuration} | ||||
|  */ | ||||
| module.exports = { | ||||
|   // Changes the cache location for Puppeteer.
 | ||||
|   cacheDirectory: join(__dirname, '.cache', 'puppeteer'), | ||||
| }; | ||||
| @ -1,157 +1,184 @@ | ||||
| const PLACEHOLDERS_NUM = 616 | ||||
| 
 | ||||
| export function interval (when) { | ||||
|   switch (when) { | ||||
|     case 'week': | ||||
|       return '1 week' | ||||
|     case 'month': | ||||
|       return '1 month' | ||||
|     case 'year': | ||||
|       return '1 year' | ||||
|     case 'forever': | ||||
|       return null | ||||
|     default: | ||||
|       return '1 day' | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function timeUnit (when) { | ||||
|   switch (when) { | ||||
|     case 'week': | ||||
|     case 'month': | ||||
|       return 'day' | ||||
|     case 'year': | ||||
|     case 'forever': | ||||
|       return 'month' | ||||
|     default: | ||||
|       return 'hour' | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function withClause (when) { | ||||
|   const ival = interval(when) | ||||
|   const unit = timeUnit(when) | ||||
| 
 | ||||
|   return ` | ||||
|     WITH range_values AS ( | ||||
|       SELECT date_trunc('${unit}', ${ival ? "now_utc() - interval '" + ival + "'" : "'2021-06-07'::timestamp"}) as minval, | ||||
|             date_trunc('${unit}', now_utc()) as maxval), | ||||
|     times AS ( | ||||
|       SELECT generate_series(minval, maxval, interval '1 ${unit}') as time | ||||
|       FROM range_values | ||||
|     ) | ||||
|   ` | ||||
| } | ||||
| 
 | ||||
| // HACKY AF this is a performance enhancement that allows us to use the created_at indices on tables
 | ||||
| export function intervalClause (when, table, and) { | ||||
|   if (when === 'forever') { | ||||
|     return and ? '' : 'TRUE' | ||||
|   } | ||||
| 
 | ||||
|   return `"${table}".created_at >= now_utc() - interval '${interval(when)}' ${and ? 'AND' : ''} ` | ||||
| } | ||||
| 
 | ||||
| export default { | ||||
|   Query: { | ||||
|     registrationGrowth: async (parent, args, { models }) => { | ||||
|     registrationGrowth: async (parent, { when }, { models }) => { | ||||
|       return await models.$queryRaw( | ||||
|         `SELECT date_trunc('month', created_at) AS time, count("inviteId") as invited, count(*) - count("inviteId") as organic
 | ||||
|         FROM users | ||||
|         WHERE id > ${PLACEHOLDERS_NUM} AND date_trunc('month', now_utc()) <> date_trunc('month', created_at) | ||||
|         `${withClause(when)} | ||||
|         SELECT time, json_build_array( | ||||
|           json_build_object('name', 'referrals', 'value', count("referrerId")), | ||||
|           json_build_object('name', 'organic', 'value', count(users.id) FILTER(WHERE id > ${PLACEHOLDERS_NUM}) - count("inviteId")) | ||||
|         ) AS data | ||||
|         FROM times | ||||
|         LEFT JOIN users ON ${intervalClause(when, 'users', true)} time = date_trunc('${timeUnit(when)}', created_at) | ||||
|         GROUP BY time | ||||
|         ORDER BY time ASC`)
 | ||||
|     }, | ||||
|     activeGrowth: async (parent, args, { models }) => { | ||||
|     spenderGrowth: async (parent, { when }, { models }) => { | ||||
|       return await models.$queryRaw( | ||||
|         `SELECT date_trunc('month', created_at) AS time, count(DISTINCT "userId") as num
 | ||||
|         `${withClause(when)} | ||||
|         SELECT time, json_build_array( | ||||
|           json_build_object('name', 'any', 'value', count(DISTINCT "userId")), | ||||
|           json_build_object('name', 'jobs', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'STREAM')), | ||||
|           json_build_object('name', 'boost', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'BOOST')), | ||||
|           json_build_object('name', 'fees', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'FEE')), | ||||
|           json_build_object('name', 'tips', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'TIP')), | ||||
|           json_build_object('name', 'donation', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'DONATION')) | ||||
|         ) AS data | ||||
|         FROM times | ||||
|         LEFT JOIN | ||||
|         ((SELECT "ItemAct".created_at, "userId", act::text as act | ||||
|           FROM "ItemAct" | ||||
|         WHERE date_trunc('month', now_utc()) <> date_trunc('month', created_at) | ||||
|         GROUP BY time | ||||
|         ORDER BY time ASC`)
 | ||||
|     }, | ||||
|     itemGrowth: async (parent, args, { models }) => { | ||||
|       return await models.$queryRaw( | ||||
|         `SELECT date_trunc('month', created_at) AS time, count("parentId") as comments,
 | ||||
|           count("subName") as jobs, count(*)-count("parentId")-count("subName") as posts | ||||
|         FROM "Item" | ||||
|         WHERE date_trunc('month', now_utc()) <> date_trunc('month', created_at) | ||||
|         GROUP BY time | ||||
|         ORDER BY time ASC`)
 | ||||
|     }, | ||||
|     spentGrowth: async (parent, args, { models }) => { | ||||
|       // add up earn for each month
 | ||||
|       // add up non-self votes/tips for posts and comments
 | ||||
| 
 | ||||
|       return await models.$queryRaw( | ||||
|         `SELECT date_trunc('month', "ItemAct".created_at) AS time,
 | ||||
|         sum(CASE WHEN act = 'STREAM' THEN "ItemAct".sats ELSE 0 END) as jobs, | ||||
|         sum(CASE WHEN act IN ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN "ItemAct".sats ELSE 0 END) as fees, | ||||
|         sum(CASE WHEN act = 'BOOST' THEN "ItemAct".sats ELSE 0 END) as boost, | ||||
|         sum(CASE WHEN act = 'TIP' THEN "ItemAct".sats ELSE 0 END) as tips | ||||
|         FROM "ItemAct" | ||||
|         JOIN "Item" on "ItemAct"."itemId" = "Item".id | ||||
|         WHERE date_trunc('month', now_utc()) <> date_trunc('month',  "ItemAct".created_at) | ||||
|         GROUP BY time | ||||
|         ORDER BY time ASC`)
 | ||||
|     }, | ||||
|     earnerGrowth: async (parent, args, { models }) => { | ||||
|       return await models.$queryRaw( | ||||
|         `SELECT time, count(distinct user_id) as num
 | ||||
|         FROM | ||||
|         ((SELECT date_trunc('month', "ItemAct".created_at) AS time, "Item"."userId" as user_id | ||||
|           FROM "ItemAct" | ||||
|           JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId" | ||||
|           WHERE date_trunc('month', now_utc()) <> date_trunc('month', "ItemAct".created_at)) | ||||
|           WHERE ${intervalClause(when, 'ItemAct', false)}) | ||||
|         UNION ALL | ||||
|         (SELECT date_trunc('month', created_at) AS time, "userId" as user_id | ||||
|           FROM "Earn" | ||||
|           WHERE date_trunc('month', now_utc()) <> date_trunc('month', created_at))) u | ||||
|         (SELECT created_at, "userId", 'DONATION' as act | ||||
|           FROM "Donation" | ||||
|           WHERE ${intervalClause(when, 'Donation', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at) | ||||
|         GROUP BY time | ||||
|         ORDER BY time ASC`)
 | ||||
|     }, | ||||
|     stackedGrowth: async (parent, args, { models }) => { | ||||
|     itemGrowth: async (parent, { when }, { models }) => { | ||||
|       return await models.$queryRaw( | ||||
|         `SELECT time, sum(airdrop) as rewards, sum(post) as posts, sum(comment) as comments
 | ||||
|         FROM | ||||
|         ((SELECT date_trunc('month', "ItemAct".created_at) AS time, 0 as airdrop, | ||||
|           CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".sats END as comment, | ||||
|           CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".sats ELSE 0 END as post | ||||
|           FROM "ItemAct" | ||||
|           JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId" | ||||
|           WHERE date_trunc('month', now_utc()) <> date_trunc('month', "ItemAct".created_at) AND | ||||
|           "ItemAct".act IN ('VOTE', 'TIP')) | ||||
|         UNION ALL | ||||
|         (SELECT date_trunc('month', created_at) AS time, msats / 1000 as airdrop, 0 as post, 0 as comment | ||||
|           FROM "Earn" | ||||
|           WHERE date_trunc('month', now_utc()) <> date_trunc('month', created_at))) u | ||||
|         GROUP BY time | ||||
|         ORDER BY time ASC`)
 | ||||
|     }, | ||||
|     registrationsWeekly: async (parent, args, { models }) => { | ||||
|       return await models.user.count({ | ||||
|         where: { | ||||
|           createdAt: { | ||||
|             gte: new Date(new Date().setDate(new Date().getDate() - 7)) | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
|     }, | ||||
|     activeWeekly: async (parent, args, { models }) => { | ||||
|       const [{ active }] = await models.$queryRaw( | ||||
|         `SELECT count(DISTINCT "userId") as active
 | ||||
|         FROM "ItemAct" | ||||
|         WHERE created_at >= now_utc() - interval '1 week'` | ||||
|       ) | ||||
|       return active | ||||
|     }, | ||||
|     earnersWeekly: async (parent, args, { models }) => { | ||||
|       const [{ earners }] = await models.$queryRaw( | ||||
|         `SELECT count(distinct user_id) as earners
 | ||||
|         FROM | ||||
|         ((SELECT "Item"."userId" as user_id | ||||
|           FROM "ItemAct" | ||||
|           JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId" | ||||
|           WHERE "ItemAct".created_at >= now_utc() - interval '1 week') | ||||
|         UNION ALL | ||||
|         (SELECT "userId" as user_id | ||||
|           FROM "Earn" | ||||
|           WHERE created_at >= now_utc() - interval '1 week')) u`)
 | ||||
|       return earners | ||||
|     }, | ||||
|     itemsWeekly: async (parent, args, { models }) => { | ||||
|       const [stats] = await models.$queryRaw( | ||||
|         `SELECT json_build_array(
 | ||||
|         `${withClause(when)} | ||||
|         SELECT time, json_build_array( | ||||
|           json_build_object('name', 'comments', 'value', count("parentId")), | ||||
|           json_build_object('name', 'jobs', 'value', count("subName")), | ||||
|           json_build_object('name', 'posts', 'value', count(*)-count("parentId")-count("subName"))) as array | ||||
|         FROM "Item" | ||||
|         WHERE created_at >= now_utc() - interval '1 week'`)
 | ||||
| 
 | ||||
|       return stats?.array | ||||
|           json_build_object('name', 'posts', 'value', count("Item".id)-count("parentId")-count("subName")) | ||||
|         ) AS data | ||||
|         FROM times | ||||
|         LEFT JOIN "Item" ON ${intervalClause(when, 'Item', true)} time = date_trunc('${timeUnit(when)}', created_at) | ||||
|         GROUP BY time | ||||
|         ORDER BY time ASC`)
 | ||||
|     }, | ||||
|     spentWeekly: async (parent, args, { models }) => { | ||||
|       const [stats] = await models.$queryRaw( | ||||
|         `SELECT json_build_array(
 | ||||
|           json_build_object('name', 'jobs', 'value', sum(CASE WHEN act = 'STREAM' THEN "ItemAct".sats ELSE 0 END)), | ||||
|           json_build_object('name', 'fees', 'value', sum(CASE WHEN act in ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN "ItemAct".sats ELSE 0 END)), | ||||
|           json_build_object('name', 'boost', 'value', sum(CASE WHEN act = 'BOOST' THEN "ItemAct".sats ELSE 0 END)), | ||||
|           json_build_object('name', 'tips', 'value', sum(CASE WHEN act = 'TIP' THEN "ItemAct".sats ELSE 0 END))) as array | ||||
|     spendingGrowth: async (parent, { when }, { models }) => { | ||||
|       return await models.$queryRaw( | ||||
|         `${withClause(when)} | ||||
|         SELECT time, json_build_array( | ||||
|           json_build_object('name', 'jobs', 'value', coalesce(floor(sum(CASE WHEN act = 'STREAM' THEN msats ELSE 0 END)/1000),0)), | ||||
|           json_build_object('name', 'boost', 'value', coalesce(floor(sum(CASE WHEN act = 'BOOST' THEN msats ELSE 0 END)/1000),0)), | ||||
|           json_build_object('name', 'fees', 'value', coalesce(floor(sum(CASE WHEN act NOT IN ('BOOST', 'TIP', 'STREAM', 'DONATION') THEN msats ELSE 0 END)/1000),0)), | ||||
|           json_build_object('name', 'tips', 'value', coalesce(floor(sum(CASE WHEN act = 'TIP' THEN msats ELSE 0 END)/1000),0)), | ||||
|           json_build_object('name', 'donations', 'value', coalesce(floor(sum(CASE WHEN act = 'DONATION' THEN msats ELSE 0 END)/1000),0)) | ||||
|         ) AS data | ||||
|         FROM times | ||||
|         LEFT JOIN | ||||
|         ((SELECT "ItemAct".created_at, msats, act::text as act | ||||
|           FROM "ItemAct" | ||||
|           WHERE ${intervalClause(when, 'ItemAct', false)}) | ||||
|         UNION ALL | ||||
|         (SELECT created_at, sats * 1000 as msats, 'DONATION' as act | ||||
|           FROM "Donation" | ||||
|           WHERE ${intervalClause(when, 'Donation', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at) | ||||
|         GROUP BY time | ||||
|         ORDER BY time ASC`)
 | ||||
|     }, | ||||
|     stackerGrowth: async (parent, { when }, { models }) => { | ||||
|       return await models.$queryRaw( | ||||
|         `${withClause(when)} | ||||
|         SELECT time, json_build_array( | ||||
|           json_build_object('name', 'any', 'value', count(distinct user_id)), | ||||
|           json_build_object('name', 'posts', 'value', count(distinct user_id) FILTER (WHERE type = 'POST')), | ||||
|           json_build_object('name', 'comments', 'value', count(distinct user_id) FILTER (WHERE type = 'COMMENT')), | ||||
|           json_build_object('name', 'rewards', 'value', count(distinct user_id) FILTER (WHERE type = 'EARN')), | ||||
|           json_build_object('name', 'referrals', 'value', count(distinct user_id) FILTER (WHERE type = 'REFERRAL')) | ||||
|         ) AS data | ||||
|         FROM times | ||||
|         LEFT JOIN | ||||
|         ((SELECT "ItemAct".created_at, "Item"."userId" as user_id, CASE WHEN "Item"."parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type | ||||
|           FROM "ItemAct" | ||||
|           JOIN "Item" on "ItemAct"."itemId" = "Item".id | ||||
|         WHERE "ItemAct".created_at >= now_utc() - interval '1 week'`)
 | ||||
| 
 | ||||
|       return stats?.array | ||||
|     }, | ||||
|     stackedWeekly: async (parent, args, { models }) => { | ||||
|       const [stats] = await models.$queryRaw( | ||||
|         `SELECT json_build_array(
 | ||||
|           json_build_object('name', 'rewards', 'value', sum(airdrop)), | ||||
|           json_build_object('name', 'posts', 'value', sum(post)), | ||||
|           json_build_object('name', 'comments', 'value', sum(comment)) | ||||
|         ) as array | ||||
|         FROM | ||||
|         ((SELECT 0 as airdrop, | ||||
|           CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".sats END as comment, | ||||
|           CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".sats ELSE 0 END as post | ||||
|           FROM "ItemAct" | ||||
|           JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId" | ||||
|           WHERE  "ItemAct".created_at >= now_utc() - interval '1 week' AND | ||||
|           "ItemAct".act IN ('VOTE', 'TIP')) | ||||
|           WHERE ${intervalClause(when, 'ItemAct', true)} "ItemAct".act = 'TIP') | ||||
|         UNION ALL | ||||
|         (SELECT msats / 1000 as airdrop, 0 as post, 0 as comment | ||||
|         (SELECT created_at, "userId" as user_id, 'EARN' as type | ||||
|           FROM "Earn" | ||||
|           WHERE  created_at >= now_utc() - interval '1 week')) u`)
 | ||||
| 
 | ||||
|       return stats?.array | ||||
|           WHERE ${intervalClause(when, 'Earn', false)}) | ||||
|         UNION ALL | ||||
|           (SELECT created_at, "referrerId" as user_id, 'REFERRAL' as type | ||||
|             FROM "ReferralAct" | ||||
|             WHERE ${intervalClause(when, 'ReferralAct', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at) | ||||
|         GROUP BY time | ||||
|         ORDER BY time ASC`)
 | ||||
|     }, | ||||
|     stackingGrowth: async (parent, { when }, { models }) => { | ||||
|       return await models.$queryRaw( | ||||
|         `${withClause(when)} | ||||
|         SELECT time, json_build_array( | ||||
|           json_build_object('name', 'rewards', 'value', coalesce(floor(sum(airdrop)/1000),0)), | ||||
|           json_build_object('name', 'posts', 'value', coalesce(floor(sum(post)/1000),0)), | ||||
|           json_build_object('name', 'comments', 'value', coalesce(floor(sum(comment)/1000),0)), | ||||
|           json_build_object('name', 'referrals', 'value', coalesce(floor(sum(referral)/1000),0)) | ||||
|         ) AS data | ||||
|         FROM times | ||||
|         LEFT JOIN | ||||
|         ((SELECT "ItemAct".created_at, 0 as airdrop, | ||||
|           CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".msats END as comment, | ||||
|           CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".msats ELSE 0 END as post, | ||||
|           0 as referral | ||||
|           FROM "ItemAct" | ||||
|           JOIN "Item" on "ItemAct"."itemId" = "Item".id | ||||
|           WHERE ${intervalClause(when, 'ItemAct', true)} "ItemAct".act = 'TIP') | ||||
|         UNION ALL | ||||
|           (SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, msats as referral | ||||
|             FROM "ReferralAct" | ||||
|             WHERE ${intervalClause(when, 'ReferralAct', false)}) | ||||
|         UNION ALL | ||||
|         (SELECT created_at, msats as airdrop, 0 as post, 0 as comment, 0 as referral | ||||
|           FROM "Earn" | ||||
|           WHERE ${intervalClause(when, 'Earn', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at) | ||||
|         GROUP BY time | ||||
|         ORDER BY time ASC`)
 | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -9,7 +9,9 @@ import sub from './sub' | ||||
| import upload from './upload' | ||||
| import growth from './growth' | ||||
| import search from './search' | ||||
| import rewards from './rewards' | ||||
| import referrals from './referrals' | ||||
| import { GraphQLJSONObject } from 'graphql-type-json' | ||||
| 
 | ||||
| export default [user, item, message, wallet, lnurl, notifications, invite, sub, | ||||
|   upload, growth, search, { JSONObject: GraphQLJSONObject }] | ||||
|   upload, growth, search, rewards, referrals, { JSONObject: GraphQLJSONObject }] | ||||
|  | ||||
| @ -8,18 +8,19 @@ import { | ||||
|   BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES, | ||||
|   MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST | ||||
| } from '../../lib/constants' | ||||
| import { msatsToSats } from '../../lib/format' | ||||
| 
 | ||||
| async function comments (me, models, id, sort) { | ||||
|   let orderBy | ||||
|   switch (sort) { | ||||
|     case 'top': | ||||
|       orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".id DESC` | ||||
|       orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".msats DESC, "Item".id DESC` | ||||
|       break | ||||
|     case 'recent': | ||||
|       orderBy = 'ORDER BY "Item".created_at DESC, "Item".id DESC' | ||||
|       orderBy = 'ORDER BY "Item".created_at DESC, "Item".msats 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".id DESC` | ||||
|       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".id DESC` | ||||
|       break | ||||
|   } | ||||
| 
 | ||||
| @ -74,7 +75,7 @@ async function topOrderClause (sort, me, models) { | ||||
|     case 'comments': | ||||
|       return 'ORDER BY ncomments DESC' | ||||
|     case 'sats': | ||||
|       return 'ORDER BY sats DESC' | ||||
|       return 'ORDER BY msats DESC' | ||||
|     default: | ||||
|       return await topOrderByWeightedSats(me, models) | ||||
|   } | ||||
| @ -125,6 +126,21 @@ export async function filterClause (me, models) { | ||||
|   return clause | ||||
| } | ||||
| 
 | ||||
| function recentClause (type) { | ||||
|   switch (type) { | ||||
|     case 'links': | ||||
|       return ' AND url IS NOT NULL' | ||||
|     case 'discussions': | ||||
|       return ' AND url IS NULL AND bio = false AND "pollCost"  IS NULL' | ||||
|     case 'polls': | ||||
|       return ' AND "pollCost" IS NOT NULL' | ||||
|     case 'bios': | ||||
|       return ' AND bio = true' | ||||
|     default: | ||||
|       return '' | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default { | ||||
|   Query: { | ||||
|     itemRepetition: async (parent, { parentId }, { me, models }) => { | ||||
| @ -169,7 +185,7 @@ export default { | ||||
|         comments | ||||
|       } | ||||
|     }, | ||||
|     items: async (parent, { sub, sort, cursor, name, within }, { me, models }) => { | ||||
|     items: async (parent, { sub, sort, type, cursor, name, within }, { me, models }) => { | ||||
|       const decodedCursor = decodeCursor(cursor) | ||||
|       let items; let user; let pins; let subFull | ||||
| 
 | ||||
| @ -211,6 +227,7 @@ export default { | ||||
|             ${subClause(3)} | ||||
|             ${activeOrMine()} | ||||
|             ${await filterClause(me, models)} | ||||
|             ${recentClause(type)} | ||||
|             ORDER BY created_at DESC | ||||
|             OFFSET $2 | ||||
|             LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
 | ||||
| @ -235,9 +252,6 @@ export default { | ||||
| 
 | ||||
|           switch (subFull?.rankingType) { | ||||
|             case 'AUCTION': | ||||
|               // it might be sufficient to sort by the floor(maxBid / 1000) desc, created_at desc
 | ||||
|               // we pull from their wallet
 | ||||
|               // TODO: need to filter out by payment status
 | ||||
|               items = await models.$queryRaw(` | ||||
|                 SELECT * | ||||
|                 FROM ( | ||||
| @ -691,6 +705,12 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   Item: { | ||||
|     sats: async (item, args, { models }) => { | ||||
|       return msatsToSats(item.msats) | ||||
|     }, | ||||
|     commentSats: async (item, args, { models }) => { | ||||
|       return msatsToSats(item.commentMsats) | ||||
|     }, | ||||
|     isJob: async (item, args, { models }) => { | ||||
|       return item.subName === 'jobs' | ||||
|     }, | ||||
| @ -772,25 +792,17 @@ export default { | ||||
|       return comments(me, models, item.id, 'hot') | ||||
|     }, | ||||
|     upvotes: async (item, args, { models }) => { | ||||
|       const { sum: { sats } } = await models.itemAct.aggregate({ | ||||
|         sum: { | ||||
|           sats: true | ||||
|         }, | ||||
|         where: { | ||||
|           itemId: Number(item.id), | ||||
|           userId: { | ||||
|             not: Number(item.userId) | ||||
|           }, | ||||
|           act: 'VOTE' | ||||
|         } | ||||
|       }) | ||||
|       const [{ count }] = await models.$queryRaw(` | ||||
|         SELECT COUNT(DISTINCT "userId") as count | ||||
|         FROM "ItemAct" | ||||
|         WHERE act = 'TIP' AND "itemId" = $1`, Number(item.id))
 | ||||
| 
 | ||||
|       return sats || 0 | ||||
|       return count | ||||
|     }, | ||||
|     boost: async (item, args, { models }) => { | ||||
|       const { sum: { sats } } = await models.itemAct.aggregate({ | ||||
|       const { sum: { msats } } = await models.itemAct.aggregate({ | ||||
|         sum: { | ||||
|           sats: true | ||||
|           msats: true | ||||
|         }, | ||||
|         where: { | ||||
|           itemId: Number(item.id), | ||||
| @ -798,7 +810,7 @@ export default { | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       return sats || 0 | ||||
|       return (msats && msatsToSats(msats)) || 0 | ||||
|     }, | ||||
|     wvotes: async (item) => { | ||||
|       return item.weightedVotes - item.weightedDownVotes | ||||
| @ -806,9 +818,9 @@ export default { | ||||
|     meSats: async (item, args, { me, models }) => { | ||||
|       if (!me) return 0 | ||||
| 
 | ||||
|       const { sum: { sats } } = await models.itemAct.aggregate({ | ||||
|       const { sum: { msats } } = await models.itemAct.aggregate({ | ||||
|         sum: { | ||||
|           sats: true | ||||
|           msats: true | ||||
|         }, | ||||
|         where: { | ||||
|           itemId: Number(item.id), | ||||
| @ -818,13 +830,13 @@ export default { | ||||
|               act: 'TIP' | ||||
|             }, | ||||
|             { | ||||
|               act: 'VOTE' | ||||
|               act: 'FEE' | ||||
|             } | ||||
|           ] | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       return sats || 0 | ||||
|       return (msats && msatsToSats(msats)) || 0 | ||||
|     }, | ||||
|     meDontLike: async (item, args, { me, models }) => { | ||||
|       if (!me) return false | ||||
| @ -1011,7 +1023,7 @@ export const SELECT = | ||||
|   "Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid", | ||||
|   "Item".company, "Item".location, "Item".remote, | ||||
|   "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", | ||||
|   "Item".sats, "Item".ncomments, "Item"."commentSats", "Item"."lastCommentAt", "Item"."weightedVotes", | ||||
|   "Item".msats, "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes", | ||||
|   "Item"."weightedDownVotes", "Item".freebie, ltree2text("Item"."path") AS "path"` | ||||
| 
 | ||||
| async function newTimedOrderByWeightedSats (me, models, num) { | ||||
|  | ||||
| @ -98,7 +98,7 @@ export default { | ||||
|             FROM "Item" | ||||
|             WHERE "Item"."userId" = $1 | ||||
|             AND "maxBid" IS NOT NULL | ||||
|             AND "statusUpdatedAt" <= $2 | ||||
|             AND "statusUpdatedAt" <= $2 AND "statusUpdatedAt" <> created_at | ||||
|             ORDER BY "sortTime" DESC | ||||
|             LIMIT ${LIMIT}+$3)` | ||||
|         ) | ||||
| @ -106,12 +106,12 @@ export default { | ||||
|         if (meFull.noteItemSats) { | ||||
|           queries.push( | ||||
|             `(SELECT "Item".id::TEXT, MAX("ItemAct".created_at) AS "sortTime",
 | ||||
|               sum("ItemAct".sats) as "earnedSats", 'Votification' AS type | ||||
|               floor(sum("ItemAct".msats)/1000) as "earnedSats", 'Votification' AS type | ||||
|               FROM "Item" | ||||
|               JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id | ||||
|               WHERE "ItemAct"."userId" <> $1 | ||||
|               AND "ItemAct".created_at <= $2 | ||||
|               AND "ItemAct".act in ('VOTE', 'TIP') | ||||
|               AND "ItemAct".act IN ('TIP', 'FEE') | ||||
|               AND "Item"."userId" = $1 | ||||
|               GROUP BY "Item".id | ||||
|               ORDER BY "sortTime" DESC | ||||
| @ -160,6 +160,15 @@ export default { | ||||
|               ORDER BY "sortTime" DESC | ||||
|               LIMIT ${LIMIT}+$3)` | ||||
|           ) | ||||
|           queries.push( | ||||
|             `(SELECT users.id::text, users.created_at AS "sortTime", NULL as "earnedSats",
 | ||||
|               'Referral' AS type | ||||
|               FROM users | ||||
|               WHERE "users"."referrerId" = $1 | ||||
|               AND "inviteId" IS NULL | ||||
|               AND users.created_at <= $2 | ||||
|               LIMIT ${LIMIT}+$3)` | ||||
|           ) | ||||
|         } | ||||
| 
 | ||||
|         if (meFull.noteEarning) { | ||||
|  | ||||
							
								
								
									
										53
									
								
								api/resolvers/referrals.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								api/resolvers/referrals.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,53 @@ | ||||
| import { AuthenticationError } from 'apollo-server-micro' | ||||
| import { withClause, intervalClause, timeUnit } from './growth' | ||||
| 
 | ||||
| export default { | ||||
|   Query: { | ||||
|     referrals: async (parent, { when }, { models, me }) => { | ||||
|       if (!me) { | ||||
|         throw new AuthenticationError('you must be logged in') | ||||
|       } | ||||
| 
 | ||||
|       const [{ totalSats }] = await models.$queryRaw(` | ||||
|         SELECT COALESCE(FLOOR(sum(msats) / 1000), 0) as "totalSats" | ||||
|         FROM "ReferralAct" | ||||
|         WHERE ${intervalClause(when, 'ReferralAct', true)} | ||||
|         "ReferralAct"."referrerId" = $1 | ||||
|       `, Number(me.id))
 | ||||
| 
 | ||||
|       const [{ totalReferrals }] = await models.$queryRaw(` | ||||
|         SELECT count(*) as "totalReferrals" | ||||
|         FROM users | ||||
|         WHERE ${intervalClause(when, 'users', true)} | ||||
|         "referrerId" = $1 | ||||
|     `, Number(me.id))
 | ||||
| 
 | ||||
|       const stats = await models.$queryRaw( | ||||
|         `${withClause(when)} | ||||
|         SELECT time, json_build_array( | ||||
|           json_build_object('name', 'referrals', 'value', count(*) FILTER (WHERE act = 'REFERREE')), | ||||
|           json_build_object('name', 'sats', 'value', FLOOR(COALESCE(sum(msats) FILTER (WHERE act IN ('BOOST', 'STREAM', 'FEE')), 0))) | ||||
|         ) AS data | ||||
|         FROM times | ||||
|         LEFT JOIN | ||||
|         ((SELECT "ReferralAct".created_at, "ReferralAct".msats / 1000.0 as msats, "ItemAct".act::text as act | ||||
|           FROM "ReferralAct" | ||||
|           JOIN "ItemAct" ON "ItemAct".id = "ReferralAct"."itemActId" | ||||
|           WHERE ${intervalClause(when, 'ReferralAct', true)} | ||||
|             "ReferralAct"."referrerId" = $1) | ||||
|         UNION ALL | ||||
|         (SELECT created_at, 0.0 as sats, 'REFERREE' as act | ||||
|           FROM users | ||||
|           WHERE ${intervalClause(when, 'users', true)} | ||||
|             "referrerId" = $1)) u ON time = date_trunc('${timeUnit(when)}', u.created_at) | ||||
|         GROUP BY time | ||||
|         ORDER BY time ASC`, Number(me.id))
 | ||||
| 
 | ||||
|       return { | ||||
|         totalSats, | ||||
|         totalReferrals, | ||||
|         stats | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										49
									
								
								api/resolvers/rewards.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								api/resolvers/rewards.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,49 @@ | ||||
| import { AuthenticationError } from 'apollo-server-micro' | ||||
| import serialize from './serial' | ||||
| 
 | ||||
| export default { | ||||
|   Query: { | ||||
|     expectedRewards: async (parent, args, { models }) => { | ||||
|       // get the last reward time, then get all contributions to rewards since then
 | ||||
|       const lastReward = await models.earn.findFirst({ | ||||
|         orderBy: { | ||||
|           createdAt: 'desc' | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       const [result] = await models.$queryRaw` | ||||
|         SELECT coalesce(FLOOR(sum(sats)), 0) as total, json_build_array( | ||||
|           json_build_object('name', 'donations', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'DONATION')), 0)), | ||||
|           json_build_object('name', 'fees', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM', 'DONATION'))), 0)), | ||||
|           json_build_object('name', 'boost', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'BOOST')), 0)), | ||||
|           json_build_object('name', 'jobs', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'STREAM')), 0)) | ||||
|         ) AS sources | ||||
|         FROM ( | ||||
|           (SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) / 1000.0 as sats, act::text as type | ||||
|             FROM "ItemAct" | ||||
|             LEFT JOIN "ReferralAct" ON "ItemAct".id = "ReferralAct"."itemActId" | ||||
|             WHERE "ItemAct".created_at > ${lastReward.createdAt} AND "ItemAct".act <> 'TIP') | ||||
|             UNION ALL | ||||
|           (SELECT sats::FLOAT, 'DONATION' as type | ||||
|             FROM "Donation" | ||||
|             WHERE created_at > ${lastReward.createdAt}) | ||||
|         ) subquery` | ||||
| 
 | ||||
|       return result | ||||
|     } | ||||
|   }, | ||||
|   Mutation: { | ||||
|     donateToRewards: async (parent, { sats }, { me, models }) => { | ||||
|       if (!me) { | ||||
|         throw new AuthenticationError('you must be logged in') | ||||
|       } | ||||
| 
 | ||||
|       await serialize(models, | ||||
|         models.$queryRaw( | ||||
|           'SELECT donate($1, $2)', | ||||
|           sats, Number(me.id))) | ||||
| 
 | ||||
|       return sats | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -26,6 +26,12 @@ async function serialize (models, call) { | ||||
|       if (error.message.includes('SN_INELIGIBLE')) { | ||||
|         bail(new Error('user ineligible for gift')) | ||||
|       } | ||||
|       if (error.message.includes('SN_UNSUPPORTED')) { | ||||
|         bail(new Error('unsupported action')) | ||||
|       } | ||||
|       if (error.message.includes('SN_DUPLICATE')) { | ||||
|         bail(new Error('duplicate not allowed')) | ||||
|       } | ||||
|       if (error.message.includes('SN_REVOKED_OR_EXHAUSTED')) { | ||||
|         bail(new Error('faucet has been revoked or is exhausted')) | ||||
|       } | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| import { AuthenticationError, UserInputError } from 'apollo-server-errors' | ||||
| import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' | ||||
| import { msatsToSats } from '../../lib/format' | ||||
| import { createMentions, getItem, SELECT, updateItem, filterClause } from './item' | ||||
| import serialize from './serial' | ||||
| 
 | ||||
| @ -92,11 +93,20 @@ export default { | ||||
|       let users | ||||
|       if (sort === 'spent') { | ||||
|         users = await models.$queryRaw(` | ||||
|           SELECT users.*, sum("ItemAct".sats) as spent | ||||
|           SELECT users.*, sum(sats_spent) as spent | ||||
|           FROM | ||||
|           ((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent | ||||
|             FROM "ItemAct" | ||||
|           JOIN users on "ItemAct"."userId" = users.id | ||||
|             WHERE "ItemAct".created_at <= $1 | ||||
|             ${within('ItemAct', when)} | ||||
|             GROUP BY "userId") | ||||
|             UNION ALL | ||||
|           (SELECT "userId", sats as sats_spent | ||||
|             FROM "Donation" | ||||
|             WHERE created_at <= $1 | ||||
|             ${within('Donation', when)})) spending | ||||
|           JOIN users on spending."userId" = users.id | ||||
|           AND NOT users."hideFromTopUsers" | ||||
|           GROUP BY users.id, users.name | ||||
|           ORDER BY spent DESC NULLS LAST, users.created_at DESC | ||||
|           OFFSET $2 | ||||
| @ -107,6 +117,7 @@ export default { | ||||
|           FROM users | ||||
|           JOIN "Item" on "Item"."userId" = users.id | ||||
|           WHERE "Item".created_at <= $1 AND "Item"."parentId" IS NULL | ||||
|           AND NOT users."hideFromTopUsers" | ||||
|           ${within('Item', when)} | ||||
|           GROUP BY users.id | ||||
|           ORDER BY nitems DESC NULLS LAST, users.created_at DESC | ||||
| @ -118,26 +129,47 @@ export default { | ||||
|           FROM users | ||||
|           JOIN "Item" on "Item"."userId" = users.id | ||||
|           WHERE "Item".created_at <= $1 AND "Item"."parentId" IS NOT NULL | ||||
|           AND NOT users."hideFromTopUsers" | ||||
|           ${within('Item', when)} | ||||
|           GROUP BY users.id | ||||
|           ORDER BY ncomments DESC NULLS LAST, users.created_at DESC | ||||
|           OFFSET $2 | ||||
|           LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
 | ||||
|       } else if (sort === 'referrals') { | ||||
|         users = await models.$queryRaw(` | ||||
|           SELECT users.*, count(*) as referrals | ||||
|           FROM users | ||||
|           JOIN "users" referree on users.id = referree."referrerId" | ||||
|           WHERE referree.created_at <= $1 | ||||
|           AND NOT users."hideFromTopUsers" | ||||
|           ${within('referree', when)} | ||||
|           GROUP BY users.id | ||||
|           ORDER BY referrals DESC NULLS LAST, users.created_at DESC | ||||
|           OFFSET $2 | ||||
|           LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
 | ||||
|       } else { | ||||
|         users = await models.$queryRaw(` | ||||
|           SELECT u.id, u.name, u."photoId", sum(amount) as stacked | ||||
|           SELECT u.id, u.name, u."photoId", floor(sum(amount)/1000) as stacked | ||||
|           FROM | ||||
|           ((SELECT users.*, "ItemAct".sats as amount | ||||
|           ((SELECT users.*, "ItemAct".msats as amount | ||||
|             FROM "ItemAct" | ||||
|             JOIN "Item" on "ItemAct"."itemId" = "Item".id | ||||
|             JOIN users on "Item"."userId" = users.id | ||||
|             WHERE act <> 'BOOST' AND "ItemAct"."userId" <> users.id AND "ItemAct".created_at <= $1 | ||||
|             AND NOT users."hideFromTopUsers" | ||||
|             ${within('ItemAct', when)}) | ||||
|           UNION ALL | ||||
|           (SELECT users.*, "Earn".msats/1000 as amount | ||||
|           (SELECT users.*, "Earn".msats as amount | ||||
|             FROM "Earn" | ||||
|             JOIN users on users.id = "Earn"."userId" | ||||
|             WHERE "Earn".msats > 0 ${within('Earn', when)})) u | ||||
|             WHERE "Earn".msats > 0 ${within('Earn', when)} | ||||
|             AND NOT users."hideFromTopUsers") | ||||
|           UNION ALL | ||||
|           (SELECT users.*, "ReferralAct".msats as amount | ||||
|               FROM "ReferralAct" | ||||
|               JOIN users on users.id = "ReferralAct"."referrerId" | ||||
|               WHERE "ReferralAct".msats > 0 ${within('ReferralAct', when)} | ||||
|               AND NOT users."hideFromTopUsers")) u | ||||
|           GROUP BY u.id, u.name, u.created_at, u."photoId" | ||||
|           ORDER BY stacked DESC NULLS LAST, created_at DESC | ||||
|           OFFSET $2 | ||||
| @ -149,6 +181,130 @@ export default { | ||||
|         users | ||||
|       } | ||||
|     }, | ||||
|     hasNewNotes: async (parent, args, { me, models }) => { | ||||
|       if (!me) { | ||||
|         return false | ||||
|       } | ||||
|       const user = await models.user.findUnique({ where: { id: me.id } }) | ||||
|       const lastChecked = user.checkedNotesAt || new Date(0) | ||||
| 
 | ||||
|       // check if any votes have been cast for them since checkedNotesAt
 | ||||
|       if (user.noteItemSats) { | ||||
|         const votes = await models.$queryRaw(` | ||||
|         SELECT "ItemAct".id, "ItemAct".created_at | ||||
|           FROM "Item" | ||||
|           JOIN "ItemAct" on "ItemAct"."itemId" = "Item".id | ||||
|           WHERE "ItemAct"."userId" <> $1 | ||||
|           AND "ItemAct".created_at > $2 | ||||
|           AND "Item"."userId" = $1 | ||||
|           AND "ItemAct".act = 'TIP' | ||||
|           LIMIT 1`, me.id, lastChecked)
 | ||||
|         if (votes.length > 0) { | ||||
|           return true | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       // check if they have any replies since checkedNotesAt
 | ||||
|       const newReplies = await models.$queryRaw(` | ||||
|         SELECT "Item".id, "Item".created_at | ||||
|           FROM "Item" | ||||
|           JOIN "Item" p ON ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'} | ||||
|           WHERE p."userId" = $1 | ||||
|           AND "Item".created_at > $2  AND "Item"."userId" <> $1 | ||||
|           ${await filterClause(me, models)} | ||||
|           LIMIT 1`, me.id, lastChecked)
 | ||||
|       if (newReplies.length > 0) { | ||||
|         return true | ||||
|       } | ||||
| 
 | ||||
|       // check if they have any mentions since checkedNotesAt
 | ||||
|       if (user.noteMentions) { | ||||
|         const newMentions = await models.$queryRaw(` | ||||
|         SELECT "Item".id, "Item".created_at | ||||
|           FROM "Mention" | ||||
|           JOIN "Item" ON "Mention"."itemId" = "Item".id | ||||
|           WHERE "Mention"."userId" = $1 | ||||
|           AND "Mention".created_at > $2 | ||||
|           AND "Item"."userId" <> $1 | ||||
|           LIMIT 1`, me.id, lastChecked)
 | ||||
|         if (newMentions.length > 0) { | ||||
|           return true | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       const job = await models.item.findFirst({ | ||||
|         where: { | ||||
|           maxBid: { | ||||
|             not: null | ||||
|           }, | ||||
|           userId: me.id, | ||||
|           statusUpdatedAt: { | ||||
|             gt: lastChecked | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
|       if (job && job.statusUpdatedAt > job.createdAt) { | ||||
|         return true | ||||
|       } | ||||
| 
 | ||||
|       if (user.noteEarning) { | ||||
|         const earn = await models.earn.findFirst({ | ||||
|           where: { | ||||
|             userId: me.id, | ||||
|             createdAt: { | ||||
|               gt: lastChecked | ||||
|             }, | ||||
|             msats: { | ||||
|               gte: 1000 | ||||
|             } | ||||
|           } | ||||
|         }) | ||||
|         if (earn) { | ||||
|           return true | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       if (user.noteDeposits) { | ||||
|         const invoice = await models.invoice.findFirst({ | ||||
|           where: { | ||||
|             userId: me.id, | ||||
|             confirmedAt: { | ||||
|               gt: lastChecked | ||||
|             } | ||||
|           } | ||||
|         }) | ||||
|         if (invoice) { | ||||
|           return true | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       // check if new invites have been redeemed
 | ||||
|       if (user.noteInvites) { | ||||
|         const newInvitees = await models.$queryRaw(` | ||||
|         SELECT "Invite".id | ||||
|           FROM users JOIN "Invite" on users."inviteId" = "Invite".id | ||||
|           WHERE "Invite"."userId" = $1 | ||||
|           AND users.created_at > $2 | ||||
|           LIMIT 1`, me.id, lastChecked)
 | ||||
|         if (newInvitees.length > 0) { | ||||
|           return true | ||||
|         } | ||||
| 
 | ||||
|         const referral = await models.user.findFirst({ | ||||
|           where: { | ||||
|             referrerId: me.id, | ||||
|             createdAt: { | ||||
|               gt: lastChecked | ||||
|             } | ||||
|           } | ||||
|         }) | ||||
|         if (referral) { | ||||
|           return true | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       return false | ||||
|     }, | ||||
|     searchUsers: async (parent, { q, limit, similarity }, { models }) => { | ||||
|       return await models.$queryRaw` | ||||
|         SELECT * FROM users where id > 615 AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}` | ||||
| @ -178,12 +334,29 @@ export default { | ||||
|         throw error | ||||
|       } | ||||
|     }, | ||||
|     setSettings: async (parent, data, { me, models }) => { | ||||
|     setSettings: async (parent, { nostrRelays, ...data }, { me, models }) => { | ||||
|       if (!me) { | ||||
|         throw new AuthenticationError('you must be logged in') | ||||
|       } | ||||
| 
 | ||||
|       return await models.user.update({ where: { id: me.id }, data }) | ||||
|       if (nostrRelays?.length) { | ||||
|         const connectOrCreate = [] | ||||
|         for (const nr of nostrRelays) { | ||||
|           await models.nostrRelay.upsert({ | ||||
|             where: { addr: nr }, | ||||
|             update: { addr: nr }, | ||||
|             create: { addr: nr } | ||||
|           }) | ||||
|           connectOrCreate.push({ | ||||
|             where: { userId_nostrRelayAddr: { userId: me.id, nostrRelayAddr: nr } }, | ||||
|             create: { nostrRelayAddr: nr } | ||||
|           }) | ||||
|         } | ||||
| 
 | ||||
|         return await models.user.update({ where: { id: me.id }, data: { ...data, nostrRelays: { deleteMany: {}, connectOrCreate } } }) | ||||
|       } else { | ||||
|         return await models.user.update({ where: { id: me.id }, data: { ...data, nostrRelays: { deleteMany: {} } } }) | ||||
|       } | ||||
|     }, | ||||
|     setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => { | ||||
|       if (!me) { | ||||
| @ -310,22 +483,27 @@ export default { | ||||
| 
 | ||||
|       if (!when) { | ||||
|         // forever
 | ||||
|         return Math.floor((user.stackedMsats || 0) / 1000) | ||||
|         return (user.stackedMsats && msatsToSats(user.stackedMsats)) || 0 | ||||
|       } else { | ||||
|         const [{ stacked }] = await models.$queryRaw(` | ||||
|           SELECT sum(amount) as stacked | ||||
|           FROM | ||||
|           ((SELECT sum("ItemAct".sats) as amount | ||||
|           ((SELECT coalesce(sum("ItemAct".msats),0) as amount | ||||
|             FROM "ItemAct" | ||||
|             JOIN "Item" on "ItemAct"."itemId" = "Item".id | ||||
|             WHERE act <> 'BOOST' AND "ItemAct"."userId" <> $2 AND "Item"."userId" = $2 | ||||
|             AND "ItemAct".created_at >= $1) | ||||
|           UNION ALL | ||||
|           (SELECT sum("Earn".msats/1000) as amount | ||||
|             (SELECT coalesce(sum("ReferralAct".msats),0) as amount | ||||
|               FROM "ReferralAct" | ||||
|               WHERE "ReferralAct".msats > 0 AND "ReferralAct"."referrerId" = $2 | ||||
|               AND "ReferralAct".created_at >= $1) | ||||
|           UNION ALL | ||||
|           (SELECT coalesce(sum("Earn".msats), 0) as amount | ||||
|             FROM "Earn" | ||||
|             WHERE "Earn".msats > 0 AND "Earn"."userId" = $2 | ||||
|             AND "Earn".created_at >= $1)) u`, withinDate(when), Number(user.id))
 | ||||
|         return stacked || 0 | ||||
|         return (stacked && msatsToSats(stacked)) || 0 | ||||
|       } | ||||
|     }, | ||||
|     spent: async (user, { when }, { models }) => { | ||||
| @ -333,9 +511,9 @@ export default { | ||||
|         return user.spent | ||||
|       } | ||||
| 
 | ||||
|       const { sum: { sats } } = await models.itemAct.aggregate({ | ||||
|       const { sum: { msats } } = await models.itemAct.aggregate({ | ||||
|         sum: { | ||||
|           sats: true | ||||
|           msats: true | ||||
|         }, | ||||
|         where: { | ||||
|           userId: user.id, | ||||
| @ -345,13 +523,23 @@ export default { | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       return sats || 0 | ||||
|       return (msats && msatsToSats(msats)) || 0 | ||||
|     }, | ||||
|     referrals: async (user, { when }, { models }) => { | ||||
|       return await models.user.count({ | ||||
|         where: { | ||||
|           referrerId: user.id, | ||||
|           createdAt: { | ||||
|             gte: withinDate(when) | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
|     }, | ||||
|     sats: async (user, args, { models, me }) => { | ||||
|       if (me?.id !== user.id) { | ||||
|         return 0 | ||||
|       } | ||||
|       return Math.floor(user.msats / 1000.0) | ||||
|       return msatsToSats(user.msats) | ||||
|     }, | ||||
|     bio: async (user, args, { models }) => { | ||||
|       return getItem(user, { id: user.bioId }, { models }) | ||||
| @ -363,113 +551,12 @@ export default { | ||||
| 
 | ||||
|       return invites.length > 0 | ||||
|     }, | ||||
|     hasNewNotes: async (user, args, { me, models }) => { | ||||
|       const lastChecked = user.checkedNotesAt || new Date(0) | ||||
| 
 | ||||
|       // check if any votes have been cast for them since checkedNotesAt
 | ||||
|       if (user.noteItemSats) { | ||||
|         const votes = await models.$queryRaw(` | ||||
|         SELECT "ItemAct".id, "ItemAct".created_at | ||||
|           FROM "Item" | ||||
|           JOIN "ItemAct" on "ItemAct"."itemId" = "Item".id | ||||
|           WHERE "ItemAct"."userId" <> $1 | ||||
|           AND "ItemAct".created_at > $2 | ||||
|           AND "Item"."userId" = $1 | ||||
|           AND "ItemAct".act IN ('VOTE', 'TIP') | ||||
|           LIMIT 1`, me.id, lastChecked)
 | ||||
|         if (votes.length > 0) { | ||||
|           return true | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       // check if they have any replies since checkedNotesAt
 | ||||
|       const newReplies = await models.$queryRaw(` | ||||
|         SELECT "Item".id, "Item".created_at | ||||
|           FROM "Item" | ||||
|           JOIN "Item" p ON ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'} | ||||
|           WHERE p."userId" = $1 | ||||
|           AND "Item".created_at > $2  AND "Item"."userId" <> $1 | ||||
|           ${await filterClause(me, models)} | ||||
|           LIMIT 1`, me.id, lastChecked)
 | ||||
|       if (newReplies.length > 0) { | ||||
|         return true | ||||
|       } | ||||
| 
 | ||||
|       // check if they have any mentions since checkedNotesAt
 | ||||
|       if (user.noteMentions) { | ||||
|         const newMentions = await models.$queryRaw(` | ||||
|         SELECT "Item".id, "Item".created_at | ||||
|           FROM "Mention" | ||||
|           JOIN "Item" ON "Mention"."itemId" = "Item".id | ||||
|           WHERE "Mention"."userId" = $1 | ||||
|           AND "Mention".created_at > $2 | ||||
|           AND "Item"."userId" <> $1 | ||||
|           LIMIT 1`, me.id, lastChecked)
 | ||||
|         if (newMentions.length > 0) { | ||||
|           return true | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       const job = await models.item.findFirst({ | ||||
|         where: { | ||||
|           maxBid: { | ||||
|             not: null | ||||
|           }, | ||||
|           userId: me.id, | ||||
|           statusUpdatedAt: { | ||||
|             gt: lastChecked | ||||
|           } | ||||
|         } | ||||
|     nostrRelays: async (user, args, { models }) => { | ||||
|       const relays = await models.userNostrRelay.findMany({ | ||||
|         where: { userId: user.id } | ||||
|       }) | ||||
|       if (job) { | ||||
|         return true | ||||
|       } | ||||
| 
 | ||||
|       if (user.noteEarning) { | ||||
|         const earn = await models.earn.findFirst({ | ||||
|           where: { | ||||
|             userId: me.id, | ||||
|             createdAt: { | ||||
|               gt: lastChecked | ||||
|             }, | ||||
|             msats: { | ||||
|               gte: 1000 | ||||
|             } | ||||
|           } | ||||
|         }) | ||||
|         if (earn) { | ||||
|           return true | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       if (user.noteDeposits) { | ||||
|         const invoice = await models.invoice.findFirst({ | ||||
|           where: { | ||||
|             userId: me.id, | ||||
|             confirmedAt: { | ||||
|               gt: lastChecked | ||||
|             } | ||||
|           } | ||||
|         }) | ||||
|         if (invoice) { | ||||
|           return true | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       // check if new invites have been redeemed
 | ||||
|       if (user.noteInvites) { | ||||
|         const newInvitees = await models.$queryRaw(` | ||||
|         SELECT "Invite".id | ||||
|           FROM users JOIN "Invite" on users."inviteId" = "Invite".id | ||||
|           WHERE "Invite"."userId" = $1 | ||||
|           AND users.created_at > $2 | ||||
|           LIMIT 1`, me.id, lastChecked)
 | ||||
|         if (newInvitees.length > 0) { | ||||
|           return true | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       return false | ||||
|       return relays?.map(r => r.nostrRelayAddr) | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -5,6 +5,7 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' | ||||
| import lnpr from 'bolt11' | ||||
| import { SELECT } from './item' | ||||
| import { lnurlPayDescriptionHash } from '../../lib/lnurl' | ||||
| import { msatsToSats, msatsToSatsDecimal } from '../../lib/format' | ||||
| 
 | ||||
| export async function getInvoice (parent, { id }, { me, models }) { | ||||
|   if (!me) { | ||||
| @ -93,11 +94,11 @@ export default { | ||||
|       if (include.has('stacked')) { | ||||
|         queries.push( | ||||
|           `(SELECT ('stacked' || "Item".id) as id, "Item".id as "factId", NULL as bolt11,
 | ||||
|           MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".sats) * 1000 as msats, | ||||
|           MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats, | ||||
|           0 as "msatsFee", NULL as status, 'stacked' as type | ||||
|           FROM "ItemAct" | ||||
|           JOIN "Item" on "ItemAct"."itemId" = "Item".id | ||||
|           WHERE "ItemAct"."userId" <> $1 AND "ItemAct".act <> 'BOOST' | ||||
|           WHERE act = 'TIP' | ||||
|           AND (("Item"."userId" = $1 AND "Item"."fwdUserId" IS NULL) | ||||
|                 OR ("Item"."fwdUserId" = $1 AND "ItemAct"."userId" <> "Item"."userId")) | ||||
|           AND "ItemAct".created_at <= $2 | ||||
| @ -109,18 +110,31 @@ export default { | ||||
|             FROM "Earn" | ||||
|             WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2 | ||||
|             GROUP BY "userId", created_at)`)
 | ||||
|         queries.push( | ||||
|             `(SELECT ('referral' || "ReferralAct".id) as id, "ReferralAct".id as "factId", NULL as bolt11,
 | ||||
|             created_at as "createdAt", msats, | ||||
|             0 as "msatsFee", NULL as status, 'referral' as type | ||||
|             FROM "ReferralAct" | ||||
|             WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)`)
 | ||||
|       } | ||||
| 
 | ||||
|       if (include.has('spent')) { | ||||
|         queries.push( | ||||
|           `(SELECT ('spent' || "Item".id) as id, "Item".id as "factId", NULL as bolt11,
 | ||||
|           MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".sats) * 1000 as msats, | ||||
|           MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats, | ||||
|           0 as "msatsFee", NULL as status, 'spent' as type | ||||
|           FROM "ItemAct" | ||||
|           JOIN "Item" on "ItemAct"."itemId" = "Item".id | ||||
|           WHERE "ItemAct"."userId" = $1 | ||||
|           AND "ItemAct".created_at <= $2 | ||||
|           GROUP BY "Item".id)`)
 | ||||
|         queries.push( | ||||
|             `(SELECT ('donation' || "Donation".id) as id, "Donation".id as "factId", NULL as bolt11,
 | ||||
|             created_at as "createdAt", sats * 1000 as msats, | ||||
|             0 as "msatsFee", NULL as status, 'donation' as type | ||||
|             FROM "Donation" | ||||
|             WHERE "userId" = $1 | ||||
|             AND created_at <= $2)`)
 | ||||
|       } | ||||
| 
 | ||||
|       if (queries.length === 0) { | ||||
| @ -156,6 +170,9 @@ export default { | ||||
|           case 'spent': | ||||
|             f.msats *= -1 | ||||
|             break | ||||
|           case 'donation': | ||||
|             f.msats *= -1 | ||||
|             break | ||||
|           default: | ||||
|             break | ||||
|         } | ||||
| @ -254,10 +271,14 @@ export default { | ||||
|   }, | ||||
| 
 | ||||
|   Withdrawl: { | ||||
|     satsPaying: w => Math.floor(w.msatsPaying / 1000), | ||||
|     satsPaid: w => Math.floor(w.msatsPaid / 1000), | ||||
|     satsFeePaying: w => Math.floor(w.msatsFeePaying / 1000), | ||||
|     satsFeePaid: w => Math.floor(w.msatsFeePaid / 1000) | ||||
|     satsPaying: w => msatsToSats(w.msatsPaying), | ||||
|     satsPaid: w => msatsToSats(w.msatsPaid), | ||||
|     satsFeePaying: w => msatsToSats(w.msatsFeePaying), | ||||
|     satsFeePaid: w => msatsToSats(w.msatsFeePaid) | ||||
|   }, | ||||
| 
 | ||||
|   Invoice: { | ||||
|     satsReceived: i => msatsToSats(i.msatsReceived) | ||||
|   }, | ||||
| 
 | ||||
|   Fact: { | ||||
| @ -271,7 +292,9 @@ export default { | ||||
|         WHERE id = $1`, Number(fact.factId))
 | ||||
| 
 | ||||
|       return item | ||||
|     } | ||||
|     }, | ||||
|     sats: fact => msatsToSatsDecimal(fact.msats), | ||||
|     satsFee: fact => msatsToSatsDecimal(fact.msatsFee) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @ -285,7 +308,7 @@ async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd | ||||
|     throw new UserInputError('could not decode invoice') | ||||
|   } | ||||
| 
 | ||||
|   if (!decoded.mtokens || Number(decoded.mtokens) <= 0) { | ||||
|   if (!decoded.mtokens || BigInt(decoded.mtokens) <= 0) { | ||||
|     throw new UserInputError('your invoice must specify an amount') | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { ApolloClient, InMemoryCache } from '@apollo/client' | ||||
| import { SchemaLink } from '@apollo/client/link/schema' | ||||
| import { mergeSchemas } from 'graphql-tools' | ||||
| import { makeExecutableSchema } from 'graphql-tools' | ||||
| import { getSession } from 'next-auth/client' | ||||
| import resolvers from './resolvers' | ||||
| import typeDefs from './typeDefs' | ||||
| @ -8,17 +8,17 @@ import models from './models' | ||||
| import { print } from 'graphql' | ||||
| import lnd from './lnd' | ||||
| import search from './search' | ||||
| import { ME_SSR } from '../fragments/users' | ||||
| import { ME } from '../fragments/users' | ||||
| import { getPrice } from '../components/price' | ||||
| 
 | ||||
| export default async function getSSRApolloClient (req, me = null) { | ||||
|   const session = req && await getSession({ req }) | ||||
|   return new ApolloClient({ | ||||
|   const client = new ApolloClient({ | ||||
|     ssrMode: true, | ||||
|     link: new SchemaLink({ | ||||
|       schema: mergeSchemas({ | ||||
|         schemas: typeDefs, | ||||
|         resolvers: resolvers | ||||
|       schema: makeExecutableSchema({ | ||||
|         typeDefs, | ||||
|         resolvers | ||||
|       }), | ||||
|       context: { | ||||
|         models, | ||||
| @ -31,6 +31,8 @@ export default async function getSSRApolloClient (req, me = null) { | ||||
|     }), | ||||
|     cache: new InMemoryCache() | ||||
|   }) | ||||
|   await client.clearStore() | ||||
|   return client | ||||
| } | ||||
| 
 | ||||
| export function getGetServerSideProps (query, variables = null, notFoundFunc, requireVar) { | ||||
| @ -40,7 +42,7 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re | ||||
|     const client = await getSSRApolloClient(req) | ||||
| 
 | ||||
|     const { data: { me } } = await client.query({ | ||||
|       query: ME_SSR | ||||
|       query: ME | ||||
|     }) | ||||
| 
 | ||||
|     const price = await getPrice(me?.fiatCurrency) | ||||
|  | ||||
| @ -2,24 +2,12 @@ import { gql } from 'apollo-server-micro' | ||||
| 
 | ||||
| export default gql` | ||||
|   extend type Query { | ||||
|     registrationGrowth: [RegistrationGrowth!]! | ||||
|     activeGrowth: [TimeNum!]! | ||||
|     itemGrowth: [ItemGrowth!]! | ||||
|     spentGrowth: [SpentGrowth!]! | ||||
|     stackedGrowth: [StackedGrowth!]! | ||||
|     earnerGrowth: [TimeNum!]! | ||||
| 
 | ||||
|     registrationsWeekly: Int! | ||||
|     activeWeekly: Int! | ||||
|     earnersWeekly: Int! | ||||
|     itemsWeekly: [NameValue!]! | ||||
|     spentWeekly: [NameValue!]! | ||||
|     stackedWeekly: [NameValue!]! | ||||
|   } | ||||
| 
 | ||||
|   type TimeNum { | ||||
|     time: String! | ||||
|     num: Int! | ||||
|     registrationGrowth(when: String): [TimeData!]! | ||||
|     itemGrowth(when: String): [TimeData!]! | ||||
|     spendingGrowth(when: String): [TimeData!]! | ||||
|     spenderGrowth(when: String): [TimeData!]! | ||||
|     stackingGrowth(when: String): [TimeData!]! | ||||
|     stackerGrowth(when: String): [TimeData!]! | ||||
|   } | ||||
| 
 | ||||
|   type NameValue { | ||||
| @ -27,31 +15,8 @@ export default gql` | ||||
|     value: Int! | ||||
|   } | ||||
| 
 | ||||
|   type RegistrationGrowth { | ||||
|   type TimeData { | ||||
|     time: String! | ||||
|     invited: Int! | ||||
|     organic: Int! | ||||
|   } | ||||
| 
 | ||||
|   type ItemGrowth { | ||||
|     time: String! | ||||
|     jobs: Int! | ||||
|     posts: Int! | ||||
|     comments: Int! | ||||
|   } | ||||
| 
 | ||||
|   type StackedGrowth { | ||||
|     time: String! | ||||
|     rewards: Int! | ||||
|     posts: Int! | ||||
|     comments: Int! | ||||
|   } | ||||
| 
 | ||||
|   type SpentGrowth { | ||||
|     time: String! | ||||
|     jobs: Int! | ||||
|     fees: Int! | ||||
|     boost: Int! | ||||
|     tips: Int! | ||||
|     data: [NameValue!]! | ||||
|   } | ||||
| ` | ||||
|  | ||||
| @ -10,6 +10,8 @@ import invite from './invite' | ||||
| import sub from './sub' | ||||
| import upload from './upload' | ||||
| import growth from './growth' | ||||
| import rewards from './rewards' | ||||
| import referrals from './referrals' | ||||
| 
 | ||||
| const link = gql` | ||||
|   type Query { | ||||
| @ -26,4 +28,4 @@ const link = gql` | ||||
| ` | ||||
| 
 | ||||
| export default [link, user, item, message, wallet, lnurl, notifications, invite, | ||||
|   sub, upload, growth] | ||||
|   sub, upload, growth, rewards, referrals] | ||||
|  | ||||
| @ -2,7 +2,7 @@ import { gql } from 'apollo-server-micro' | ||||
| 
 | ||||
| export default gql` | ||||
|   extend type Query { | ||||
|     items(sub: String, sort: String, cursor: String, name: String, within: String): Items | ||||
|     items(sub: String, sort: String, type: String, cursor: String, name: String, within: String): Items | ||||
|     moreFlatComments(sort: String!, cursor: String, name: String, within: String): Comments | ||||
|     item(id: ID!): Item | ||||
|     comments(id: ID!, sort: String): [Item!]! | ||||
|  | ||||
| @ -50,8 +50,12 @@ export default gql` | ||||
|     sortTime: String! | ||||
|   } | ||||
| 
 | ||||
|   type Referral { | ||||
|     sortTime: String! | ||||
|   } | ||||
| 
 | ||||
|   union Notification = Reply | Votification | Mention | ||||
|     | Invitification | Earn | JobChanged | InvoicePaid | ||||
|     | Invitification | Earn | JobChanged | InvoicePaid | Referral | ||||
| 
 | ||||
|   type Notifications { | ||||
|     lastChecked: String | ||||
|  | ||||
							
								
								
									
										13
									
								
								api/typeDefs/referrals.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								api/typeDefs/referrals.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | ||||
| import { gql } from 'apollo-server-micro' | ||||
| 
 | ||||
| export default gql` | ||||
|   extend type Query { | ||||
|     referrals(when: String): Referrals! | ||||
|   } | ||||
| 
 | ||||
|   type Referrals { | ||||
|     totalSats: Int! | ||||
|     totalReferrals: Int! | ||||
|     stats: [TimeData!]! | ||||
|   } | ||||
| ` | ||||
							
								
								
									
										16
									
								
								api/typeDefs/rewards.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								api/typeDefs/rewards.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | ||||
| import { gql } from 'apollo-server-micro' | ||||
| 
 | ||||
| export default gql` | ||||
|   extend type Query { | ||||
|     expectedRewards: ExpectedRewards! | ||||
|   } | ||||
| 
 | ||||
|   extend type Mutation { | ||||
|     donateToRewards(sats: Int!): Int! | ||||
|   } | ||||
| 
 | ||||
|   type ExpectedRewards { | ||||
|     total: Int! | ||||
|     sources: [NameValue!]! | ||||
|   } | ||||
| ` | ||||
| @ -9,6 +9,7 @@ export default gql` | ||||
|     nameAvailable(name: String!): Boolean! | ||||
|     topUsers(cursor: String, when: String, sort: String): Users | ||||
|     searchUsers(q: String!, limit: Int, similarity: Float): [User!]! | ||||
|     hasNewNotes: Boolean! | ||||
|   } | ||||
| 
 | ||||
|   type Users { | ||||
| @ -18,10 +19,10 @@ export default gql` | ||||
| 
 | ||||
|   extend type Mutation { | ||||
|     setName(name: String!): Boolean | ||||
|     setSettings(tipDefault: Int!, fiatCurrency: String!, noteItemSats: Boolean!, noteEarning: Boolean!, | ||||
|       noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!, | ||||
|       noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!, | ||||
|       wildWestMode: Boolean!, greeterMode: Boolean!): User | ||||
|     setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, noteItemSats: Boolean!, | ||||
|       noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!, | ||||
|       noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!, hideFromTopUsers: Boolean!, | ||||
|       wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrRelays: [String!]): User | ||||
|     setPhoto(photoId: ID!): Int! | ||||
|     upsertBio(bio: String!): User! | ||||
|     setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean | ||||
| @ -44,12 +45,15 @@ export default gql` | ||||
|     ncomments(when: String): Int! | ||||
|     stacked(when: String): Int! | ||||
|     spent(when: String): Int! | ||||
|     referrals(when: String): Int! | ||||
|     freePosts: Int! | ||||
|     freeComments: Int! | ||||
|     hasNewNotes: Boolean! | ||||
|     hasInvites: Boolean! | ||||
|     tipDefault: Int! | ||||
|     turboTipping: Boolean! | ||||
|     fiatCurrency: String! | ||||
|     nostrPubkey: String | ||||
|     nostrRelays: [String!] | ||||
|     bio: Item | ||||
|     bioId: Int | ||||
|     photoId: Int | ||||
| @ -64,6 +68,7 @@ export default gql` | ||||
|     noteInvites: Boolean! | ||||
|     noteJobIndicator: Boolean! | ||||
|     hideInvoiceDesc: Boolean! | ||||
|     hideFromTopUsers: Boolean! | ||||
|     wildWestMode: Boolean! | ||||
|     greeterMode: Boolean! | ||||
|     lastCheckedJobs: String | ||||
|  | ||||
| @ -21,7 +21,7 @@ export default gql` | ||||
|     expiresAt: String! | ||||
|     cancelled: Boolean! | ||||
|     confirmedAt: String | ||||
|     msatsReceived: Int | ||||
|     satsReceived: Int | ||||
|   } | ||||
| 
 | ||||
|   type Withdrawl { | ||||
| @ -29,13 +29,9 @@ export default gql` | ||||
|     createdAt: String! | ||||
|     hash: String! | ||||
|     bolt11: String! | ||||
|     msatsPaying: Int! | ||||
|     satsPaying: Int! | ||||
|     msatsPaid: Int | ||||
|     satsPaid: Int | ||||
|     msatsFeePaying: Int! | ||||
|     satsFeePaying: Int! | ||||
|     msatsFeePaid: Int | ||||
|     satsFeePaid: Int | ||||
|     status: String | ||||
|   } | ||||
| @ -45,8 +41,8 @@ export default gql` | ||||
|     factId: ID! | ||||
|     bolt11: String | ||||
|     createdAt: String! | ||||
|     msats: Int! | ||||
|     msatsFee: Int | ||||
|     sats: Float! | ||||
|     satsFee: Float | ||||
|     status: String | ||||
|     type: String! | ||||
|     description: String | ||||
|  | ||||
| @ -18,6 +18,7 @@ import DontLikeThis from './dont-link-this' | ||||
| import Flag from '../svgs/flag-fill.svg' | ||||
| import { Badge } from 'react-bootstrap' | ||||
| import { abbrNum } from '../lib/format' | ||||
| import Share from './share' | ||||
| 
 | ||||
| function Parent ({ item, rootText }) { | ||||
|   const ParentFrag = () => ( | ||||
| @ -169,6 +170,7 @@ export default function Comment ({ | ||||
|                     localStorage.setItem(`commentCollapse:${item.id}`, 'yep') | ||||
|                   }} | ||||
|                 />)} | ||||
|             {topLevel && <Share item={item} />} | ||||
|           </div> | ||||
|           {edit | ||||
|             ? ( | ||||
|  | ||||
| @ -1,10 +1,11 @@ | ||||
| import { gql, useMutation } from '@apollo/client' | ||||
| import { Dropdown } from 'react-bootstrap' | ||||
| import MoreIcon from '../svgs/more-fill.svg' | ||||
| import { useFundError } from './fund-error' | ||||
| import FundError from './fund-error' | ||||
| import { useShowModal } from './modal' | ||||
| 
 | ||||
| export default function DontLikeThis ({ id }) { | ||||
|   const { setError } = useFundError() | ||||
|   const showModal = useShowModal() | ||||
| 
 | ||||
|   const [dontLikeThis] = useMutation( | ||||
|     gql` | ||||
| @ -41,7 +42,9 @@ export default function DontLikeThis ({ id }) { | ||||
|               }) | ||||
|             } catch (error) { | ||||
|               if (error.toString().includes('insufficient funds')) { | ||||
|                 setError(true) | ||||
|                 showModal(onClose => { | ||||
|                   return <FundError onClose={onClose} /> | ||||
|                 }) | ||||
|               } | ||||
|             } | ||||
|           }} | ||||
|  | ||||
| @ -35,6 +35,11 @@ const COLORS = { | ||||
|     brandColor: 'rgba(0, 0, 0, 0.9)', | ||||
|     grey: '#707070', | ||||
|     link: '#007cbe', | ||||
|     toolbarActive: 'rgba(0, 0, 0, 0.10)', | ||||
|     toolbarHover: 'rgba(0, 0, 0, 0.20)', | ||||
|     toolbar: '#ffffff', | ||||
|     quoteBar: 'rgb(206, 208, 212)', | ||||
|     quoteColor: 'rgb(101, 103, 107)', | ||||
|     linkHover: '#004a72', | ||||
|     linkVisited: '#537587' | ||||
|   }, | ||||
| @ -54,6 +59,11 @@ const COLORS = { | ||||
|     brandColor: 'var(--primary)', | ||||
|     grey: '#969696', | ||||
|     link: '#2e99d1', | ||||
|     toolbarActive: 'rgba(255, 255, 255, 0.10)', | ||||
|     toolbarHover: 'rgba(255, 255, 255, 0.20)', | ||||
|     toolbar: '#3e3f3f', | ||||
|     quoteBar: 'rgb(158, 159, 163)', | ||||
|     quoteColor: 'rgb(141, 144, 150)', | ||||
|     linkHover: '#007cbe', | ||||
|     linkVisited: '#56798E' | ||||
|   } | ||||
| @ -98,7 +108,7 @@ const AnalyticsPopover = ( | ||||
|         visitors | ||||
|       </a> | ||||
|       <span className='mx-2 text-dark'> \ </span> | ||||
|       <Link href='/users/week' passHref> | ||||
|       <Link href='/users/day' passHref> | ||||
|         <a className='text-dark d-inline-flex'> | ||||
|           users | ||||
|         </a> | ||||
| @ -126,7 +136,7 @@ export default function Footer ({ noLinks }) { | ||||
|   useEffect(() => { | ||||
|     setMounted(true) | ||||
|     setLightning(localStorage.getItem('lnAnimate') || 'yes') | ||||
|   }) | ||||
|   }, []) | ||||
| 
 | ||||
|   const toggleLightning = () => { | ||||
|     if (lightning === 'yes') { | ||||
| @ -151,6 +161,13 @@ export default function Footer ({ noLinks }) { | ||||
|                 <DarkModeIcon onClick={() => darkMode.toggle()} className='fill-grey theme' /> | ||||
|                 <LnIcon onClick={toggleLightning} width={24} height={24} className='ml-2 fill-grey theme' /> | ||||
|               </div>} | ||||
|             <div className='mb-0' style={{ fontWeight: 500 }}> | ||||
|               <Link href='/rewards' passHref> | ||||
|                 <a className='nav-link p-0 d-inline-flex'> | ||||
|                   rewards | ||||
|                 </a> | ||||
|               </Link> | ||||
|             </div> | ||||
|             <div className='mb-0' style={{ fontWeight: 500 }}> | ||||
|               <OverlayTrigger trigger='click' placement='top' overlay={AnalyticsPopover} rootClose> | ||||
|                 <div className='nav-link p-0 d-inline-flex' style={{ cursor: 'pointer' }}> | ||||
|  | ||||
| @ -299,7 +299,7 @@ export function Input ({ label, groupClassName, ...props }) { | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function VariableInput ({ label, groupClassName, name, hint, max, readOnlyLen, ...props }) { | ||||
| export function VariableInput ({ label, groupClassName, name, hint, max, min, readOnlyLen, ...props }) { | ||||
|   return ( | ||||
|     <FormGroup label={label} className={groupClassName}> | ||||
|       <FieldArray name={name}> | ||||
| @ -307,11 +307,11 @@ export function VariableInput ({ label, groupClassName, name, hint, max, readOnl | ||||
|           const options = form.values[name] | ||||
|           return ( | ||||
|             <> | ||||
|               {options.map((_, i) => ( | ||||
|               {options?.map((_, i) => ( | ||||
|                 <div key={i}> | ||||
|                   <BootstrapForm.Row className='mb-2'> | ||||
|                     <Col> | ||||
|                       <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i > 1 ? 'optional' : undefined} /> | ||||
|                       <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} /> | ||||
|                     </Col> | ||||
|                     {options.length - 1 === i && options.length !== max | ||||
|                       ? <AddIcon className='fill-grey align-self-center pointer mx-2' onClick={() => fieldArrayHelpers.push('')} /> | ||||
|  | ||||
| @ -1,48 +1,15 @@ | ||||
| import { Button, Modal } from 'react-bootstrap' | ||||
| import React, { useState, useCallback, useContext } from 'react' | ||||
| import Link from 'next/link' | ||||
| import { Button } from 'react-bootstrap' | ||||
| 
 | ||||
| export const FundErrorContext = React.createContext({ | ||||
|   error: null, | ||||
|   toggleError: () => {} | ||||
| }) | ||||
| 
 | ||||
| export function FundErrorProvider ({ children }) { | ||||
|   const [error, setError] = useState(false) | ||||
| 
 | ||||
|   const contextValue = { | ||||
|     error, | ||||
|     setError: useCallback(e => setError(e), []) | ||||
|   } | ||||
| 
 | ||||
| export default function FundError ({ onClose }) { | ||||
|   return ( | ||||
|     <FundErrorContext.Provider value={contextValue}> | ||||
|       {children} | ||||
|     </FundErrorContext.Provider> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function useFundError () { | ||||
|   const { error, setError } = useContext(FundErrorContext) | ||||
|   return { error, setError } | ||||
| } | ||||
| 
 | ||||
| export function FundErrorModal () { | ||||
|   const { error, setError } = useFundError() | ||||
|   return ( | ||||
|     <Modal | ||||
|       show={error} | ||||
|       onHide={() => setError(false)} | ||||
|     > | ||||
|       <div className='modal-close' onClick={() => setError(false)}>X</div> | ||||
|       <Modal.Body> | ||||
|     <> | ||||
|       <p className='font-weight-bolder'>you need more sats</p> | ||||
|       <div className='d-flex justify-content-end'> | ||||
|         <Link href='/wallet?type=fund'> | ||||
|             <Button variant='success' onClick={() => setError(false)}>fund</Button> | ||||
|           <Button variant='success' onClick={onClose}>fund</Button> | ||||
|         </Link> | ||||
|       </div> | ||||
|       </Modal.Body> | ||||
|     </Modal> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| @ -7,7 +7,7 @@ import { Button, Container, NavDropdown } from 'react-bootstrap' | ||||
| import Price from './price' | ||||
| import { useMe } from './me' | ||||
| import Head from 'next/head' | ||||
| import { signOut, signIn } from 'next-auth/client' | ||||
| import { signOut } from 'next-auth/client' | ||||
| import { useLightning } from './lightning' | ||||
| import { useEffect, useState } from 'react' | ||||
| import { randInRange } from '../lib/rand' | ||||
| @ -35,7 +35,11 @@ export default function Header ({ sub }) { | ||||
|       subLatestPost(name: $name) | ||||
|     } | ||||
|   `, { variables: { name: 'jobs' }, pollInterval: 600000, fetchPolicy: 'network-only' })
 | ||||
| 
 | ||||
|   const { data: hasNewNotes } = useQuery(gql` | ||||
|     { | ||||
|       hasNewNotes | ||||
|     } | ||||
|   `, { pollInterval: 30000, fetchPolicy: 'cache-and-network' })
 | ||||
|   const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime()) | ||||
|   useEffect(() => { | ||||
|     if (me) { | ||||
| @ -46,19 +50,19 @@ export default function Header ({ sub }) { | ||||
|       } | ||||
|       setLastCheckedJobs(localStorage.getItem('lastCheckedJobs')) | ||||
|     } | ||||
|   }) | ||||
|   }, [sub]) | ||||
| 
 | ||||
|   const Corner = () => { | ||||
|     if (me) { | ||||
|       return ( | ||||
|         <div className='d-flex align-items-center'> | ||||
|           <Head> | ||||
|             <link rel='shortcut icon' href={me?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} /> | ||||
|             <link rel='shortcut icon' href={hasNewNotes?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} /> | ||||
|           </Head> | ||||
|           <Link href='/notifications' passHref> | ||||
|             <Nav.Link eventKey='notifications' className='pl-0 position-relative'> | ||||
|               <NoteIcon /> | ||||
|               {me?.hasNewNotes && | ||||
|               <NoteIcon className='theme' /> | ||||
|               {hasNewNotes?.hasNewNotes && | ||||
|                 <span className={styles.notification}> | ||||
|                   <span className='invisible'>{' '}</span> | ||||
|                 </span>} | ||||
| @ -88,13 +92,8 @@ export default function Header ({ sub }) { | ||||
|                 <NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item> | ||||
|               </Link> | ||||
|               <NavDropdown.Divider /> | ||||
|               <Link href='/invites' passHref> | ||||
|                 <NavDropdown.Item eventKey='invites'>invites | ||||
|                   {me && !me.hasInvites && | ||||
|                     <div className='p-1 d-inline-block bg-success ml-1'> | ||||
|                       <span className='invisible'>{' '}</span> | ||||
|                     </div>} | ||||
|                 </NavDropdown.Item> | ||||
|               <Link href='/referrals/month' passHref> | ||||
|                 <NavDropdown.Item eventKey='referrals'>referrals</NavDropdown.Item> | ||||
|               </Link> | ||||
|               <NavDropdown.Divider /> | ||||
|               <div className='d-flex align-items-center'> | ||||
| @ -135,18 +134,30 @@ export default function Header ({ sub }) { | ||||
|           return () => { isMounted = false } | ||||
|         }, []) | ||||
|       } | ||||
|       return path !== '/login' && !path.startsWith('/invites') && | ||||
|       return path !== '/login' && path !== '/signup' && !path.startsWith('/invites') && | ||||
|         <div> | ||||
|           <Button | ||||
|           className='align-items-center d-flex pl-2 pr-3' | ||||
|             className='align-items-center px-3 py-1 mr-2' | ||||
|             id='signup' | ||||
|             style={{ borderWidth: '2px' }} | ||||
|             variant='outline-grey-darkmode' | ||||
|             onClick={async () => await router.push({ pathname: '/login', query: { callbackUrl: window.location.origin + router.asPath } })} | ||||
|           > | ||||
|             login | ||||
|           </Button> | ||||
|           <Button | ||||
|             className='align-items-center pl-2 py-1 pr-3' | ||||
|             style={{ borderWidth: '2px' }} | ||||
|             id='login' | ||||
|           onClick={() => signIn(null, { callbackUrl: window.location.origin + router.asPath })} | ||||
|             onClick={async () => await router.push({ pathname: '/signup', query: { callbackUrl: window.location.origin + router.asPath } })} | ||||
|           > | ||||
|             <LightningIcon | ||||
|               width={17} | ||||
|               height={17} | ||||
|               className='mr-1' | ||||
|           />login | ||||
|             />sign up | ||||
|           </Button> | ||||
|         </div> | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -11,6 +11,7 @@ | ||||
| 
 | ||||
| .navLinkButton { | ||||
|     border: 2px solid; | ||||
|     padding: 0.2rem .9rem !important; | ||||
|     border-radius: .4rem; | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -4,7 +4,7 @@ import ThumbDown from '../svgs/thumb-down-fill.svg' | ||||
| 
 | ||||
| function InvoiceDefaultStatus ({ status }) { | ||||
|   return ( | ||||
|     <div className='d-flex mt-2'> | ||||
|     <div className='d-flex mt-2 justify-content-center'> | ||||
|       <Moon className='spin fill-grey' /> | ||||
|       <div className='ml-3 text-muted' style={{ fontWeight: '600' }}>{status}</div> | ||||
|     </div> | ||||
| @ -13,7 +13,7 @@ function InvoiceDefaultStatus ({ status }) { | ||||
| 
 | ||||
| function InvoiceConfirmedStatus ({ status }) { | ||||
|   return ( | ||||
|     <div className='d-flex mt-2'> | ||||
|     <div className='d-flex mt-2 justify-content-center'> | ||||
|       <Check className='fill-success' /> | ||||
|       <div className='ml-3 text-success' style={{ fontWeight: '600' }}>{status}</div> | ||||
|     </div> | ||||
| @ -22,7 +22,7 @@ function InvoiceConfirmedStatus ({ status }) { | ||||
| 
 | ||||
| function InvoiceFailedStatus ({ status }) { | ||||
|   return ( | ||||
|     <div className='d-flex mt-2'> | ||||
|     <div className='d-flex mt-2 justify-content-center'> | ||||
|       <ThumbDown className='fill-danger' /> | ||||
|       <div className='ml-3 text-danger' style={{ fontWeight: '600' }}>{status}</div> | ||||
|     </div> | ||||
|  | ||||
| @ -5,7 +5,7 @@ export function Invoice ({ invoice }) { | ||||
|   let status = 'waiting for you' | ||||
|   if (invoice.confirmedAt) { | ||||
|     variant = 'confirmed' | ||||
|     status = `${invoice.msatsReceived / 1000} sats deposited` | ||||
|     status = `${invoice.satsReceived} sats deposited` | ||||
|   } else if (invoice.cancelled) { | ||||
|     variant = 'failed' | ||||
|     status = 'cancelled' | ||||
|  | ||||
| @ -1,57 +1,25 @@ | ||||
| import { InputGroup, Modal } from 'react-bootstrap' | ||||
| import React, { useState, useCallback, useContext, useRef, useEffect } from 'react' | ||||
| import { Button, InputGroup } from 'react-bootstrap' | ||||
| import React, { useState, useRef, useEffect } from 'react' | ||||
| import * as Yup from 'yup' | ||||
| import { Form, Input, SubmitButton } from './form' | ||||
| import { useMe } from './me' | ||||
| 
 | ||||
| export const ItemActContext = React.createContext({ | ||||
|   item: null, | ||||
|   setItem: () => {} | ||||
| }) | ||||
| 
 | ||||
| export function ItemActProvider ({ children }) { | ||||
|   const [item, setItem] = useState(null) | ||||
| 
 | ||||
|   const contextValue = { | ||||
|     item, | ||||
|     setItem: useCallback(i => setItem(i), []) | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <ItemActContext.Provider value={contextValue}> | ||||
|       {children} | ||||
|     </ItemActContext.Provider> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function useItemAct () { | ||||
|   const { item, setItem } = useContext(ItemActContext) | ||||
|   return { item, setItem } | ||||
| } | ||||
| import UpBolt from '../svgs/bolt.svg' | ||||
| 
 | ||||
| export const ActSchema = Yup.object({ | ||||
|   amount: Yup.number().typeError('must be a number').required('required') | ||||
|     .positive('must be positive').integer('must be whole') | ||||
| }) | ||||
| 
 | ||||
| export function ItemActModal () { | ||||
|   const { item, setItem } = useItemAct() | ||||
| export default function ItemAct ({ onClose, itemId, act, strike }) { | ||||
|   const inputRef = useRef(null) | ||||
|   const me = useMe() | ||||
|   const [oValue, setOValue] = useState() | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     inputRef.current?.focus() | ||||
|   }, [item]) | ||||
|   }, [onClose, itemId]) | ||||
| 
 | ||||
|   return ( | ||||
|     <Modal | ||||
|       show={!!item} | ||||
|       onHide={() => { | ||||
|         setItem(null) | ||||
|       }} | ||||
|     > | ||||
|       <div className='modal-close' onClick={() => setItem(null)}>X</div> | ||||
|       <Modal.Body> | ||||
|     <Form | ||||
|       initial={{ | ||||
|         amount: me?.tipDefault, | ||||
| @ -59,29 +27,43 @@ export function ItemActModal () { | ||||
|       }} | ||||
|       schema={ActSchema} | ||||
|       onSubmit={async ({ amount }) => { | ||||
|             await item.act({ | ||||
|         await act({ | ||||
|           variables: { | ||||
|                 id: item.itemId, | ||||
|             id: itemId, | ||||
|             sats: Number(amount) | ||||
|           } | ||||
|         }) | ||||
|             await item.strike() | ||||
|             setItem(null) | ||||
|         await strike() | ||||
|         onClose() | ||||
|       }} | ||||
|     > | ||||
|       <Input | ||||
|         label='amount' | ||||
|         name='amount' | ||||
|         innerRef={inputRef} | ||||
|         overrideValue={oValue} | ||||
|         required | ||||
|         autoFocus | ||||
|         append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} | ||||
|       /> | ||||
|       <div> | ||||
|         {[1, 10, 100, 1000, 10000].map(num => | ||||
|           <Button | ||||
|             size='sm' | ||||
|             className={`${num > 1 ? 'ml-2' : ''} mb-2`} | ||||
|             key={num} | ||||
|             onClick={() => { setOValue(num) }} | ||||
|           > | ||||
|             <UpBolt | ||||
|               className='mr-1' | ||||
|               width={14} | ||||
|               height={14} | ||||
|             />{num} | ||||
|           </Button>)} | ||||
|       </div> | ||||
|       <div className='d-flex'> | ||||
|         <SubmitButton variant='success' className='ml-auto mt-1 px-4' value='TIP'>tip</SubmitButton> | ||||
|       </div> | ||||
|     </Form> | ||||
|       </Modal.Body> | ||||
|     </Modal> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| @ -1,11 +1,12 @@ | ||||
| import * as Yup from 'yup' | ||||
| import Toc from './table-of-contents' | ||||
| import { Button, Image } from 'react-bootstrap' | ||||
| import { Badge, Button, Image } from 'react-bootstrap' | ||||
| import { SearchTitle } from './item' | ||||
| import styles from './item.module.css' | ||||
| import Link from 'next/link' | ||||
| import { timeSince } from '../lib/time' | ||||
| import EmailIcon from '../svgs/mail-open-line.svg' | ||||
| import Share from './share' | ||||
| 
 | ||||
| export default function ItemJob ({ item, toc, rank, children }) { | ||||
|   const isEmail = Yup.string().email().isValidSync(item.url) | ||||
| @ -59,6 +60,7 @@ export default function ItemJob ({ item, toc, rank, children }) { | ||||
|               </Link> | ||||
|             </span> | ||||
|             {item.mine && | ||||
|               ( | ||||
|                 <> | ||||
|                   <wbr /> | ||||
|                   <span> \ </span> | ||||
| @ -68,11 +70,16 @@ export default function ItemJob ({ item, toc, rank, children }) { | ||||
|                     </a> | ||||
|                   </Link> | ||||
|                   {item.status !== 'ACTIVE' && <span className='ml-1 font-weight-bold text-boost'> {item.status}</span>} | ||||
|                 </>)} | ||||
|             {item.maxBid > 0 && item.status === 'ACTIVE' && <Badge className={`${styles.newComment} ml-1`}>PROMOTED</Badge>} | ||||
|           </div> | ||||
|         </div> | ||||
|         {toc && | ||||
|           <> | ||||
|             <Share item={item} /> | ||||
|             <Toc text={item.text} /> | ||||
|           </>} | ||||
|       </div> | ||||
|         </div> | ||||
|         {toc && <Toc text={item.text} />} | ||||
|       </div> | ||||
|       {children && ( | ||||
|         <div className={`${styles.children}`} style={{ marginLeft: 'calc(42px + .8rem)' }}> | ||||
|           <div className='mb-3 d-flex'> | ||||
|  | ||||
| @ -14,6 +14,7 @@ import { newComments } from '../lib/new-comments' | ||||
| import { useMe } from './me' | ||||
| import DontLikeThis from './dont-link-this' | ||||
| import Flag from '../svgs/flag-fill.svg' | ||||
| import Share from './share' | ||||
| import { abbrNum } from '../lib/format' | ||||
| 
 | ||||
| export function SearchTitle ({ title }) { | ||||
| @ -141,7 +142,11 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) { | ||||
|           </div> | ||||
|           {showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />} | ||||
|         </div> | ||||
|         {toc && <Toc text={item.text} />} | ||||
|         {toc && | ||||
|           <> | ||||
|             <Share item={item} /> | ||||
|             <Toc text={item.text} /> | ||||
|           </>} | ||||
|       </div> | ||||
|       {children && ( | ||||
|         <div className={styles.children}> | ||||
|  | ||||
| @ -14,6 +14,7 @@ import Avatar from './avatar' | ||||
| import BootstrapForm from 'react-bootstrap/Form' | ||||
| import Alert from 'react-bootstrap/Alert' | ||||
| import { useMe } from './me' | ||||
| import ActionTooltip from './action-tooltip' | ||||
| 
 | ||||
| Yup.addMethod(Yup.string, 'or', function (schemas, msg) { | ||||
|   return this.test({ | ||||
| @ -183,7 +184,15 @@ export default function JobForm ({ item, sub }) { | ||||
|         /> | ||||
|         <PromoteJob item={item} sub={sub} storageKeyPrefix={storageKeyPrefix} /> | ||||
|         {item && <StatusControl item={item} />} | ||||
|         <SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton> | ||||
|         <div className='d-flex align-items-center mt-3'> | ||||
|           {item | ||||
|             ? <SubmitButton variant='secondary'>save</SubmitButton> | ||||
|             : ( | ||||
|               <ActionTooltip overlayText='1000 sats'> | ||||
|                 <SubmitButton variant='secondary'>post <small> 1000 sats</small></SubmitButton> | ||||
|               </ActionTooltip> | ||||
|               )} | ||||
|         </div> | ||||
|       </Form> | ||||
|     </> | ||||
|   ) | ||||
|  | ||||
| @ -1,10 +1,10 @@ | ||||
| import Layout from './layout' | ||||
| import styles from './layout-center.module.css' | ||||
| 
 | ||||
| export default function LayoutCenter ({ children, ...props }) { | ||||
| export default function LayoutCenter ({ children, footerLinks, ...props }) { | ||||
|   return ( | ||||
|     <div className={styles.page}> | ||||
|       <Layout noContain noFooterLinks {...props}> | ||||
|       <Layout noContain noFooterLinks={!footerLinks} {...props}> | ||||
|         <div className={styles.content}> | ||||
|           {children} | ||||
|         </div> | ||||
|  | ||||
| @ -1,7 +1,12 @@ | ||||
| import { gql, useMutation, useQuery } from '@apollo/client' | ||||
| import { signIn } from 'next-auth/client' | ||||
| import { useEffect } from 'react' | ||||
| import { Col, Container, Row } from 'react-bootstrap' | ||||
| import AccordianItem from './accordian-item' | ||||
| import LnQR, { LnQRSkeleton } from './lnqr' | ||||
| import styles from './lightning-auth.module.css' | ||||
| import BackIcon from '../svgs/arrow-left-line.svg' | ||||
| import { useRouter } from 'next/router' | ||||
| 
 | ||||
| function LnQRAuth ({ k1, encodedUrl, callbackUrl }) { | ||||
|   const query = gql` | ||||
| @ -19,16 +24,67 @@ function LnQRAuth ({ k1, encodedUrl, callbackUrl }) { | ||||
| 
 | ||||
|   // output pubkey and k1
 | ||||
|   return ( | ||||
|     <> | ||||
|       <small className='mb-2'> | ||||
|         <a className='text-muted text-underline' href='https://github.com/fiatjaf/lnurl-rfc#lnurl-documents' target='_blank' rel='noreferrer' style={{ textDecoration: 'underline' }}>Does my wallet support lnurl-auth?</a> | ||||
|       </small> | ||||
|     <LnQR value={encodedUrl} status='waiting for you' /> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function LightningAuth ({ callbackUrl }) { | ||||
| function LightningExplainer ({ text, children }) { | ||||
|   const router = useRouter() | ||||
|   return ( | ||||
|     <Container sm> | ||||
|       <div className={styles.login}> | ||||
|         <div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div> | ||||
|         <h3 className='w-100 pb-2'> | ||||
|           {text || 'Login'} with Lightning | ||||
|         </h3> | ||||
|         <div className='font-weight-bold text-muted pb-4'>This is the most private way to use Stacker News. Just open your Lightning wallet and scan the QR code.</div> | ||||
|         <Row className='w-100 text-muted'> | ||||
|           <Col className='pl-0 mb-4' md> | ||||
|             <AccordianItem | ||||
|               header={`Which wallets can I use to ${(text || 'Login').toLowerCase()}?`} | ||||
|               body={ | ||||
|                 <> | ||||
|                   <Row className='mb-3 no-gutters'> | ||||
|                     You can use any wallet that supports lnurl-auth. These are some wallets you can use: | ||||
|                   </Row> | ||||
|                   <Row> | ||||
|                     <Col xs> | ||||
|                       <ul className='mb-0'> | ||||
|                         <li>Alby</li> | ||||
|                         <li>Balance of Satoshis</li> | ||||
|                         <li>Blixt</li> | ||||
|                         <li>Breez</li> | ||||
|                         <li>Blue Wallet</li> | ||||
|                         <li>Coinos</li> | ||||
|                         <li>LNBits</li> | ||||
|                         <li>LNtxtbot</li> | ||||
|                       </ul> | ||||
|                     </Col> | ||||
|                     <Col xs> | ||||
|                       <ul> | ||||
|                         <li>Phoenix</li> | ||||
|                         <li>Simple Bitcoin Wallet</li> | ||||
|                         <li>Sparrow Wallet</li> | ||||
|                         <li>ThunderHub</li> | ||||
|                         <li>Zap Desktop</li> | ||||
|                         <li>Zeus</li> | ||||
|                       </ul> | ||||
|                     </Col> | ||||
|                   </Row> | ||||
|                 </> | ||||
|           } | ||||
|             /> | ||||
|           </Col> | ||||
|           <Col md className='mx-auto' style={{ maxWidth: '300px' }}> | ||||
|             {children} | ||||
|           </Col> | ||||
|         </Row> | ||||
|       </div> | ||||
|     </Container> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function LightningAuth ({ text, callbackUrl }) { | ||||
|   // query for challenge
 | ||||
|   const [createAuth, { data, error }] = useMutation(gql` | ||||
|     mutation createAuth { | ||||
| @ -38,13 +94,15 @@ export function LightningAuth ({ callbackUrl }) { | ||||
|       } | ||||
|     }`)
 | ||||
| 
 | ||||
|   useEffect(createAuth, []) | ||||
|   useEffect(() => { | ||||
|     createAuth() | ||||
|   }, []) | ||||
| 
 | ||||
|   if (error) return <div>error</div> | ||||
| 
 | ||||
|   if (!data) { | ||||
|     return <LnQRSkeleton status='generating' /> | ||||
|   } | ||||
| 
 | ||||
|   return <LnQRAuth {...data.createAuth} callbackUrl={callbackUrl} /> | ||||
|   return ( | ||||
|     <LightningExplainer text={text}> | ||||
|       {data ? <LnQRAuth {...data.createAuth} callbackUrl={callbackUrl} /> : <LnQRSkeleton status='generating' />} | ||||
|     </LightningExplainer> | ||||
|   ) | ||||
| } | ||||
|  | ||||
							
								
								
									
										8
									
								
								components/lightning-auth.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								components/lightning-auth.module.css
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | ||||
| .login { | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     padding-top: 3rem; | ||||
|     padding-bottom: 3rem; | ||||
| } | ||||
| @ -8,11 +8,9 @@ import { ITEM_FIELDS } from '../fragments/items' | ||||
| import Item from './item' | ||||
| import AccordianItem from './accordian-item' | ||||
| import { MAX_TITLE_LENGTH } from '../lib/constants' | ||||
| import { URL_REGEXP } from '../lib/url' | ||||
| import FeeButton, { EditFeeButton } from './fee-button' | ||||
| 
 | ||||
| // eslint-disable-next-line
 | ||||
| const URL = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i | ||||
| 
 | ||||
| export function LinkForm ({ item, editThreshold }) { | ||||
|   const router = useRouter() | ||||
|   const client = useApolloClient() | ||||
| @ -46,7 +44,7 @@ export function LinkForm ({ item, editThreshold }) { | ||||
|     title: Yup.string().required('required').trim() | ||||
|       .max(MAX_TITLE_LENGTH, | ||||
|         ({ max, value }) => `${Math.abs(max - value.length)} too many`), | ||||
|     url: Yup.string().matches(URL, 'invalid url').required('required'), | ||||
|     url: Yup.string().matches(URL_REGEXP, 'invalid url').required('required'), | ||||
|     ...AdvPostSchema(client) | ||||
|   }) | ||||
| 
 | ||||
|  | ||||
| @ -7,7 +7,8 @@ import { useEffect } from 'react' | ||||
| export default function LnQR ({ value, webLn, statusVariant, status }) { | ||||
|   const qrValue = 'lightning:' + value.toUpperCase() | ||||
| 
 | ||||
|   useEffect(async () => { | ||||
|   useEffect(() => { | ||||
|     async function effect () { | ||||
|       if (webLn) { | ||||
|         try { | ||||
|           const provider = await requestProvider() | ||||
| @ -16,11 +17,13 @@ export default function LnQR ({ value, webLn, statusVariant, status }) { | ||||
|           console.log(e.message) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     effect() | ||||
|   }, []) | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <a className='d-block p-3' style={{ background: 'white' }} href={qrValue}> | ||||
|       <a className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }} href={qrValue}> | ||||
|         <QRCode | ||||
|           className='h-auto mw-100' value={qrValue} renderAs='svg' size={300} | ||||
|         /> | ||||
|  | ||||
| @ -8,7 +8,6 @@ import { Form, Input, SubmitButton } from '../components/form' | ||||
| import * as Yup from 'yup' | ||||
| import { useState } from 'react' | ||||
| import Alert from 'react-bootstrap/Alert' | ||||
| import LayoutCenter from '../components/layout-center' | ||||
| import { useRouter } from 'next/router' | ||||
| import { LightningAuth } from './lightning-auth' | ||||
| 
 | ||||
| @ -16,7 +15,7 @@ export const EmailSchema = Yup.object({ | ||||
|   email: Yup.string().email('email is no good').required('required') | ||||
| }) | ||||
| 
 | ||||
| export function EmailLoginForm ({ callbackUrl }) { | ||||
| export function EmailLoginForm ({ text, callbackUrl }) { | ||||
|   return ( | ||||
|     <Form | ||||
|       initial={{ | ||||
| @ -34,12 +33,12 @@ export function EmailLoginForm ({ callbackUrl }) { | ||||
|         required | ||||
|         autoFocus | ||||
|       /> | ||||
|       <SubmitButton variant='secondary' className={styles.providerButton}>Login with Email</SubmitButton> | ||||
|       <SubmitButton variant='secondary' className={styles.providerButton}>{text || 'Login'} with Email</SubmitButton> | ||||
|     </Form> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export default function Login ({ providers, callbackUrl, error, Header }) { | ||||
| export default function Login ({ providers, callbackUrl, error, text, Header, Footer }) { | ||||
|   const errors = { | ||||
|     Signin: 'Try signing with a different account.', | ||||
|     OAuthSignin: 'Try signing with a different account.', | ||||
| @ -56,19 +55,20 @@ export default function Login ({ providers, callbackUrl, error, Header }) { | ||||
|   const [errorMessage, setErrorMessage] = useState(error && (errors[error] ?? errors.default)) | ||||
|   const router = useRouter() | ||||
| 
 | ||||
|   if (router.query.type === 'lightning') { | ||||
|     return <LightningAuth callbackUrl={callbackUrl} text={text} /> | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <LayoutCenter noFooter> | ||||
|     <div className={styles.login}> | ||||
|       {Header && <Header />} | ||||
|         <div className='text-center font-weight-bold text-muted pb-4'> | ||||
|           Not registered? Just login, we'll automatically create an account. | ||||
|         </div> | ||||
|       {errorMessage && | ||||
|           <Alert variant='danger' onClose={() => setErrorMessage(undefined)} dismissible>{errorMessage}</Alert>} | ||||
|         {router.query.type === 'lightning' | ||||
|           ? <LightningAuth callbackUrl={callbackUrl} /> | ||||
|           : ( | ||||
|             <> | ||||
|         <Alert | ||||
|           variant='danger' | ||||
|           onClose={() => setErrorMessage(undefined)} | ||||
|           dismissible | ||||
|         >{errorMessage} | ||||
|         </Alert>} | ||||
|       <Button | ||||
|         className={`mt-2 ${styles.providerButton}`} | ||||
|         variant='primary' | ||||
| @ -81,9 +81,9 @@ export default function Login ({ providers, callbackUrl, error, Header }) { | ||||
|           width={20} | ||||
|           height={20} | ||||
|           className='mr-3' | ||||
|                 />Login with Lightning | ||||
|         />{text || 'Login'} with Lightning | ||||
|       </Button> | ||||
|               {Object.values(providers).map(provider => { | ||||
|       {providers && Object.values(providers).map(provider => { | ||||
|         if (provider.name === 'Email' || provider.name === 'Lightning') { | ||||
|           return null | ||||
|         } | ||||
| @ -101,14 +101,13 @@ export default function Login ({ providers, callbackUrl, error, Header }) { | ||||
|           > | ||||
|             <Icon | ||||
|               className='mr-3' | ||||
|                     />Login with {provider.name} | ||||
|             />{text || 'Login'} with {provider.name} | ||||
|           </Button> | ||||
|         ) | ||||
|       })} | ||||
|       <div className='mt-2 text-center text-muted font-weight-bold'>or</div> | ||||
|               <EmailLoginForm callbackUrl={callbackUrl} /> | ||||
|             </>)} | ||||
|       <EmailLoginForm text={text} callbackUrl={callbackUrl} /> | ||||
|       {Footer && <Footer />} | ||||
|     </div> | ||||
|     </LayoutCenter> | ||||
|   ) | ||||
| } | ||||
|  | ||||
							
								
								
									
										51
									
								
								components/modal.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								components/modal.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,51 @@ | ||||
| import { createContext, useCallback, useContext, useMemo, useState } from 'react' | ||||
| import { Modal } from 'react-bootstrap' | ||||
| 
 | ||||
| export const ShowModalContext = createContext(() => null) | ||||
| 
 | ||||
| export function ShowModalProvider ({ children }) { | ||||
|   const [modal, showModal] = useModal() | ||||
|   const contextValue = showModal | ||||
| 
 | ||||
|   return ( | ||||
|     <ShowModalContext.Provider value={contextValue}> | ||||
|       {children} | ||||
|       {modal} | ||||
|     </ShowModalContext.Provider> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function useShowModal () { | ||||
|   return useContext(ShowModalContext) | ||||
| } | ||||
| 
 | ||||
| export default function useModal () { | ||||
|   const [modalContent, setModalContent] = useState(null) | ||||
| 
 | ||||
|   const onClose = useCallback(() => { | ||||
|     setModalContent(null) | ||||
|   }, []) | ||||
| 
 | ||||
|   const modal = useMemo(() => { | ||||
|     if (modalContent === null) { | ||||
|       return null | ||||
|     } | ||||
|     return ( | ||||
|       <Modal onHide={onClose} show={!!modalContent}> | ||||
|         <div className='modal-close' onClick={onClose}>X</div> | ||||
|         <Modal.Body> | ||||
|           {modalContent} | ||||
|         </Modal.Body> | ||||
|       </Modal> | ||||
|     ) | ||||
|   }, [modalContent, onClose]) | ||||
| 
 | ||||
|   const showModal = useCallback( | ||||
|     (getContent) => { | ||||
|       setModalContent(getContent(onClose)) | ||||
|     }, | ||||
|     [onClose] | ||||
|   ) | ||||
| 
 | ||||
|   return [modal, showModal] | ||||
| } | ||||
| @ -20,7 +20,7 @@ function Notification ({ n }) { | ||||
|     <div | ||||
|       className='clickToContext' | ||||
|       onClick={e => { | ||||
|         if (n.__typename === 'Earn') { | ||||
|         if (n.__typename === 'Earn' || n.__typename === 'Referral') { | ||||
|           return | ||||
|         } | ||||
| 
 | ||||
| @ -88,6 +88,15 @@ function Notification ({ n }) { | ||||
|               </div> | ||||
|             </div> | ||||
|             ) | ||||
|           : n.__typename === 'Referral' | ||||
|             ? ( | ||||
|               <> | ||||
|                 <small className='font-weight-bold text-secondary ml-2'> | ||||
|                   someone joined via one of your <Link href='/referrals/month' passHref><a className='text-reset'>referral links</a></Link> | ||||
|                   <small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small> | ||||
|                 </small> | ||||
|               </> | ||||
|               ) | ||||
|             : n.__typename === 'InvoicePaid' | ||||
|               ? ( | ||||
|                 <div className='font-weight-bold text-info ml-2 py-1'> | ||||
|  | ||||
| @ -86,6 +86,7 @@ export function PollForm ({ item, editThreshold }) { | ||||
|         name='options' | ||||
|         readOnlyLen={initialOptions?.length} | ||||
|         max={MAX_POLL_NUM_CHOICES} | ||||
|         min={2} | ||||
|         hint={editThreshold | ||||
|           ? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div> | ||||
|           : null} | ||||
|  | ||||
| @ -6,12 +6,13 @@ import { useMe } from './me' | ||||
| import styles from './poll.module.css' | ||||
| import Check from '../svgs/checkbox-circle-fill.svg' | ||||
| import { signIn } from 'next-auth/client' | ||||
| import { useFundError } from './fund-error' | ||||
| import ActionTooltip from './action-tooltip' | ||||
| import { useShowModal } from './modal' | ||||
| import FundError from './fund-error' | ||||
| 
 | ||||
| export default function Poll ({ item }) { | ||||
|   const me = useMe() | ||||
|   const { setError } = useFundError() | ||||
|   const showModal = useShowModal() | ||||
|   const [pollVote] = useMutation( | ||||
|     gql` | ||||
|       mutation pollVote($id: ID!) { | ||||
| @ -60,7 +61,9 @@ export default function Poll ({ item }) { | ||||
|                   }) | ||||
|                 } catch (error) { | ||||
|                   if (error.toString().includes('insufficient funds')) { | ||||
|                     setError(true) | ||||
|                     showModal(onClose => { | ||||
|                       return <FundError onClose={onClose} /> | ||||
|                     }) | ||||
|                   } | ||||
|                 } | ||||
|               } | ||||
|  | ||||
| @ -78,7 +78,7 @@ export default function Price () { | ||||
| 
 | ||||
|   if (asSats === 'yep') { | ||||
|     return ( | ||||
|       <Button className='text-reset p-0' onClick={handleClick} variant='link'> | ||||
|       <Button className='text-reset p-0 line-height-1' onClick={handleClick} variant='link'> | ||||
|         {fixedDecimal(100000000 / price, 0) + ` sats/${fiatSymbol}`} | ||||
|       </Button> | ||||
|     ) | ||||
| @ -86,14 +86,14 @@ export default function Price () { | ||||
| 
 | ||||
|   if (asSats === '1btc') { | ||||
|     return ( | ||||
|       <Button className='text-reset p-0' onClick={handleClick} variant='link'> | ||||
|       <Button className='text-reset p-0 line-height-1' onClick={handleClick} variant='link'> | ||||
|         1sat=1sat | ||||
|       </Button> | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Button className='text-reset p-0' onClick={handleClick} variant='link'> | ||||
|     <Button className='text-reset p-0 line-height-1' onClick={handleClick} variant='link'> | ||||
|       {fiatSymbol + fixedDecimal(price, 0)} | ||||
|     </Button> | ||||
|   ) | ||||
|  | ||||
| @ -1,35 +1,25 @@ | ||||
| import { Nav, Navbar } from 'react-bootstrap' | ||||
| import styles from './header.module.css' | ||||
| import Link from 'next/link' | ||||
| import { Form, Select } from './form' | ||||
| import { useRouter } from 'next/router' | ||||
| 
 | ||||
| export default function RecentHeader ({ type }) { | ||||
|   const router = useRouter() | ||||
| 
 | ||||
| export default function RecentHeader ({ itemType }) { | ||||
|   return ( | ||||
|     <Navbar className='pt-0'> | ||||
|       <Nav | ||||
|         className={`${styles.navbarNav} justify-content-around`} | ||||
|         activeKey={itemType} | ||||
|     <Form | ||||
|       initial={{ | ||||
|         type: router.query.type || type || 'posts' | ||||
|       }} | ||||
|     > | ||||
|         <Nav.Item> | ||||
|           <Link href='/recent' passHref> | ||||
|             <Nav.Link | ||||
|               eventKey='posts' | ||||
|               className={styles.navLink} | ||||
|             > | ||||
|               posts | ||||
|             </Nav.Link> | ||||
|           </Link> | ||||
|         </Nav.Item> | ||||
|         <Nav.Item> | ||||
|           <Link href='/recent/comments' passHref> | ||||
|             <Nav.Link | ||||
|               eventKey='comments' | ||||
|               className={styles.navLink} | ||||
|             > | ||||
|               comments | ||||
|             </Nav.Link> | ||||
|           </Link> | ||||
|         </Nav.Item> | ||||
|       </Nav> | ||||
|     </Navbar> | ||||
|       <div className='text-muted font-weight-bold mt-1 mb-3 d-flex justify-content-end align-items-center'> | ||||
|         <Select | ||||
|           groupClassName='mb-0 ml-2' | ||||
|           className='w-auto' | ||||
|           name='type' | ||||
|           size='sm' | ||||
|           items={['posts', 'comments', 'links', 'discussions', 'polls', 'bios']} | ||||
|           onChange={(formik, e) => router.push(e.target.value === 'posts' ? '/recent' : `/recent/${e.target.value}`)} | ||||
|         /> | ||||
|       </div> | ||||
|     </Form> | ||||
|   ) | ||||
| } | ||||
|  | ||||
							
								
								
									
										46
									
								
								components/share.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								components/share.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | ||||
| import { Dropdown } from 'react-bootstrap' | ||||
| import ShareIcon from '../svgs/share-fill.svg' | ||||
| import copy from 'clipboard-copy' | ||||
| import { useMe } from './me' | ||||
| 
 | ||||
| export default function Share ({ item }) { | ||||
|   const me = useMe() | ||||
|   const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}` | ||||
| 
 | ||||
|   return typeof window !== 'undefined' && navigator?.share | ||||
|     ? ( | ||||
|       <div className='ml-auto pointer d-flex align-items-center'> | ||||
|         <ShareIcon | ||||
|           className='mx-2 fill-grey theme' | ||||
|           onClick={() => { | ||||
|             if (navigator.share) { | ||||
|               navigator.share({ | ||||
|                 title: item.title || '', | ||||
|                 text: '', | ||||
|                 url | ||||
|               }).then(() => console.log('Successful share')) | ||||
|                 .catch((error) => console.log('Error sharing', error)) | ||||
|             } else { | ||||
|               console.log('no navigator.share') | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|       </div>) | ||||
|     : ( | ||||
|       <Dropdown alignRight className='ml-auto pointer  d-flex align-items-center' as='span'> | ||||
|         <Dropdown.Toggle variant='success' id='dropdown-basic' as='a'> | ||||
|           <ShareIcon className='mx-2 fill-grey theme' /> | ||||
|         </Dropdown.Toggle> | ||||
| 
 | ||||
|         <Dropdown.Menu> | ||||
|           <Dropdown.Item | ||||
|             className='text-center' | ||||
|             onClick={async () => { | ||||
|               copy(url) | ||||
|             }} | ||||
|           > | ||||
|             copy link | ||||
|           </Dropdown.Item> | ||||
|         </Dropdown.Menu> | ||||
|       </Dropdown>) | ||||
| } | ||||
| @ -32,8 +32,6 @@ function myRemarkPlugin () { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props }) { | ||||
|   const [copied, setCopied] = useState(false) | ||||
|   const [id] = useState(noFragments ? undefined : slugger.slug(toString(node).replace(/[^\w\-\s]+/gi, ''))) | ||||
| @ -135,7 +133,7 @@ export default function Text ({ topLevel, noFragments, nofollow, children }) { | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function ZoomableImage ({ src, topLevel, ...props }) { | ||||
| export function ZoomableImage ({ src, topLevel, ...props }) { | ||||
|   if (!src) { | ||||
|     return null | ||||
|   } | ||||
|  | ||||
| @ -59,13 +59,18 @@ | ||||
|     margin-bottom: 0 !important; | ||||
| } | ||||
| 
 | ||||
| .text blockquote>*:last-child { | ||||
| .text blockquote:last-child { | ||||
|     margin-bottom: 0 !important; | ||||
| } | ||||
| 
 | ||||
| .text blockquote:has(+ :not(blockquote)) { | ||||
|     margin-bottom: .5rem; | ||||
| } | ||||
| 
 | ||||
| .text img { | ||||
|     display: block; | ||||
|     margin-top: .5rem; | ||||
|     margin-bottom: .5rem; | ||||
|     border-radius: .4rem; | ||||
|     width: auto; | ||||
|     max-width: 100%; | ||||
| @ -81,9 +86,19 @@ | ||||
| } | ||||
| 
 | ||||
| .text blockquote { | ||||
|     border-left: 2px solid var(--theme-grey); | ||||
|     border-left: 4px solid var(--theme-quoteBar); | ||||
|     padding-left: 1rem; | ||||
|     margin: 0 0 0.5rem 0.5rem !important; | ||||
|     margin-left: 1.25rem; | ||||
|     margin-bottom: 0; | ||||
| } | ||||
| 
 | ||||
| .text ul { | ||||
|     margin-bottom: 0; | ||||
| } | ||||
| 
 | ||||
| .text li { | ||||
|     margin-top: .5rem; | ||||
|     margin-bottom: .5rem; | ||||
| } | ||||
| 
 | ||||
| .text h1 { | ||||
|  | ||||
| @ -41,7 +41,7 @@ export default function TopHeader ({ cat }) { | ||||
|             onChange={(formik, e) => top({ ...formik?.values, sort: e.target.value })} | ||||
|             name='sort' | ||||
|             size='sm' | ||||
|             items={cat === 'users' ? ['stacked', 'spent', 'comments', 'posts'] : ['votes', 'comments', 'sats']} | ||||
|             items={cat === 'users' ? ['stacked', 'spent', 'comments', 'posts', 'referrals'] : ['votes', 'comments', 'sats']} | ||||
|           /> | ||||
|           for | ||||
|           <Select | ||||
|  | ||||
| @ -2,15 +2,16 @@ import { LightningConsumer } from './lightning' | ||||
| import UpBolt from '../svgs/bolt.svg' | ||||
| import styles from './upvote.module.css' | ||||
| import { gql, useMutation } from '@apollo/client' | ||||
| import { signIn } from 'next-auth/client' | ||||
| import { useFundError } from './fund-error' | ||||
| import FundError from './fund-error' | ||||
| import ActionTooltip from './action-tooltip' | ||||
| import { useItemAct } from './item-act' | ||||
| import ItemAct from './item-act' | ||||
| import { useMe } from './me' | ||||
| import Rainbow from '../lib/rainbow' | ||||
| import { useRef, useState } from 'react' | ||||
| import LongPressable from 'react-longpressable' | ||||
| import { Overlay, Popover } from 'react-bootstrap' | ||||
| import { useShowModal } from './modal' | ||||
| import { useRouter } from 'next/router' | ||||
| 
 | ||||
| const getColor = (meSats) => { | ||||
|   if (!meSats || meSats <= 10) { | ||||
| @ -63,8 +64,8 @@ const TipPopover = ({ target, show, handleClose }) => ( | ||||
| ) | ||||
| 
 | ||||
| export default function UpVote ({ item, className }) { | ||||
|   const { setError } = useFundError() | ||||
|   const { setItem } = useItemAct() | ||||
|   const showModal = useShowModal() | ||||
|   const router = useRouter() | ||||
|   const [voteShow, _setVoteShow] = useState(false) | ||||
|   const [tipShow, _setTipShow] = useState(false) | ||||
|   const ref = useRef() | ||||
| @ -123,11 +124,14 @@ export default function UpVote ({ item, className }) { | ||||
|               return existingSats + sats | ||||
|             }, | ||||
|             meSats (existingSats = 0) { | ||||
|               if (sats <= me.sats) { | ||||
|                 if (existingSats === 0) { | ||||
|                   setVoteShow(true) | ||||
|                 } else { | ||||
|                   setTipShow(true) | ||||
|                 } | ||||
|               } | ||||
| 
 | ||||
|               return existingSats + sats | ||||
|             }, | ||||
|             upvotes (existingUpvotes = 0) { | ||||
| @ -152,11 +156,19 @@ export default function UpVote ({ item, className }) { | ||||
|     } | ||||
|   ) | ||||
| 
 | ||||
|   const overlayText = () => { | ||||
|     if (me?.tipDefault) { | ||||
|       return `${me.tipDefault} sat${me.tipDefault > 1 ? 's' : ''}` | ||||
|   // what should our next tip be?
 | ||||
|   let sats = me?.tipDefault || 1 | ||||
|   if (me?.turboTipping && item?.meSats) { | ||||
|     let raiseTip = sats | ||||
|     while (item?.meSats >= raiseTip) { | ||||
|       raiseTip *= 10 | ||||
|     } | ||||
|     return '1 sat' | ||||
| 
 | ||||
|     sats = raiseTip - item.meSats | ||||
|   } | ||||
| 
 | ||||
|   const overlayText = () => { | ||||
|     return `${sats} sat${sats > 1 ? 's' : ''}` | ||||
|   } | ||||
| 
 | ||||
|   const color = getColor(item?.meSats) | ||||
| @ -175,7 +187,8 @@ export default function UpVote ({ item, className }) { | ||||
|                 } | ||||
| 
 | ||||
|                 setTipShow(false) | ||||
|                 setItem({ itemId: item.id, act, strike }) | ||||
|                 showModal(onClose => | ||||
|                   <ItemAct onClose={onClose} itemId={item.id} act={act} strike={strike} />) | ||||
|               } | ||||
|             } | ||||
|             onShortPress={ | ||||
| @ -196,24 +209,29 @@ export default function UpVote ({ item, className }) { | ||||
| 
 | ||||
|                   try { | ||||
|                     await act({ | ||||
|                       variables: { id: item.id, sats: me.tipDefault || 1 }, | ||||
|                       variables: { id: item.id, sats }, | ||||
|                       optimisticResponse: { | ||||
|                         act: { | ||||
|                           id: `Item:${item.id}`, | ||||
|                           sats: me.tipDefault || 1, | ||||
|                           sats, | ||||
|                           vote: 0 | ||||
|                         } | ||||
|                       } | ||||
|                     }) | ||||
|                   } catch (error) { | ||||
|                     if (error.toString().includes('insufficient funds')) { | ||||
|                       setError(true) | ||||
|                       showModal(onClose => { | ||||
|                         return <FundError onClose={onClose} /> | ||||
|                       }) | ||||
|                       return | ||||
|                     } | ||||
|                     throw new Error({ message: error.toString() }) | ||||
|                   } | ||||
|                 } | ||||
|               : signIn | ||||
|               : async () => await router.push({ | ||||
|                 pathname: '/signup', | ||||
|                 query: { callbackUrl: window.location.origin + router.asPath } | ||||
|               }) | ||||
|           } | ||||
|           > | ||||
|             <ActionTooltip notForm disable={item?.mine || fwd2me} overlayText={overlayText()}> | ||||
|  | ||||
| @ -1,35 +1,26 @@ | ||||
| import Link from 'next/link' | ||||
| import { useRouter } from 'next/router' | ||||
| import { Nav, Navbar } from 'react-bootstrap' | ||||
| import styles from './header.module.css' | ||||
| import { Form, Select } from './form' | ||||
| 
 | ||||
| export function UsageHeader () { | ||||
|   const router = useRouter() | ||||
| 
 | ||||
|   return ( | ||||
|     <Navbar className='pt-0'> | ||||
|       <Nav | ||||
|         className={`${styles.navbarNav} justify-content-around`} | ||||
|         activeKey={router.asPath} | ||||
|     <Form | ||||
|       initial={{ | ||||
|         when: router.query.when || 'day' | ||||
|       }} | ||||
|     > | ||||
|         <Nav.Item> | ||||
|           <Link href='/users/week' passHref> | ||||
|             <Nav.Link | ||||
|               className={styles.navLink} | ||||
|             > | ||||
|               week | ||||
|             </Nav.Link> | ||||
|           </Link> | ||||
|         </Nav.Item> | ||||
|         <Nav.Item> | ||||
|           <Link href='/users/forever' passHref> | ||||
|             <Nav.Link | ||||
|               className={styles.navLink} | ||||
|             > | ||||
|               forever | ||||
|             </Nav.Link> | ||||
|           </Link> | ||||
|         </Nav.Item> | ||||
|       </Nav> | ||||
|     </Navbar> | ||||
|       <div className='text-muted font-weight-bold my-3 d-flex align-items-center'> | ||||
|         user analytics for | ||||
|         <Select | ||||
|           groupClassName='mb-0 ml-2' | ||||
|           className='w-auto' | ||||
|           name='when' | ||||
|           size='sm' | ||||
|           items={['day', 'week', 'month', 'year', 'forever']} | ||||
|           onChange={(formik, e) => router.push(`/users/${e.target.value}`)} | ||||
|         /> | ||||
|       </div> | ||||
|     </Form> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| @ -38,6 +38,7 @@ export default function UserList ({ users }) { | ||||
|                 {abbrNum(user.ncomments)} comments | ||||
|               </a> | ||||
|             </Link> | ||||
|             {user.referrals > 0 && <span> \ {abbrNum(user.referrals)} referrals</span>} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
							
								
								
									
										161
									
								
								components/when-charts.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								components/when-charts.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,161 @@ | ||||
| import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, AreaChart, Area, ComposedChart, Bar } from 'recharts' | ||||
| import { abbrNum } from '../lib/format' | ||||
| import { useRouter } from 'next/router' | ||||
| 
 | ||||
| const dateFormatter = when => { | ||||
|   return timeStr => { | ||||
|     const date = new Date(timeStr) | ||||
|     switch (when) { | ||||
|       case 'week': | ||||
|       case 'month': | ||||
|         return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${date.getUTCDate()}` | ||||
|       case 'year': | ||||
|       case 'forever': | ||||
|         return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${String(date.getUTCFullYear()).slice(-2)}` | ||||
|       default: | ||||
|         return `${date.getHours() % 12 || 12}${date.getHours() >= 12 ? 'pm' : 'am'}` | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function xAxisName (when) { | ||||
|   switch (when) { | ||||
|     case 'week': | ||||
|     case 'month': | ||||
|       return 'days' | ||||
|     case 'year': | ||||
|     case 'forever': | ||||
|       return 'months' | ||||
|     default: | ||||
|       return 'hours' | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const transformData = data => { | ||||
|   return data.map(entry => { | ||||
|     const obj = { time: entry.time } | ||||
|     entry.data.forEach(entry1 => { | ||||
|       obj[entry1.name] = entry1.value | ||||
|     }) | ||||
|     return obj | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| const COLORS = [ | ||||
|   'var(--secondary)', | ||||
|   'var(--info)', | ||||
|   'var(--success)', | ||||
|   'var(--boost)', | ||||
|   'var(--theme-grey)', | ||||
|   'var(--danger)' | ||||
| ] | ||||
| 
 | ||||
| export function WhenAreaChart ({ data }) { | ||||
|   const router = useRouter() | ||||
|   if (!data || data.length === 0) { | ||||
|     return null | ||||
|   } | ||||
|   // transform data into expected shape
 | ||||
|   data = transformData(data) | ||||
|   // need to grab when
 | ||||
|   const when = router.query.when | ||||
| 
 | ||||
|   return ( | ||||
|     <ResponsiveContainer width='100%' height={300} minWidth={300}> | ||||
|       <AreaChart | ||||
|         data={data} | ||||
|         margin={{ | ||||
|           top: 5, | ||||
|           right: 5, | ||||
|           left: 0, | ||||
|           bottom: 0 | ||||
|         }} | ||||
|       > | ||||
|         <XAxis | ||||
|           dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)} | ||||
|           tick={{ fill: 'var(--theme-grey)' }} | ||||
|         /> | ||||
|         <YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} /> | ||||
|         <Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} /> | ||||
|         <Legend /> | ||||
|         {Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) => | ||||
|           <Area key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)} | ||||
|       </AreaChart> | ||||
|     </ResponsiveContainer> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function WhenLineChart ({ data }) { | ||||
|   const router = useRouter() | ||||
|   if (!data || data.length === 0) { | ||||
|     return null | ||||
|   } | ||||
|   // transform data into expected shape
 | ||||
|   data = transformData(data) | ||||
|   // need to grab when
 | ||||
|   const when = router.query.when | ||||
| 
 | ||||
|   return ( | ||||
|     <ResponsiveContainer width='100%' height={300} minWidth={300}> | ||||
|       <LineChart | ||||
|         data={data} | ||||
|         margin={{ | ||||
|           top: 5, | ||||
|           right: 5, | ||||
|           left: 0, | ||||
|           bottom: 0 | ||||
|         }} | ||||
|       > | ||||
|         <XAxis | ||||
|           dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)} | ||||
|           tick={{ fill: 'var(--theme-grey)' }} | ||||
|         /> | ||||
|         <YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} /> | ||||
|         <Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} /> | ||||
|         <Legend /> | ||||
|         {Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) => | ||||
|           <Line key={v} type='monotone' dataKey={v} name={v} stroke={COLORS[i]} fill={COLORS[i]} />)} | ||||
|       </LineChart> | ||||
|     </ResponsiveContainer> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function WhenComposedChart ({ data, lineNames, areaNames, barNames }) { | ||||
|   const router = useRouter() | ||||
|   if (!data || data.length === 0) { | ||||
|     return null | ||||
|   } | ||||
|   // transform data into expected shape
 | ||||
|   data = transformData(data) | ||||
|   // need to grab when
 | ||||
|   const when = router.query.when | ||||
| 
 | ||||
|   return ( | ||||
|     <ResponsiveContainer width='100%' height={300} minWidth={300}> | ||||
|       <ComposedChart | ||||
|         data={data} | ||||
|         margin={{ | ||||
|           top: 5, | ||||
|           right: 5, | ||||
|           left: 0, | ||||
|           bottom: 0 | ||||
|         }} | ||||
|       > | ||||
|         <XAxis | ||||
|           dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)} | ||||
|           tick={{ fill: 'var(--theme-grey)' }} | ||||
|         /> | ||||
|         <YAxis yAxisId='left' orientation='left' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} /> | ||||
|         <YAxis yAxisId='right' orientation='right' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} /> | ||||
|         <Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} /> | ||||
|         <Legend /> | ||||
|         {barNames?.map((v, i) => | ||||
|           <Bar yAxisId='right' key={v} type='monotone' dataKey={v} name={v} stroke='var(--info)' fill='var(--info)' />)} | ||||
|         {areaNames?.map((v, i) => | ||||
|           <Area yAxisId='left' key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)} | ||||
|         {lineNames?.map((v, i) => | ||||
|           <Line yAxisId='left' key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)} | ||||
|       </ComposedChart> | ||||
|     </ResponsiveContainer> | ||||
|   ) | ||||
| } | ||||
| @ -56,8 +56,8 @@ export const ITEM_FIELDS = gql` | ||||
| export const ITEMS = gql` | ||||
|   ${ITEM_FIELDS} | ||||
| 
 | ||||
|   query items($sub: String, $sort: String, $cursor: String, $name: String, $within: String) { | ||||
|     items(sub: $sub, sort: $sort, cursor: $cursor, name: $name, within: $within) { | ||||
|   query items($sub: String, $sort: String, $type: String, $cursor: String, $name: String, $within: String) { | ||||
|     items(sub: $sub, sort: $sort, type: $type, cursor: $cursor, name: $name, within: $within) { | ||||
|       cursor | ||||
|       items { | ||||
|         ...ItemFields | ||||
|  | ||||
| @ -37,6 +37,9 @@ export const NOTIFICATIONS = gql` | ||||
|             tips | ||||
|           } | ||||
|         } | ||||
|         ... on Referral { | ||||
|           sortTime | ||||
|         } | ||||
|         ... on Reply { | ||||
|           sortTime | ||||
|           item { | ||||
|  | ||||
| @ -3,36 +3,6 @@ import { COMMENT_FIELDS } from './comments' | ||||
| import { ITEM_FIELDS, ITEM_WITH_COMMENTS } from './items' | ||||
| 
 | ||||
| export const ME = gql` | ||||
|   { | ||||
|     me { | ||||
|       id | ||||
|       name | ||||
|       sats | ||||
|       stacked | ||||
|       freePosts | ||||
|       freeComments | ||||
|       hasNewNotes | ||||
|       tipDefault | ||||
|       fiatCurrency | ||||
|       bioId | ||||
|       hasInvites | ||||
|       upvotePopover | ||||
|       tipPopover | ||||
|       noteItemSats | ||||
|       noteEarning | ||||
|       noteAllDescendants | ||||
|       noteMentions | ||||
|       noteDeposits | ||||
|       noteInvites | ||||
|       noteJobIndicator | ||||
|       hideInvoiceDesc | ||||
|       wildWestMode | ||||
|       greeterMode | ||||
|       lastCheckedJobs | ||||
|     } | ||||
|   }` | ||||
| 
 | ||||
| export const ME_SSR = gql` | ||||
|   { | ||||
|     me { | ||||
|       id | ||||
| @ -42,6 +12,7 @@ export const ME_SSR = gql` | ||||
|       freePosts | ||||
|       freeComments | ||||
|       tipDefault | ||||
|       turboTipping | ||||
|       fiatCurrency | ||||
|       bioId | ||||
|       upvotePopover | ||||
| @ -54,6 +25,7 @@ export const ME_SSR = gql` | ||||
|       noteInvites | ||||
|       noteJobIndicator | ||||
|       hideInvoiceDesc | ||||
|       hideFromTopUsers | ||||
|       wildWestMode | ||||
|       greeterMode | ||||
|       lastCheckedJobs | ||||
| @ -63,6 +35,7 @@ export const ME_SSR = gql` | ||||
| export const SETTINGS_FIELDS = gql` | ||||
|   fragment SettingsFields on User { | ||||
|     tipDefault | ||||
|     turboTipping | ||||
|     fiatCurrency | ||||
|     noteItemSats | ||||
|     noteEarning | ||||
| @ -72,6 +45,9 @@ export const SETTINGS_FIELDS = gql` | ||||
|     noteInvites | ||||
|     noteJobIndicator | ||||
|     hideInvoiceDesc | ||||
|     hideFromTopUsers | ||||
|     nostrPubkey | ||||
|     nostrRelays | ||||
|     wildWestMode | ||||
|     greeterMode | ||||
|     authMethods { | ||||
| @ -93,15 +69,15 @@ ${SETTINGS_FIELDS} | ||||
| export const SET_SETTINGS = | ||||
| gql` | ||||
| ${SETTINGS_FIELDS} | ||||
| mutation setSettings($tipDefault: Int!, $fiatCurrency: String!, $noteItemSats: Boolean!, $noteEarning: Boolean!, | ||||
|   $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!, | ||||
|   $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!, | ||||
|   $wildWestMode: Boolean!, $greeterMode: Boolean!) { | ||||
|   setSettings(tipDefault: $tipDefault, fiatCurrency: $fiatCurrency, noteItemSats: $noteItemSats, | ||||
|     noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants, | ||||
| mutation setSettings($tipDefault: Int!, $turboTipping: Boolean!, $fiatCurrency: String!, $noteItemSats: Boolean!, | ||||
|   $noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!, | ||||
|   $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!, $hideFromTopUsers: Boolean!, | ||||
|   $wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrRelays: [String!]) { | ||||
|   setSettings(tipDefault: $tipDefault, turboTipping: $turboTipping,  fiatCurrency: $fiatCurrency, | ||||
|     noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants, | ||||
|     noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites, | ||||
|     noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc, wildWestMode: $wildWestMode, | ||||
|     greeterMode: $greeterMode) { | ||||
|     noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc, hideFromTopUsers: $hideFromTopUsers, | ||||
|     wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrRelays: $nostrRelays) { | ||||
|       ...SettingsFields | ||||
|     } | ||||
|   } | ||||
| @ -161,6 +137,7 @@ export const TOP_USERS = gql` | ||||
|         spent(when: $when) | ||||
|         ncomments(when: $when) | ||||
|         nitems(when: $when) | ||||
|         referrals(when: $when) | ||||
|       } | ||||
|       cursor | ||||
|     } | ||||
|  | ||||
| @ -7,7 +7,7 @@ export const INVOICE = gql` | ||||
|     invoice(id: $id) { | ||||
|       id | ||||
|       bolt11 | ||||
|       msatsReceived | ||||
|       satsReceived | ||||
|       cancelled | ||||
|       confirmedAt | ||||
|       expiresAt | ||||
| @ -40,8 +40,8 @@ export const WALLET_HISTORY = gql` | ||||
|         factId | ||||
|         type | ||||
|         createdAt | ||||
|         msats | ||||
|         msatsFee | ||||
|         sats | ||||
|         satsFee | ||||
|         status | ||||
|         type | ||||
|         description | ||||
|  | ||||
							
								
								
									
										461
									
								
								lexical/nodes/image.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										461
									
								
								lexical/nodes/image.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,461 @@ | ||||
| import { | ||||
|   $applyNodeReplacement, | ||||
|   $getNodeByKey, | ||||
|   $getSelection, | ||||
|   $isNodeSelection, | ||||
|   $setSelection, | ||||
|   CLICK_COMMAND, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, createEditor, DecoratorNode, | ||||
|   DRAGSTART_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_ENTER_COMMAND, | ||||
|   KEY_ESCAPE_COMMAND, SELECTION_CHANGE_COMMAND | ||||
| } from 'lexical' | ||||
| import { useRef, Suspense, useEffect, useCallback } from 'react' | ||||
| import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection' | ||||
| import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' | ||||
| import { mergeRegister } from '@lexical/utils' | ||||
| 
 | ||||
| const imageCache = new Set() | ||||
| 
 | ||||
| function useSuspenseImage (src) { | ||||
|   if (!imageCache.has(src)) { | ||||
|     throw new Promise((resolve) => { | ||||
|       const img = new Image() | ||||
|       img.src = src | ||||
|       img.onload = () => { | ||||
|         imageCache.add(src) | ||||
|         resolve(null) | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function LazyImage ({ | ||||
|   altText, | ||||
|   className, | ||||
|   imageRef, | ||||
|   src, | ||||
|   width, | ||||
|   height, | ||||
|   maxWidth | ||||
| }) { | ||||
|   useSuspenseImage(src) | ||||
|   return ( | ||||
|     <img | ||||
|       className={className || undefined} | ||||
|       src={src} | ||||
|       alt={altText} | ||||
|       ref={imageRef} | ||||
|       style={{ | ||||
|         height, | ||||
|         maxHeight: '25vh', | ||||
|         // maxWidth,
 | ||||
|         // width,
 | ||||
|         display: 'block', | ||||
|         marginBottom: '.5rem', | ||||
|         marginTop: '.5rem', | ||||
|         borderRadius: '.4rem', | ||||
|         width: 'auto', | ||||
|         maxWidth: '100%' | ||||
|       }} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function convertImageElement (domNode) { | ||||
|   if (domNode instanceof HTMLImageElement) { | ||||
|     const { alt: altText, src } = domNode | ||||
|     const node = $createImageNode({ altText, src }) | ||||
|     return { node } | ||||
|   } | ||||
|   return null | ||||
| } | ||||
| 
 | ||||
| export class ImageNode extends DecoratorNode { | ||||
|   __src; | ||||
|   __altText; | ||||
|   __width; | ||||
|   __height; | ||||
|   __maxWidth; | ||||
|   __showCaption; | ||||
|   __caption; | ||||
|   // Captions cannot yet be used within editor cells
 | ||||
|   __captionsEnabled; | ||||
| 
 | ||||
|   static getType () { | ||||
|     return 'image' | ||||
|   } | ||||
| 
 | ||||
|   static clone (node) { | ||||
|     return new ImageNode( | ||||
|       node.__src, | ||||
|       node.__altText, | ||||
|       node.__maxWidth, | ||||
|       node.__width, | ||||
|       node.__height, | ||||
|       node.__showCaption, | ||||
|       node.__caption, | ||||
|       node.__captionsEnabled, | ||||
|       node.__key | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   static importJSON (serializedNode) { | ||||
|     const { altText, height, width, maxWidth, caption, src, showCaption } = | ||||
|       serializedNode | ||||
|     const node = $createImageNode({ | ||||
|       altText, | ||||
|       height, | ||||
|       maxWidth, | ||||
|       showCaption, | ||||
|       src, | ||||
|       width | ||||
|     }) | ||||
|     const nestedEditor = node.__caption | ||||
|     const editorState = nestedEditor.parseEditorState(caption.editorState) | ||||
|     if (!editorState.isEmpty()) { | ||||
|       nestedEditor.setEditorState(editorState) | ||||
|     } | ||||
|     return node | ||||
|   } | ||||
| 
 | ||||
|   exportDOM () { | ||||
|     const element = document.createElement('img') | ||||
|     element.setAttribute('src', this.__src) | ||||
|     element.setAttribute('alt', this.__altText) | ||||
|     return { element } | ||||
|   } | ||||
| 
 | ||||
|   static importDOM () { | ||||
|     return { | ||||
|       img: (node) => ({ | ||||
|         conversion: convertImageElement, | ||||
|         priority: 0 | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   constructor ( | ||||
|     src, | ||||
|     altText, | ||||
|     maxWidth, | ||||
|     width, | ||||
|     height, | ||||
|     showCaption, | ||||
|     caption, | ||||
|     captionsEnabled, | ||||
|     key | ||||
|   ) { | ||||
|     super(key) | ||||
|     this.__src = src | ||||
|     this.__altText = altText | ||||
|     this.__maxWidth = maxWidth | ||||
|     this.__width = width || 'inherit' | ||||
|     this.__height = height || 'inherit' | ||||
|     this.__showCaption = showCaption || false | ||||
|     this.__caption = caption || createEditor() | ||||
|     this.__captionsEnabled = captionsEnabled || captionsEnabled === undefined | ||||
|   } | ||||
| 
 | ||||
|   exportJSON () { | ||||
|     return { | ||||
|       altText: this.getAltText(), | ||||
|       caption: this.__caption.toJSON(), | ||||
|       height: this.__height === 'inherit' ? 0 : this.__height, | ||||
|       maxWidth: this.__maxWidth, | ||||
|       showCaption: this.__showCaption, | ||||
|       src: this.getSrc(), | ||||
|       type: 'image', | ||||
|       version: 1, | ||||
|       width: this.__width === 'inherit' ? 0 : this.__width | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   setWidthAndHeight ( | ||||
|     width, | ||||
|     height | ||||
|   ) { | ||||
|     const writable = this.getWritable() | ||||
|     writable.__width = width | ||||
|     writable.__height = height | ||||
|   } | ||||
| 
 | ||||
|   setShowCaption (showCaption) { | ||||
|     const writable = this.getWritable() | ||||
|     writable.__showCaption = showCaption | ||||
|   } | ||||
| 
 | ||||
|   // View
 | ||||
| 
 | ||||
|   createDOM (config) { | ||||
|     const span = document.createElement('span') | ||||
|     const theme = config.theme | ||||
|     const className = theme.image | ||||
|     if (className !== undefined) { | ||||
|       span.className = className | ||||
|     } | ||||
|     return span | ||||
|   } | ||||
| 
 | ||||
|   updateDOM () { | ||||
|     return false | ||||
|   } | ||||
| 
 | ||||
|   getSrc () { | ||||
|     return this.__src | ||||
|   } | ||||
| 
 | ||||
|   getAltText () { | ||||
|     return this.__altText | ||||
|   } | ||||
| 
 | ||||
|   decorate () { | ||||
|     return ( | ||||
|       <Suspense fallback={null}> | ||||
|         <ImageComponent | ||||
|           src={this.__src} | ||||
|           altText={this.__altText} | ||||
|           width={this.__width} | ||||
|           height={this.__height} | ||||
|           maxWidth={this.__maxWidth} | ||||
|           nodeKey={this.getKey()} | ||||
|           showCaption={this.__showCaption} | ||||
|           caption={this.__caption} | ||||
|           captionsEnabled={this.__captionsEnabled} | ||||
|           resizable | ||||
|         /> | ||||
|       </Suspense> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function $createImageNode ({ | ||||
|   altText, | ||||
|   height, | ||||
|   maxWidth = 500, | ||||
|   captionsEnabled, | ||||
|   src, | ||||
|   width, | ||||
|   showCaption, | ||||
|   caption, | ||||
|   key | ||||
| }) { | ||||
|   return $applyNodeReplacement( | ||||
|     new ImageNode( | ||||
|       src, | ||||
|       altText, | ||||
|       maxWidth, | ||||
|       width, | ||||
|       height, | ||||
|       showCaption, | ||||
|       caption, | ||||
|       captionsEnabled, | ||||
|       key | ||||
|     ) | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function $isImageNode ( | ||||
|   node | ||||
| ) { | ||||
|   return node instanceof ImageNode | ||||
| } | ||||
| 
 | ||||
| export default function ImageComponent ({ | ||||
|   src, | ||||
|   altText, | ||||
|   nodeKey, | ||||
|   width, | ||||
|   height, | ||||
|   maxWidth, | ||||
|   resizable, | ||||
|   showCaption, | ||||
|   caption, | ||||
|   captionsEnabled | ||||
| }) { | ||||
|   const imageRef = useRef(null) | ||||
|   const buttonRef = useRef(null) | ||||
|   const [isSelected, setSelected, clearSelection] = | ||||
|     useLexicalNodeSelection(nodeKey) | ||||
|   const [editor] = useLexicalComposerContext() | ||||
|   // const [selection, setSelection] = useState(null)
 | ||||
|   const activeEditorRef = useRef(null) | ||||
| 
 | ||||
|   const onDelete = useCallback( | ||||
|     (payload) => { | ||||
|       if (isSelected && $isNodeSelection($getSelection())) { | ||||
|         const event = payload | ||||
|         event.preventDefault() | ||||
|         const node = $getNodeByKey(nodeKey) | ||||
|         if ($isImageNode(node)) { | ||||
|           node.remove() | ||||
|         } | ||||
|         setSelected(false) | ||||
|       } | ||||
|       return false | ||||
|     }, | ||||
|     [isSelected, nodeKey, setSelected] | ||||
|   ) | ||||
| 
 | ||||
|   const onEnter = useCallback( | ||||
|     (event) => { | ||||
|       const latestSelection = $getSelection() | ||||
|       const buttonElem = buttonRef.current | ||||
|       if ( | ||||
|         isSelected && | ||||
|         $isNodeSelection(latestSelection) && | ||||
|         latestSelection.getNodes().length === 1 | ||||
|       ) { | ||||
|         if (showCaption) { | ||||
|           // Move focus into nested editor
 | ||||
|           $setSelection(null) | ||||
|           event.preventDefault() | ||||
|           caption.focus() | ||||
|           return true | ||||
|         } else if ( | ||||
|           buttonElem !== null && | ||||
|           buttonElem !== document.activeElement | ||||
|         ) { | ||||
|           event.preventDefault() | ||||
|           buttonElem.focus() | ||||
|           return true | ||||
|         } | ||||
|       } | ||||
|       return false | ||||
|     }, | ||||
|     [caption, isSelected, showCaption] | ||||
|   ) | ||||
| 
 | ||||
|   const onEscape = useCallback( | ||||
|     (event) => { | ||||
|       if ( | ||||
|         activeEditorRef.current === caption || | ||||
|         buttonRef.current === event.target | ||||
|       ) { | ||||
|         $setSelection(null) | ||||
|         editor.update(() => { | ||||
|           setSelected(true) | ||||
|           const parentRootElement = editor.getRootElement() | ||||
|           if (parentRootElement !== null) { | ||||
|             parentRootElement.focus() | ||||
|           } | ||||
|         }) | ||||
|         return true | ||||
|       } | ||||
|       return false | ||||
|     }, | ||||
|     [caption, editor, setSelected] | ||||
|   ) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     return mergeRegister( | ||||
|       // editor.registerUpdateListener(({ editorState }) => {
 | ||||
|       //   setSelection(editorState.read(() => $getSelection()))
 | ||||
|       // }),
 | ||||
|       editor.registerCommand( | ||||
|         SELECTION_CHANGE_COMMAND, | ||||
|         (_, activeEditor) => { | ||||
|           activeEditorRef.current = activeEditor | ||||
|           return false | ||||
|         }, | ||||
|         COMMAND_PRIORITY_LOW | ||||
|       ), | ||||
|       editor.registerCommand( | ||||
|         CLICK_COMMAND, | ||||
|         (payload) => { | ||||
|           const event = payload | ||||
|           if (event.target === imageRef.current) { | ||||
|             if (event.shiftKey) { | ||||
|               setSelected(!isSelected) | ||||
|             } else { | ||||
|               clearSelection() | ||||
|               setSelected(true) | ||||
|             } | ||||
|             return true | ||||
|           } | ||||
| 
 | ||||
|           return false | ||||
|         }, | ||||
|         COMMAND_PRIORITY_LOW | ||||
|       ), | ||||
|       editor.registerCommand( | ||||
|         DRAGSTART_COMMAND, | ||||
|         (payload) => { | ||||
|           const event = payload | ||||
|           if (event.target === imageRef.current) { | ||||
|             if (event.shiftKey) { | ||||
|               setSelected(!isSelected) | ||||
|             } else { | ||||
|               clearSelection() | ||||
|               setSelected(true) | ||||
|             } | ||||
|             return true | ||||
|           } | ||||
| 
 | ||||
|           return false | ||||
|         }, | ||||
|         COMMAND_PRIORITY_HIGH | ||||
|       ), | ||||
|       editor.registerCommand( | ||||
|         DRAGSTART_COMMAND, | ||||
|         (event) => { | ||||
|           if (event.target === imageRef.current) { | ||||
|             // TODO This is just a temporary workaround for FF to behave like other browsers.
 | ||||
|             // Ideally, this handles drag & drop too (and all browsers).
 | ||||
|             event.preventDefault() | ||||
|             return true | ||||
|           } | ||||
|           return false | ||||
|         }, | ||||
|         COMMAND_PRIORITY_LOW | ||||
|       ), | ||||
|       editor.registerCommand( | ||||
|         KEY_DELETE_COMMAND, | ||||
|         onDelete, | ||||
|         COMMAND_PRIORITY_LOW | ||||
|       ), | ||||
|       editor.registerCommand( | ||||
|         KEY_BACKSPACE_COMMAND, | ||||
|         onDelete, | ||||
|         COMMAND_PRIORITY_LOW | ||||
|       ), | ||||
|       editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_LOW), | ||||
|       editor.registerCommand( | ||||
|         KEY_ESCAPE_COMMAND, | ||||
|         onEscape, | ||||
|         COMMAND_PRIORITY_LOW | ||||
|       ) | ||||
|     ) | ||||
|   }, [ | ||||
|     clearSelection, | ||||
|     editor, | ||||
|     isSelected, | ||||
|     nodeKey, | ||||
|     onDelete, | ||||
|     onEnter, | ||||
|     onEscape, | ||||
|     setSelected | ||||
|   ]) | ||||
| 
 | ||||
|   // const draggable = isSelected && $isNodeSelection(selection)
 | ||||
|   // const isFocused = isSelected
 | ||||
|   return ( | ||||
|     <Suspense fallback={null}> | ||||
|       <> | ||||
|         <div draggable> | ||||
|           <LazyImage | ||||
|             // className={
 | ||||
|             //   isFocused
 | ||||
|             //     ? `focused ${$isNodeSelection(selection) ? 'draggable' : ''}`
 | ||||
|             //     : null
 | ||||
|             // }
 | ||||
|             src={src} | ||||
|             altText={altText} | ||||
|             imageRef={imageRef} | ||||
|             width={width} | ||||
|             height={height} | ||||
|             maxWidth={maxWidth} | ||||
|           /> | ||||
|         </div> | ||||
|       </> | ||||
|     </Suspense> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										34
									
								
								lexical/plugins/autolink.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								lexical/plugins/autolink.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,34 @@ | ||||
| import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin' | ||||
| 
 | ||||
| const URL_MATCHER = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/ | ||||
| 
 | ||||
| const EMAIL_MATCHER = /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/ | ||||
| 
 | ||||
| const MATCHERS = [ | ||||
|   (text) => { | ||||
|     const match = URL_MATCHER.exec(text) | ||||
|     return ( | ||||
|       match && { | ||||
|         index: match.index, | ||||
|         length: match[0].length, | ||||
|         text: match[0], | ||||
|         url: match[0] | ||||
|       } | ||||
|     ) | ||||
|   }, | ||||
|   (text) => { | ||||
|     const match = EMAIL_MATCHER.exec(text) | ||||
|     return ( | ||||
|       match && { | ||||
|         index: match.index, | ||||
|         length: match[0].length, | ||||
|         text: match[0], | ||||
|         url: `mailto:${match[0]}` | ||||
|       } | ||||
|     ) | ||||
|   } | ||||
| ] | ||||
| 
 | ||||
| export default function PlaygroundAutoLinkPlugin () { | ||||
|   return <AutoLinkPlugin matchers={MATCHERS} /> | ||||
| } | ||||
							
								
								
									
										252
									
								
								lexical/plugins/image-insert.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										252
									
								
								lexical/plugins/image-insert.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,252 @@ | ||||
| import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' | ||||
| import { $wrapNodeInElement, mergeRegister } from '@lexical/utils' | ||||
| import { | ||||
|   $createParagraphNode, | ||||
|   $createRangeSelection, | ||||
|   $getSelection, | ||||
|   $insertNodes, | ||||
|   $isRootOrShadowRoot, | ||||
|   $setSelection, | ||||
|   COMMAND_PRIORITY_EDITOR, | ||||
|   COMMAND_PRIORITY_HIGH, | ||||
|   COMMAND_PRIORITY_LOW, | ||||
|   createCommand, | ||||
|   DRAGOVER_COMMAND, | ||||
|   DRAGSTART_COMMAND, | ||||
|   DROP_COMMAND | ||||
| } from 'lexical' | ||||
| import { useEffect, useRef } from 'react' | ||||
| import * as Yup from 'yup' | ||||
| import { ensureProtocol, URL_REGEXP } from '../../lib/url' | ||||
| 
 | ||||
| import { | ||||
|   $createImageNode, | ||||
|   $isImageNode, | ||||
|   ImageNode | ||||
| } from '../nodes/image' | ||||
| import { Form, Input, SubmitButton } from '../../components/form' | ||||
| import styles from '../styles.module.css' | ||||
| 
 | ||||
| const getDOMSelection = (targetWindow) => | ||||
|   typeof window !== 'undefined' ? (targetWindow || window).getSelection() : null | ||||
| 
 | ||||
| export const INSERT_IMAGE_COMMAND = createCommand('INSERT_IMAGE_COMMAND') | ||||
| 
 | ||||
| const LinkSchema = Yup.object({ | ||||
|   url: Yup.string().matches(URL_REGEXP, 'invalid url').required('required') | ||||
| }) | ||||
| 
 | ||||
| export function ImageInsertModal ({ onClose, editor }) { | ||||
|   const inputRef = useRef(null) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     inputRef.current?.focus() | ||||
|   }, []) | ||||
| 
 | ||||
|   return ( | ||||
|     <Form | ||||
|       initial={{ | ||||
|         url: '', | ||||
|         alt: '' | ||||
|       }} | ||||
|       schema={LinkSchema} | ||||
|       onSubmit={async ({ alt, url }) => { | ||||
|         editor.dispatchCommand(INSERT_IMAGE_COMMAND, { src: ensureProtocol(url), altText: alt }) | ||||
|         onClose() | ||||
|       }} | ||||
|     > | ||||
|       <Input | ||||
|         label='url' | ||||
|         name='url' | ||||
|         innerRef={inputRef} | ||||
|         required | ||||
|         autoFocus | ||||
|       /> | ||||
|       <Input | ||||
|         label={<>alt text <small className='text-muted ml-2'>optional</small></>} | ||||
|         name='alt' | ||||
|       /> | ||||
|       <div className='d-flex'> | ||||
|         <SubmitButton variant='success' className='ml-auto mt-1 px-4'>ok</SubmitButton> | ||||
|       </div> | ||||
|     </Form> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export default function ImageInsertPlugin ({ | ||||
|   captionsEnabled | ||||
| }) { | ||||
|   const [editor] = useLexicalComposerContext() | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (!editor.hasNodes([ImageNode])) { | ||||
|       throw new Error('ImagesPlugin: ImageNode not registered on editor') | ||||
|     } | ||||
| 
 | ||||
|     return mergeRegister( | ||||
|       editor.registerCommand( | ||||
|         INSERT_IMAGE_COMMAND, | ||||
|         (payload) => { | ||||
|           const imageNode = $createImageNode(payload) | ||||
|           $insertNodes([imageNode]) | ||||
|           if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) { | ||||
|             $wrapNodeInElement(imageNode, $createParagraphNode).selectEnd() | ||||
|           } | ||||
| 
 | ||||
|           return true | ||||
|         }, | ||||
|         COMMAND_PRIORITY_EDITOR | ||||
|       ), | ||||
|       editor.registerCommand( | ||||
|         DRAGSTART_COMMAND, | ||||
|         (event) => { | ||||
|           return onDragStart(event) | ||||
|         }, | ||||
|         COMMAND_PRIORITY_HIGH | ||||
|       ), | ||||
|       editor.registerCommand( | ||||
|         DRAGOVER_COMMAND, | ||||
|         (event) => { | ||||
|           return onDragover(event) | ||||
|         }, | ||||
|         COMMAND_PRIORITY_LOW | ||||
|       ), | ||||
|       editor.registerCommand( | ||||
|         DROP_COMMAND, | ||||
|         (event) => { | ||||
|           return onDrop(event, editor) | ||||
|         }, | ||||
|         COMMAND_PRIORITY_HIGH | ||||
|       ) | ||||
|     ) | ||||
|   }, [captionsEnabled, editor]) | ||||
| 
 | ||||
|   return null | ||||
| } | ||||
| 
 | ||||
| const TRANSPARENT_IMAGE = | ||||
|    'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' | ||||
| const img = typeof window !== 'undefined' ? document.createElement('img') : undefined | ||||
| if (img) { | ||||
|   img.src = TRANSPARENT_IMAGE | ||||
| } | ||||
| 
 | ||||
| function onDragStart (event) { | ||||
|   const node = getImageNodeInSelection() | ||||
|   if (!node) { | ||||
|     return false | ||||
|   } | ||||
|   const dataTransfer = event.dataTransfer | ||||
|   if (!dataTransfer) { | ||||
|     return false | ||||
|   } | ||||
|   dataTransfer.setData('text/plain', '_') | ||||
|   img.src = node.getSrc() | ||||
|   dataTransfer.setDragImage(img, 0, 0) | ||||
|   dataTransfer.setData( | ||||
|     'application/x-lexical-drag', | ||||
|     JSON.stringify({ | ||||
|       data: { | ||||
|         altText: node.__altText, | ||||
|         caption: node.__caption, | ||||
|         height: node.__height, | ||||
|         maxHeight: '25vh', | ||||
|         key: node.getKey(), | ||||
|         maxWidth: node.__maxWidth, | ||||
|         showCaption: node.__showCaption, | ||||
|         src: node.__src, | ||||
|         width: node.__width | ||||
|       }, | ||||
|       type: 'image' | ||||
|     }) | ||||
|   ) | ||||
| 
 | ||||
|   return true | ||||
| } | ||||
| 
 | ||||
| function onDragover (event) { | ||||
|   const node = getImageNodeInSelection() | ||||
|   if (!node) { | ||||
|     return false | ||||
|   } | ||||
|   if (!canDropImage(event)) { | ||||
|     event.preventDefault() | ||||
|   } | ||||
|   return true | ||||
| } | ||||
| 
 | ||||
| function onDrop (event, editor) { | ||||
|   const node = getImageNodeInSelection() | ||||
|   if (!node) { | ||||
|     return false | ||||
|   } | ||||
|   const data = getDragImageData(event) | ||||
|   if (!data) { | ||||
|     return false | ||||
|   } | ||||
|   event.preventDefault() | ||||
|   if (canDropImage(event)) { | ||||
|     const range = getDragSelection(event) | ||||
|     node.remove() | ||||
|     const rangeSelection = $createRangeSelection() | ||||
|     if (range !== null && range !== undefined) { | ||||
|       rangeSelection.applyDOMRange(range) | ||||
|     } | ||||
|     $setSelection(rangeSelection) | ||||
|     editor.dispatchCommand(INSERT_IMAGE_COMMAND, data) | ||||
|   } | ||||
|   return true | ||||
| } | ||||
| 
 | ||||
| function getImageNodeInSelection () { | ||||
|   const selection = $getSelection() | ||||
|   const nodes = selection.getNodes() | ||||
|   const node = nodes[0] | ||||
|   return $isImageNode(node) ? node : null | ||||
| } | ||||
| 
 | ||||
| function getDragImageData (event) { | ||||
|   const dragData = event.dataTransfer?.getData('application/x-lexical-drag') | ||||
|   if (!dragData) { | ||||
|     return null | ||||
|   } | ||||
|   const { type, data } = JSON.parse(dragData) | ||||
|   if (type !== 'image') { | ||||
|     return null | ||||
|   } | ||||
| 
 | ||||
|   return data | ||||
| } | ||||
| 
 | ||||
| function canDropImage (event) { | ||||
|   const target = event.target | ||||
|   return !!( | ||||
|     target && | ||||
|      target instanceof HTMLElement && | ||||
|      !target.closest('code, span.editor-image') && | ||||
|      target.parentElement && | ||||
|      target.parentElement.closest(`div.${styles.editorInput}`) | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function getDragSelection (event) { | ||||
|   let range | ||||
|   const target = event.target | ||||
|   const targetWindow = | ||||
|      target == null | ||||
|        ? null | ||||
|        : target.nodeType === 9 | ||||
|          ? target.defaultView | ||||
|          : target.ownerDocument.defaultView | ||||
|   const domSelection = getDOMSelection(targetWindow) | ||||
|   if (document.caretRangeFromPoint) { | ||||
|     range = document.caretRangeFromPoint(event.clientX, event.clientY) | ||||
|   } else if (event.rangeParent && domSelection !== null) { | ||||
|     domSelection.collapse(event.rangeParent, event.rangeOffset || 0) | ||||
|     range = domSelection.getRangeAt(0) | ||||
|   } else { | ||||
|     throw Error('Cannot get the selection when dragging') | ||||
|   } | ||||
| 
 | ||||
|   return range | ||||
| } | ||||
							
								
								
									
										134
									
								
								lexical/plugins/link-insert.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								lexical/plugins/link-insert.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,134 @@ | ||||
| import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' | ||||
| import { $createTextNode, $getSelection, $insertNodes, $setSelection, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical' | ||||
| import { $wrapNodeInElement, mergeRegister } from '@lexical/utils' | ||||
| import { $createLinkNode, $isLinkNode } from '@lexical/link' | ||||
| import { Modal } from 'react-bootstrap' | ||||
| import React, { useState, useCallback, useContext, useRef, useEffect } from 'react' | ||||
| import * as Yup from 'yup' | ||||
| import { Form, Input, SubmitButton } from '../../components/form' | ||||
| import { ensureProtocol, URL_REGEXP } from '../../lib/url' | ||||
| import { getSelectedNode } from '../utils/selected-node' | ||||
| 
 | ||||
| export const INSERT_LINK_COMMAND = createCommand('INSERT_LINK_COMMAND') | ||||
| 
 | ||||
| export default function LinkInsertPlugin () { | ||||
|   const [editor] = useLexicalComposerContext() | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     return mergeRegister( | ||||
|       editor.registerCommand( | ||||
|         INSERT_LINK_COMMAND, | ||||
|         (payload) => { | ||||
|           const selection = $getSelection() | ||||
|           const node = getSelectedNode(selection) | ||||
|           const parent = node.getParent() | ||||
|           if ($isLinkNode(parent)) { | ||||
|             parent.remove() | ||||
|           } else if ($isLinkNode(node)) { | ||||
|             node.remove() | ||||
|           } | ||||
|           const textNode = $createTextNode(payload.text) | ||||
|           $insertNodes([textNode]) | ||||
|           const linkNode = $createLinkNode(payload.url) | ||||
|           $wrapNodeInElement(textNode, () => linkNode) | ||||
|           $setSelection(textNode.select()) | ||||
|           return true | ||||
|         }, | ||||
|         COMMAND_PRIORITY_EDITOR | ||||
|       ) | ||||
|     ) | ||||
|   }, [editor]) | ||||
| 
 | ||||
|   return null | ||||
| } | ||||
| 
 | ||||
| export const LinkInsertContext = React.createContext({ | ||||
|   link: null, | ||||
|   setLink: () => {} | ||||
| }) | ||||
| 
 | ||||
| export function LinkInsertProvider ({ children }) { | ||||
|   const [link, setLink] = useState(null) | ||||
| 
 | ||||
|   const contextValue = { | ||||
|     link, | ||||
|     setLink: useCallback(link => setLink(link), []) | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <LinkInsertContext.Provider value={contextValue}> | ||||
|       <LinkInsertModal /> | ||||
|       {children} | ||||
|     </LinkInsertContext.Provider> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function useLinkInsert () { | ||||
|   const { link, setLink } = useContext(LinkInsertContext) | ||||
|   return { link, setLink } | ||||
| } | ||||
| 
 | ||||
| const LinkSchema = Yup.object({ | ||||
|   text: Yup.string().required('required'), | ||||
|   url: Yup.string().matches(URL_REGEXP, 'invalid url').required('required') | ||||
| }) | ||||
| 
 | ||||
| export function LinkInsertModal () { | ||||
|   const [editor] = useLexicalComposerContext() | ||||
|   const { link, setLink } = useLinkInsert() | ||||
|   const inputRef = useRef(null) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (link) { | ||||
|       inputRef.current?.focus() | ||||
|     } | ||||
|   }, [link]) | ||||
| 
 | ||||
|   return ( | ||||
|     <Modal | ||||
|       show={!!link} | ||||
|       onHide={() => { | ||||
|         setLink(null) | ||||
|         setTimeout(() => editor.focus(), 100) | ||||
|       }} | ||||
|     > | ||||
|       <div | ||||
|         className='modal-close' onClick={() => { | ||||
|           setLink(null) | ||||
|           // I think bootstrap messes with the focus on close so we have to do this ourselves
 | ||||
|           setTimeout(() => editor.focus(), 100) | ||||
|         }} | ||||
|       >X | ||||
|       </div> | ||||
|       <Modal.Body> | ||||
|         <Form | ||||
|           initial={{ | ||||
|             text: link?.text, | ||||
|             url: link?.url | ||||
|           }} | ||||
|           schema={LinkSchema} | ||||
|           onSubmit={async ({ text, url }) => { | ||||
|             editor.dispatchCommand(INSERT_LINK_COMMAND, { url: ensureProtocol(url), text }) | ||||
|             await setLink(null) | ||||
|             setTimeout(() => editor.focus(), 100) | ||||
|           }} | ||||
|         > | ||||
|           <Input | ||||
|             label='text' | ||||
|             name='text' | ||||
|             innerRef={inputRef} | ||||
|             required | ||||
|           /> | ||||
|           <Input | ||||
|             label='url' | ||||
|             name='url' | ||||
|             required | ||||
|           /> | ||||
|           <div className='d-flex'> | ||||
|             <SubmitButton variant='success' className='ml-auto mt-1 px-4'>ok</SubmitButton> | ||||
|           </div> | ||||
|         </Form> | ||||
|       </Modal.Body> | ||||
|     </Modal> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										232
									
								
								lexical/plugins/link-tooltip.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										232
									
								
								lexical/plugins/link-tooltip.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,232 @@ | ||||
| import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link' | ||||
| import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' | ||||
| import { $findMatchingParent, mergeRegister } from '@lexical/utils' | ||||
| import styles from '../styles.module.css' | ||||
| import { | ||||
|   $getSelection, | ||||
|   $isRangeSelection, | ||||
|   COMMAND_PRIORITY_CRITICAL, | ||||
|   COMMAND_PRIORITY_HIGH, | ||||
|   COMMAND_PRIORITY_LOW, | ||||
|   KEY_ESCAPE_COMMAND, | ||||
|   SELECTION_CHANGE_COMMAND | ||||
| } from 'lexical' | ||||
| import { useCallback, useEffect, useRef, useState } from 'react' | ||||
| import * as React from 'react' | ||||
| 
 | ||||
| import { getSelectedNode } from '../utils/selected-node' | ||||
| import { setTooltipPosition } from '../utils/tooltip-position' | ||||
| import { useLinkInsert } from './link-insert' | ||||
| import { getLinkFromSelection } from '../utils/link-from-selection' | ||||
| 
 | ||||
| function FloatingLinkEditor ({ | ||||
|   editor, | ||||
|   isLink, | ||||
|   setIsLink, | ||||
|   anchorElem | ||||
| }) { | ||||
|   const { setLink } = useLinkInsert() | ||||
|   const editorRef = useRef(null) | ||||
|   const inputRef = useRef(null) | ||||
|   const [linkUrl, setLinkUrl] = useState('') | ||||
|   const [isEditMode, setEditMode] = useState(false) | ||||
| 
 | ||||
|   const updateLinkEditor = useCallback(() => { | ||||
|     const selection = $getSelection() | ||||
|     if ($isRangeSelection(selection)) { | ||||
|       const node = getSelectedNode(selection) | ||||
|       const parent = node.getParent() | ||||
|       if ($isLinkNode(parent)) { | ||||
|         setLinkUrl(parent.getURL()) | ||||
|       } else if ($isLinkNode(node)) { | ||||
|         setLinkUrl(node.getURL()) | ||||
|       } else { | ||||
|         setLinkUrl('') | ||||
|       } | ||||
|     } | ||||
|     const editorElem = editorRef.current | ||||
|     const nativeSelection = window.getSelection() | ||||
|     const activeElement = document.activeElement | ||||
| 
 | ||||
|     if (editorElem === null) { | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     const rootElement = editor.getRootElement() | ||||
| 
 | ||||
|     if ( | ||||
|       selection !== null && | ||||
|        nativeSelection !== null && | ||||
|        rootElement !== null && | ||||
|        rootElement.contains(nativeSelection.anchorNode) && | ||||
|        editor.isEditable() | ||||
|     ) { | ||||
|       const domRange = nativeSelection.getRangeAt(0) | ||||
|       let rect | ||||
|       if (nativeSelection.anchorNode === rootElement) { | ||||
|         let inner = rootElement | ||||
|         while (inner.firstElementChild != null) { | ||||
|           inner = inner.firstElementChild | ||||
|         } | ||||
|         rect = inner.getBoundingClientRect() | ||||
|       } else { | ||||
|         rect = domRange.getBoundingClientRect() | ||||
|       } | ||||
| 
 | ||||
|       setTooltipPosition(rect, editorElem, anchorElem) | ||||
|     } else if (!activeElement) { | ||||
|       if (rootElement !== null) { | ||||
|         setTooltipPosition(null, editorElem, anchorElem) | ||||
|       } | ||||
|       setEditMode(false) | ||||
|       setLinkUrl('') | ||||
|     } | ||||
| 
 | ||||
|     return true | ||||
|   }, [anchorElem, editor]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const scrollerElem = anchorElem.parentElement | ||||
| 
 | ||||
|     const update = () => { | ||||
|       editor.getEditorState().read(() => { | ||||
|         updateLinkEditor() | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     window.addEventListener('resize', update) | ||||
| 
 | ||||
|     if (scrollerElem) { | ||||
|       scrollerElem.addEventListener('scroll', update) | ||||
|     } | ||||
| 
 | ||||
|     return () => { | ||||
|       window.removeEventListener('resize', update) | ||||
| 
 | ||||
|       if (scrollerElem) { | ||||
|         scrollerElem.removeEventListener('scroll', update) | ||||
|       } | ||||
|     } | ||||
|   }, [anchorElem.parentElement, editor, updateLinkEditor]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     return mergeRegister( | ||||
|       editor.registerUpdateListener(({ editorState }) => { | ||||
|         editorState.read(() => { | ||||
|           updateLinkEditor() | ||||
|         }) | ||||
|       }), | ||||
| 
 | ||||
|       editor.registerCommand( | ||||
|         SELECTION_CHANGE_COMMAND, | ||||
|         () => { | ||||
|           updateLinkEditor() | ||||
|           return true | ||||
|         }, | ||||
|         COMMAND_PRIORITY_LOW | ||||
|       ), | ||||
|       editor.registerCommand( | ||||
|         KEY_ESCAPE_COMMAND, | ||||
|         () => { | ||||
|           if (isLink) { | ||||
|             setIsLink(false) | ||||
|             return true | ||||
|           } | ||||
|           return false | ||||
|         }, | ||||
|         COMMAND_PRIORITY_HIGH | ||||
|       ) | ||||
|     ) | ||||
|   }, [editor, updateLinkEditor, setIsLink, isLink]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     editor.getEditorState().read(() => { | ||||
|       updateLinkEditor() | ||||
|     }) | ||||
|   }, [editor, updateLinkEditor]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (isEditMode && inputRef.current) { | ||||
|       inputRef.current.focus() | ||||
|     } | ||||
|   }, [isEditMode]) | ||||
| 
 | ||||
|   return ( | ||||
|     linkUrl && | ||||
|       <div ref={editorRef} className={styles.linkTooltip}> | ||||
|         <div className='tooltip-inner d-flex'> | ||||
|           <a href={linkUrl} target='_blank' rel='noreferrer' className={`${styles.tooltipUrl} text-reset`}>{linkUrl.replace('https://', '').replace('http://', '')}</a> | ||||
|           <span className='px-1'> \ </span> | ||||
|           <span | ||||
|             className='pointer' | ||||
|             onClick={() => { | ||||
|               editor.update(() => { | ||||
|                 // we need to replace the link
 | ||||
|                 // their playground simple 'TOGGLE's it with a new url
 | ||||
|                 // but we need to potentiallyr replace the text
 | ||||
|                 setLink(getLinkFromSelection()) | ||||
|               }) | ||||
|             }} | ||||
|           >edit | ||||
|           </span> | ||||
|           <span className='px-1'> \ </span> | ||||
|           <span | ||||
|             className='pointer' | ||||
|             onClick={() => { | ||||
|               editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) | ||||
|             }} | ||||
|           >remove | ||||
|           </span> | ||||
|         </div> | ||||
|       </div> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function useFloatingLinkEditorToolbar ({ editor, anchorElem }) { | ||||
|   const [activeEditor, setActiveEditor] = useState(editor) | ||||
|   const [isLink, setIsLink] = useState(false) | ||||
| 
 | ||||
|   const updateToolbar = useCallback(() => { | ||||
|     const selection = $getSelection() | ||||
|     if ($isRangeSelection(selection)) { | ||||
|       const node = getSelectedNode(selection) | ||||
|       const linkParent = $findMatchingParent(node, $isLinkNode) | ||||
|       const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode) | ||||
| 
 | ||||
|       // We don't want this menu to open for auto links.
 | ||||
|       if (linkParent != null && autoLinkParent == null) { | ||||
|         setIsLink(true) | ||||
|       } else { | ||||
|         setIsLink(false) | ||||
|       } | ||||
|     } | ||||
|   }, []) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     return editor.registerCommand( | ||||
|       SELECTION_CHANGE_COMMAND, | ||||
|       (_payload, newEditor) => { | ||||
|         updateToolbar() | ||||
|         setActiveEditor(newEditor) | ||||
|         return false | ||||
|       }, | ||||
|       COMMAND_PRIORITY_CRITICAL | ||||
|     ) | ||||
|   }, [editor, updateToolbar]) | ||||
| 
 | ||||
|   return isLink | ||||
|     ? <FloatingLinkEditor | ||||
|         editor={activeEditor} | ||||
|         isLink={isLink} | ||||
|         anchorElem={anchorElem} | ||||
|         setIsLink={setIsLink} | ||||
|       /> | ||||
|     : null | ||||
| } | ||||
| 
 | ||||
| export default function LinkTooltipPlugin ({ | ||||
|   anchorElem = document.body | ||||
| }) { | ||||
|   const [editor] = useLexicalComposerContext() | ||||
|   return useFloatingLinkEditorToolbar({ editor, anchorElem }) | ||||
| } | ||||
							
								
								
									
										68
									
								
								lexical/plugins/list-max-indent.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								lexical/plugins/list-max-indent.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,68 @@ | ||||
| import { $getListDepth, $isListItemNode, $isListNode } from '@lexical/list' | ||||
| import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' | ||||
| import { | ||||
|   $getSelection, | ||||
|   $isElementNode, | ||||
|   $isRangeSelection, | ||||
|   INDENT_CONTENT_COMMAND, | ||||
|   COMMAND_PRIORITY_HIGH | ||||
| } from 'lexical' | ||||
| import { useEffect } from 'react' | ||||
| 
 | ||||
| function getElementNodesInSelection (selection) { | ||||
|   const nodesInSelection = selection.getNodes() | ||||
| 
 | ||||
|   if (nodesInSelection.length === 0) { | ||||
|     return new Set([ | ||||
|       selection.anchor.getNode().getParentOrThrow(), | ||||
|       selection.focus.getNode().getParentOrThrow() | ||||
|     ]) | ||||
|   } | ||||
| 
 | ||||
|   return new Set( | ||||
|     nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())) | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function isIndentPermitted (maxDepth) { | ||||
|   const selection = $getSelection() | ||||
| 
 | ||||
|   if (!$isRangeSelection(selection)) { | ||||
|     return false | ||||
|   } | ||||
| 
 | ||||
|   const elementNodesInSelection = getElementNodesInSelection(selection) | ||||
| 
 | ||||
|   let totalDepth = 0 | ||||
| 
 | ||||
|   for (const elementNode of elementNodesInSelection) { | ||||
|     if ($isListNode(elementNode)) { | ||||
|       totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth) | ||||
|     } else if ($isListItemNode(elementNode)) { | ||||
|       const parent = elementNode.getParent() | ||||
|       if (!$isListNode(parent)) { | ||||
|         throw new Error( | ||||
|           'ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.' | ||||
|         ) | ||||
|       } | ||||
| 
 | ||||
|       totalDepth = Math.max($getListDepth(parent) + 1, totalDepth) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return totalDepth <= maxDepth | ||||
| } | ||||
| 
 | ||||
| export default function ListMaxIndentLevelPlugin ({ maxDepth }) { | ||||
|   const [editor] = useLexicalComposerContext() | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     return editor.registerCommand( | ||||
|       INDENT_CONTENT_COMMAND, | ||||
|       () => !isIndentPermitted(maxDepth ?? 7), | ||||
|       COMMAND_PRIORITY_HIGH | ||||
|     ) | ||||
|   }, [editor, maxDepth]) | ||||
| 
 | ||||
|   return null | ||||
| } | ||||
							
								
								
									
										383
									
								
								lexical/plugins/toolbar.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										383
									
								
								lexical/plugins/toolbar.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,383 @@ | ||||
| import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' | ||||
| import { useCallback, useEffect, useRef, useState } from 'react' | ||||
| import { | ||||
|   SELECTION_CHANGE_COMMAND, | ||||
|   FORMAT_TEXT_COMMAND, | ||||
|   INDENT_CONTENT_COMMAND, | ||||
|   OUTDENT_CONTENT_COMMAND, | ||||
|   $getSelection, | ||||
|   $isRangeSelection, | ||||
|   $createParagraphNode | ||||
| } from 'lexical' | ||||
| import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link' | ||||
| import { | ||||
|   $wrapNodes | ||||
| } from '@lexical/selection' | ||||
| import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils' | ||||
| import { | ||||
|   INSERT_ORDERED_LIST_COMMAND, | ||||
|   INSERT_UNORDERED_LIST_COMMAND, | ||||
|   REMOVE_LIST_COMMAND, | ||||
|   $isListNode, | ||||
|   ListNode | ||||
| } from '@lexical/list' | ||||
| import { | ||||
|   $createHeadingNode, | ||||
|   $createQuoteNode, | ||||
|   $isHeadingNode | ||||
| } from '@lexical/rich-text' | ||||
| // import {
 | ||||
| //   $createCodeNode
 | ||||
| // } from '@lexical/code'
 | ||||
| import BoldIcon from '../../svgs/bold.svg' | ||||
| import ItalicIcon from '../../svgs/italic.svg' | ||||
| // import StrikethroughIcon from '../../svgs/strikethrough.svg'
 | ||||
| import LinkIcon from '../../svgs/link.svg' | ||||
| import ListOrderedIcon from '../../svgs/list-ordered.svg' | ||||
| import ListUnorderedIcon from '../../svgs/list-unordered.svg' | ||||
| import IndentIcon from '../../svgs/indent-increase.svg' | ||||
| import OutdentIcon from '../../svgs/indent-decrease.svg' | ||||
| import ImageIcon from '../../svgs/image-line.svg' | ||||
| import FontSizeIcon from '../../svgs/font-size-2.svg' | ||||
| import QuoteIcon from '../../svgs/double-quotes-r.svg' | ||||
| // import CodeIcon from '../../svgs/code-line.svg'
 | ||||
| // import CodeBoxIcon from '../../svgs/code-box-line.svg'
 | ||||
| import ArrowDownIcon from '../../svgs/arrow-down-s-fill.svg' | ||||
| import CheckIcon from '../../svgs/check-line.svg' | ||||
| 
 | ||||
| import styles from '../styles.module.css' | ||||
| import { Dropdown } from 'react-bootstrap' | ||||
| import { useLinkInsert } from './link-insert' | ||||
| import { getSelectedNode } from '../utils/selected-node' | ||||
| import { getLinkFromSelection } from '../utils/link-from-selection' | ||||
| import { ImageInsertModal } from './image-insert' | ||||
| import useModal from '../utils/modal' | ||||
| 
 | ||||
| const LowPriority = 1 | ||||
| 
 | ||||
| function Divider () { | ||||
|   return <div className={styles.divider} /> | ||||
| } | ||||
| 
 | ||||
| function FontSizeDropdown ({ | ||||
|   editor, | ||||
|   blockType | ||||
| }) { | ||||
|   const formatParagraph = () => { | ||||
|     if (blockType !== 'paragraph') { | ||||
|       editor.update(() => { | ||||
|         const selection = $getSelection() | ||||
| 
 | ||||
|         if ($isRangeSelection(selection)) { | ||||
|           $wrapNodes(selection, () => $createParagraphNode()) | ||||
|         } | ||||
|         setTimeout(() => editor.focus(), 100) | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const formatLargeHeading = () => { | ||||
|     if (blockType !== 'h1') { | ||||
|       editor.update(() => { | ||||
|         const selection = $getSelection() | ||||
| 
 | ||||
|         if ($isRangeSelection(selection)) { | ||||
|           $wrapNodes(selection, () => $createHeadingNode('h1')) | ||||
|         } | ||||
| 
 | ||||
|         setTimeout(() => editor.focus(), 100) | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const formatSmallHeading = () => { | ||||
|     if (blockType !== 'h2') { | ||||
|       editor.update(() => { | ||||
|         const selection = $getSelection() | ||||
| 
 | ||||
|         if ($isRangeSelection(selection)) { | ||||
|           $wrapNodes(selection, () => $createHeadingNode('h2')) | ||||
|         } | ||||
| 
 | ||||
|         setTimeout(() => editor.focus(), 100) | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Dropdown className='pointer' as='span'> | ||||
|       <Dropdown.Toggle | ||||
|         id='dropdown-basic' | ||||
|         as='button' className={styles.toolbarItem} aria-label='Font size' | ||||
|       > | ||||
|         <FontSizeIcon /> | ||||
|         <ArrowDownIcon /> | ||||
|       </Dropdown.Toggle> | ||||
| 
 | ||||
|       <Dropdown.Menu> | ||||
|         <Dropdown.Item as='button' className={`${styles.paragraph} my-0`} onClick={formatParagraph}> | ||||
|           <CheckIcon className={`mr-1 ${blockType === 'paragraph' ? 'fill-grey' : 'invisible'}`} /> | ||||
|           <span className={styles.text}>normal</span> | ||||
|         </Dropdown.Item> | ||||
|         <Dropdown.Item as='button' className={`${styles.heading2} my-0`} onClick={formatSmallHeading}> | ||||
|           <CheckIcon className={`mr-1 ${['h2', 'h3', 'h4', 'h5', 'h6'].includes(blockType) ? 'fill-grey' : 'invisible'}`} /> | ||||
|           <span className={styles.text}>subheading</span> | ||||
|         </Dropdown.Item> | ||||
|         <Dropdown.Item as='button' className={`${styles.heading1} my-0`} onClick={formatLargeHeading}> | ||||
|           <CheckIcon className={`mr-1 ${blockType === 'h1' ? 'fill-grey' : 'invisible'}`} /> | ||||
|           <span className={styles.text}>heading</span> | ||||
|         </Dropdown.Item> | ||||
|       </Dropdown.Menu> | ||||
|     </Dropdown> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export default function ToolbarPlugin () { | ||||
|   const [editor] = useLexicalComposerContext() | ||||
|   const { setLink } = useLinkInsert() | ||||
|   const toolbarRef = useRef(null) | ||||
|   const [blockType, setBlockType] = useState('paragraph') | ||||
|   const [isLink, setIsLink] = useState(false) | ||||
|   const [isBold, setIsBold] = useState(false) | ||||
|   const [isItalic, setIsItalic] = useState(false) | ||||
|   // const [isStrikethrough, setIsStrikethrough] = useState(false)
 | ||||
|   // const [isCode, setIsCode] = useState(false)
 | ||||
|   const [modal, showModal] = useModal() | ||||
| 
 | ||||
|   const updateToolbar = useCallback(() => { | ||||
|     const selection = $getSelection() | ||||
|     if ($isRangeSelection(selection)) { | ||||
|       const anchorNode = selection.anchor.getNode() | ||||
|       const element = | ||||
|         anchorNode.getKey() === 'root' | ||||
|           ? anchorNode | ||||
|           : anchorNode.getTopLevelElementOrThrow() | ||||
|       const elementKey = element.getKey() | ||||
|       const elementDOM = editor.getElementByKey(elementKey) | ||||
|       if (elementDOM !== null) { | ||||
|         if ($isListNode(element)) { | ||||
|           const parentList = $getNearestNodeOfType(anchorNode, ListNode) | ||||
|           const type = parentList ? parentList.getTag() : element.getTag() | ||||
|           setBlockType(type) | ||||
|         } else { | ||||
|           const type = $isHeadingNode(element) | ||||
|             ? element.getTag() | ||||
|             : element.getType() | ||||
|           setBlockType(type) | ||||
|         } | ||||
|       } | ||||
|       // Update text format
 | ||||
|       setIsBold(selection.hasFormat('bold')) | ||||
|       setIsItalic(selection.hasFormat('italic')) | ||||
|       // setIsStrikethrough(selection.hasFormat('strikethrough'))
 | ||||
|       // setIsCode(selection.hasFormat('code'))
 | ||||
| 
 | ||||
|       // Update links
 | ||||
|       const node = getSelectedNode(selection) | ||||
|       const parent = node.getParent() | ||||
|       if ($isLinkNode(parent) || $isLinkNode(node)) { | ||||
|         setIsLink(true) | ||||
|       } else { | ||||
|         setIsLink(false) | ||||
|       } | ||||
|     } | ||||
|   }, [editor]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     return mergeRegister( | ||||
|       editor.registerUpdateListener(({ editorState }) => { | ||||
|         editorState.read(() => { | ||||
|           updateToolbar() | ||||
|         }) | ||||
|       }), | ||||
|       editor.registerCommand( | ||||
|         SELECTION_CHANGE_COMMAND, | ||||
|         (_payload, newEditor) => { | ||||
|           updateToolbar() | ||||
|           return false | ||||
|         }, | ||||
|         LowPriority | ||||
|       ) | ||||
|     ) | ||||
|   }, [editor, updateToolbar]) | ||||
| 
 | ||||
|   const insertLink = useCallback(() => { | ||||
|     if (isLink) { | ||||
|       // unlink it
 | ||||
|       editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) | ||||
|     } else { | ||||
|       editor.update(() => { | ||||
|         setLink(getLinkFromSelection()) | ||||
|       }) | ||||
|     } | ||||
|   }, [editor, isLink]) | ||||
| 
 | ||||
|   const formatBulletList = () => { | ||||
|     if (blockType !== 'ul') { | ||||
|       editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND) | ||||
|     } else { | ||||
|       editor.dispatchCommand(REMOVE_LIST_COMMAND) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const formatNumberedList = () => { | ||||
|     if (blockType !== 'ol') { | ||||
|       editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND) | ||||
|     } else { | ||||
|       editor.dispatchCommand(REMOVE_LIST_COMMAND) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const formatQuote = () => { | ||||
|     if (blockType !== 'quote') { | ||||
|       editor.update(() => { | ||||
|         const selection = $getSelection() | ||||
| 
 | ||||
|         if ($isRangeSelection(selection)) { | ||||
|           $wrapNodes(selection, () => $createQuoteNode()) | ||||
|         } | ||||
|       }) | ||||
|     } else { | ||||
|       editor.update(() => { | ||||
|         const selection = $getSelection() | ||||
| 
 | ||||
|         if ($isRangeSelection(selection)) { | ||||
|           $wrapNodes(selection, () => $createParagraphNode()) | ||||
|         } | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // const formatCode = () => {
 | ||||
|   //   if (blockType !== 'code') {
 | ||||
|   //     editor.update(() => {
 | ||||
|   //       const selection = $getSelection()
 | ||||
| 
 | ||||
|   //       if ($isRangeSelection(selection)) {
 | ||||
|   //         $wrapNodes(selection, () => {
 | ||||
|   //           const node = $createCodeNode()
 | ||||
|   //           node.setLanguage('plain')
 | ||||
|   //           return node
 | ||||
|   //         })
 | ||||
|   //       }
 | ||||
|   //     })
 | ||||
|   //   }
 | ||||
|   // }
 | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={styles.toolbar} ref={toolbarRef}> | ||||
|       <FontSizeDropdown editor={editor} blockType={blockType} /> | ||||
|       <Divider /> | ||||
|       <> | ||||
|         <button | ||||
|           onClick={() => { | ||||
|             editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold') | ||||
|           }} | ||||
|           className={`${styles.toolbarItem} ${styles.spaced} ${isBold ? styles.active : ''}`} | ||||
|           aria-label='Format Bold' | ||||
|         > | ||||
|           <BoldIcon /> | ||||
|         </button> | ||||
|         <button | ||||
|           onClick={() => { | ||||
|             editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic') | ||||
|           }} | ||||
|           className={`${styles.toolbarItem} ${styles.spaced} ${isItalic ? styles.active : ''}`} | ||||
|           aria-label='Format Italics' | ||||
|         > | ||||
|           <ItalicIcon /> | ||||
|         </button> | ||||
|         <Divider /> | ||||
|         <button | ||||
|           onClick={formatBulletList} | ||||
|           className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'ul' ? styles.active : ''}`} | ||||
|         > | ||||
|           <ListUnorderedIcon /> | ||||
|         </button> | ||||
|         <button | ||||
|           onClick={formatNumberedList} | ||||
|           className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'ol' ? styles.active : ''}`} | ||||
|           aria-label='Insert numbered list' | ||||
|         > | ||||
|           <ListOrderedIcon /> | ||||
|         </button> | ||||
|         <button | ||||
|           onClick={() => { | ||||
|             editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined) | ||||
|           }} | ||||
|           className={`${styles.toolbarItem} ${styles.spaced}`} | ||||
|           aria-label='Indent' | ||||
|         > | ||||
|           <IndentIcon /> | ||||
|         </button> | ||||
|         <button | ||||
|           onClick={() => { | ||||
|             editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined) | ||||
|           }} | ||||
|           className={`${styles.toolbarItem} ${styles.spaced}`} | ||||
|           aria-label='Outdent' | ||||
|         > | ||||
|           <OutdentIcon /> | ||||
|         </button> | ||||
|         <button | ||||
|           onClick={formatQuote} | ||||
|           className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'quote' ? styles.active : ''}`} | ||||
|           aria-label='Insert Quote' | ||||
|         > | ||||
|           <QuoteIcon /> | ||||
|         </button> | ||||
|         {/* <Divider /> */} | ||||
|         {/* <button | ||||
|           onClick={() => { | ||||
|             editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough') | ||||
|           }} | ||||
|           className={ | ||||
|             `${styles.toolbarItem} ${styles.spaced} ${isStrikethrough ? styles.active : ''}` | ||||
|             } | ||||
|           aria-label='Format Strikethrough' | ||||
|         > | ||||
|           <StrikethroughIcon /> | ||||
|         </button> */} | ||||
|         {/* <button | ||||
|           onClick={() => { | ||||
|             editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code') | ||||
|           }} | ||||
|           className={`${styles.toolbarItem} ${styles.spaced} ${isCode ? styles.active : ''}`} | ||||
|           aria-label='Insert Code' | ||||
|         > | ||||
|           <CodeIcon /> | ||||
|         </button> */} | ||||
|         {/* <button | ||||
|           onClick={formatCode} | ||||
|           className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'code' ? styles.active : ''}`} | ||||
|           aria-label='Insert Code' | ||||
|         > | ||||
|           <CodeBoxIcon /> | ||||
|         </button> */} | ||||
|         <Divider /> | ||||
|         <button | ||||
|           onClick={insertLink} | ||||
|           className={`${styles.toolbarItem} ${styles.spaced} ${isLink ? styles.active : ''}`} | ||||
|           aria-label='Insert Link' | ||||
|         > | ||||
|           <LinkIcon /> | ||||
|         </button> | ||||
|         <button | ||||
|           onClick={() => { | ||||
|             showModal((onClose) => ( | ||||
|               <ImageInsertModal | ||||
|                 editor={editor} | ||||
|                 onClose={onClose} | ||||
|               /> | ||||
|             )) | ||||
|           }} | ||||
|           className={`${styles.toolbarItem} ${styles.spaced}`} | ||||
|           aria-label='Insert Image' | ||||
|         > | ||||
|           <ImageIcon /> | ||||
|         </button> | ||||
|         {modal} | ||||
|       </> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										256
									
								
								lexical/styles.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										256
									
								
								lexical/styles.module.css
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,256 @@ | ||||
| /* editor */ | ||||
| 
 | ||||
| .editor { | ||||
|     height: 100%; | ||||
|     position: relative; | ||||
| } | ||||
| 
 | ||||
| .editorContainer { | ||||
|     margin: 20px auto 20px auto; | ||||
|     width: 100%; | ||||
|     color: var(--theme-color); | ||||
|     position: relative; | ||||
|     line-height: 20px; | ||||
|     font-weight: 400; | ||||
|     text-align: left; | ||||
|     border-top-left-radius: .4rem; | ||||
|     border-top-right-radius: .4rem; | ||||
| } | ||||
| 
 | ||||
| .editorInner { | ||||
|     position: relative; | ||||
| } | ||||
| 
 | ||||
| .editorInput>hr { | ||||
|     border-top: 1px solid var(--theme-clickToContextColor); | ||||
| } | ||||
| 
 | ||||
| .editorInput { | ||||
|     min-height: 150px; | ||||
|     resize: auto; | ||||
|     font-size: 15px; | ||||
|     caret-color: var(--theme-color); | ||||
|     background-color: var(--theme-body); | ||||
|     position: relative; | ||||
|     tab-size: 1; | ||||
|     outline: 0; | ||||
|     padding: 15px 10px; | ||||
|     border: 1px solid; | ||||
|     border-bottom-left-radius: .4rem; | ||||
|     border-bottom-right-radius: .4rem; | ||||
|     /* border-top: 0px; */ | ||||
| } | ||||
| 
 | ||||
| .editorPlaceholder { | ||||
|     color: var(--theme-grey); | ||||
|     overflow: hidden; | ||||
|     position: absolute; | ||||
|     text-overflow: ellipsis; | ||||
|     top: 15px; | ||||
|     left: 10px; | ||||
|     font-size: 15px; | ||||
|     user-select: none; | ||||
|     display: inline-block; | ||||
|     pointer-events: none; | ||||
| } | ||||
| 
 | ||||
| /* blocks */ | ||||
| 
 | ||||
| .image { | ||||
|     display: inline-block; | ||||
| } | ||||
| 
 | ||||
| .paragraph { | ||||
|     margin: 0; | ||||
|     margin-bottom: 8px; | ||||
|     position: relative; | ||||
| } | ||||
| 
 | ||||
| .paragraph:last-child { | ||||
|     margin-bottom: 0; | ||||
| } | ||||
| 
 | ||||
| .quote { | ||||
|     margin: 0; | ||||
|     margin-left: 20px; | ||||
|     font-size: 15px; | ||||
|     color: var(--theme-quoteColor); | ||||
|     border-left-color: var(--theme-quoteBar); | ||||
|     border-left-width: 4px; | ||||
|     border-left-style: solid; | ||||
|     padding-left: 16px; | ||||
| } | ||||
| 
 | ||||
| .heading1 { | ||||
|     font-size: 24px; | ||||
|     color: var(--theme-color); | ||||
|     font-weight: 400; | ||||
|     margin: 0; | ||||
|     margin-bottom: 12px; | ||||
| } | ||||
| 
 | ||||
| .heading2 { | ||||
|     font-size: 15px; | ||||
|     color: var(--theme-navLink); | ||||
|     font-weight: 700; | ||||
|     margin: 0; | ||||
|     margin-top: 10px; | ||||
|     text-transform: uppercase; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| .code { | ||||
|     background-color: rgb(240, 242, 245); | ||||
|     font-family: Menlo, Consolas, Monaco, monospace; | ||||
|     display: block; | ||||
|     padding: 8px 8px 8px 52px; | ||||
|     line-height: 1.53; | ||||
|     font-size: 13px; | ||||
|     margin: 0; | ||||
|     margin-top: 8px; | ||||
|     margin-bottom: 8px; | ||||
|     tab-size: 2; | ||||
|     /* white-space: pre; */ | ||||
|     overflow-x: auto; | ||||
|     position: relative; | ||||
| } | ||||
| 
 | ||||
| /* inline blocks */ | ||||
| 
 | ||||
| .link { | ||||
|     color: var(--theme-link); | ||||
|     text-decoration: none; | ||||
| } | ||||
| 
 | ||||
| /* lists */ | ||||
| 
 | ||||
| .listOl { | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
|     margin-left: 16px; | ||||
| } | ||||
| 
 | ||||
| .listUl { | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
|     margin-left: 16px; | ||||
| } | ||||
| 
 | ||||
| .listItem { | ||||
|     margin: 8px 32px 8px 32px; | ||||
| } | ||||
| 
 | ||||
| .nestedListItem { | ||||
|     list-style-type: none; | ||||
| } | ||||
| 
 | ||||
| /* text */ | ||||
| 
 | ||||
| .textBold { | ||||
|     font-weight: bold; | ||||
| } | ||||
| 
 | ||||
| .textItalic { | ||||
|     font-style: italic; | ||||
| } | ||||
| 
 | ||||
| .textUnderline { | ||||
|     text-decoration: underline; | ||||
| } | ||||
| 
 | ||||
| .textStrikethrough { | ||||
|     text-decoration: line-through; | ||||
| } | ||||
| 
 | ||||
| .textUnderlineStrikethrough { | ||||
|     text-decoration: underline line-through; | ||||
| } | ||||
| 
 | ||||
| .textCode { | ||||
|     background-color: rgb(240, 242, 245); | ||||
|     padding: 1px 0.25rem; | ||||
|     font-family: Menlo, Consolas, Monaco, monospace; | ||||
|     font-size: 94%; | ||||
| } | ||||
| 
 | ||||
| /* toolbar */ | ||||
| .toolbar { | ||||
|     display: flex; | ||||
|     background: var(--theme-toolbar); | ||||
|     padding: 4px; | ||||
|     border-top-left-radius: .4rem; | ||||
|     border-top-right-radius: .4rem; | ||||
|     vertical-align: middle; | ||||
|     flex-wrap: wrap; | ||||
| } | ||||
| 
 | ||||
| .toolbar button.toolbarItem { | ||||
|     border: 0; | ||||
|     display: flex; | ||||
|     background: none; | ||||
|     border-radius: .4rem; | ||||
|     padding: 8px; | ||||
|     cursor: pointer; | ||||
|     vertical-align: middle; | ||||
| } | ||||
| 
 | ||||
| .toolbar button.toolbarItem:disabled { | ||||
|     cursor: not-allowed; | ||||
| } | ||||
| 
 | ||||
| .toolbar button.toolbarItem.spaced { | ||||
|     margin-right: 2px; | ||||
| } | ||||
| 
 | ||||
| .toolbar button.toolbarItem svg { | ||||
|     background-size: contain; | ||||
|     display: inline-block; | ||||
|     height: 18px; | ||||
|     width: 18px; | ||||
|     margin-top: 2px; | ||||
|     vertical-align: -0.25em; | ||||
|     display: flex; | ||||
|     opacity: 0.6; | ||||
| } | ||||
| 
 | ||||
| .toolbar button.toolbarItem:disabled svg { | ||||
|     opacity: 0.2; | ||||
| } | ||||
| 
 | ||||
| .toolbar button.toolbarItem.active { | ||||
|     background-color: var(--theme-toolbarActive); | ||||
| } | ||||
| 
 | ||||
| .toolbar button.toolbarItem.active svg { | ||||
|     opacity: 1; | ||||
| } | ||||
| 
 | ||||
| .toolbar .toolbarItem:hover:not([disabled]) { | ||||
|     background-color: var(--theme-toolbarHover); | ||||
| } | ||||
| 
 | ||||
| .toolbar .divider { | ||||
|     width: 1px; | ||||
|     background-color: var(--theme-borderColor); | ||||
|     margin: 0 4px; | ||||
| } | ||||
| 
 | ||||
| .toolbar .toolbarItem svg { | ||||
|     fill: var(--theme-color) !important; | ||||
| } | ||||
| 
 | ||||
| .linkTooltip { | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     z-index: 10; | ||||
|     font-size: 0.7875rem; | ||||
|     opacity: 0; | ||||
|     will-change: transform; | ||||
| } | ||||
| 
 | ||||
| .tooltipUrl { | ||||
|     white-space: nowrap; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
| } | ||||
							
								
								
									
										37
									
								
								lexical/theme.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								lexical/theme.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,37 @@ | ||||
| import styles from './styles.module.css' | ||||
| 
 | ||||
| const theme = { | ||||
|   paragraph: styles.paragraph, | ||||
|   quote: styles.quote, | ||||
|   heading: { | ||||
|     h1: styles.heading1, | ||||
|     h2: styles.heading2, | ||||
|     h3: styles.heading2, | ||||
|     h4: styles.heading2, | ||||
|     h5: styles.heading2 | ||||
|   }, | ||||
|   image: styles.image, | ||||
|   link: styles.link, | ||||
|   code: styles.code, | ||||
|   hr: styles.hr, | ||||
|   list: { | ||||
|     nested: { | ||||
|       listitem: styles.nestedListItem | ||||
|     }, | ||||
|     ol: styles.listOl, | ||||
|     ul: styles.listUl, | ||||
|     listitem: styles.listItem | ||||
|   }, | ||||
|   text: { | ||||
|     bold: styles.textBold, | ||||
|     italic: styles.textItalic, | ||||
|     // overflowed: 'editor-text-overflowed',
 | ||||
|     // hashtag: 'editor-text-hashtag',
 | ||||
|     underline: styles.textUnderline, | ||||
|     strikethrough: styles.textStrikethrough, | ||||
|     underlineStrikethrough: styles.underlineStrikethrough, | ||||
|     code: styles.textCode | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default theme | ||||
							
								
								
									
										55
									
								
								lexical/utils/image-markdown-transformer.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								lexical/utils/image-markdown-transformer.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,55 @@ | ||||
| import { | ||||
|   $createImageNode, | ||||
|   $isImageNode, | ||||
|   ImageNode | ||||
| } from '../nodes/image' | ||||
| import { | ||||
|   $createHorizontalRuleNode, | ||||
|   $isHorizontalRuleNode, | ||||
|   HorizontalRuleNode | ||||
| } from '@lexical/react/LexicalHorizontalRuleNode' | ||||
| import { TRANSFORMERS } from '@lexical/markdown' | ||||
| 
 | ||||
| export const IMAGE = { | ||||
|   dependencies: [ImageNode], | ||||
|   export: (node, exportChildren, exportFormat) => { | ||||
|     if (!$isImageNode(node)) { | ||||
|       return null | ||||
|     } | ||||
|     return `})` | ||||
|   }, | ||||
|   importRegExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))/, | ||||
|   regExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))$/, | ||||
|   replace: (textNode, match) => { | ||||
|     const [, altText, src] = match | ||||
|     const imageNode = $createImageNode({ altText, src }) | ||||
|     textNode.replace(imageNode) | ||||
|   }, | ||||
|   trigger: ')', | ||||
|   type: 'text-match' | ||||
| } | ||||
| 
 | ||||
| export const HR = { | ||||
|   dependencies: [HorizontalRuleNode], | ||||
|   export: (node) => { | ||||
|     return $isHorizontalRuleNode(node) ? '***' : null | ||||
|   }, | ||||
|   regExp: /^(-{3,}|\*{3,}|_{3,})\s?$/, | ||||
|   replace: (parentNode, _1, _2, isImport) => { | ||||
|     const line = $createHorizontalRuleNode() | ||||
| 
 | ||||
|     // TODO: Get rid of isImport flag
 | ||||
|     if (isImport || parentNode.getNextSibling() != null) { | ||||
|       parentNode.replace(line) | ||||
|     } else { | ||||
|       parentNode.insertBefore(line) | ||||
|     } | ||||
| 
 | ||||
|     line.selectNext() | ||||
|   }, | ||||
|   type: 'element' | ||||
| } | ||||
| 
 | ||||
| export const SN_TRANSFORMERS = [ | ||||
|   HR, IMAGE, ...TRANSFORMERS | ||||
| ] | ||||
							
								
								
									
										24
									
								
								lexical/utils/link-from-selection.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								lexical/utils/link-from-selection.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | ||||
| import { $getSelection, $getTextContent, $isRangeSelection } from 'lexical' | ||||
| import { getSelectedNode } from './selected-node' | ||||
| import { $isLinkNode } from '@lexical/link' | ||||
| 
 | ||||
| export function getLinkFromSelection () { | ||||
|   const selection = $getSelection() | ||||
|   let url = '' | ||||
|   let text = '' | ||||
|   if ($isRangeSelection(selection)) { | ||||
|     const node = getSelectedNode(selection) | ||||
|     const parent = node.getParent() | ||||
|     if ($isLinkNode(parent)) { | ||||
|       url = parent.getURL() | ||||
|       text = parent.getTextContent() | ||||
|     } else if ($isLinkNode(node)) { | ||||
|       url = node.getURL() | ||||
|       text = node.getTextContent() | ||||
|     } else { | ||||
|       url = '' | ||||
|       text = $getTextContent(selection) | ||||
|     } | ||||
|   } | ||||
|   return { url, text } | ||||
| } | ||||
							
								
								
									
										34
									
								
								lexical/utils/modal.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								lexical/utils/modal.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,34 @@ | ||||
| import { useCallback, useMemo, useState } from 'react' | ||||
| import * as React from 'react' | ||||
| import { Modal } from 'react-bootstrap' | ||||
| 
 | ||||
| export default function useModal () { | ||||
|   const [modalContent, setModalContent] = useState(null) | ||||
| 
 | ||||
|   const onClose = useCallback(() => { | ||||
|     setModalContent(null) | ||||
|   }, []) | ||||
| 
 | ||||
|   const modal = useMemo(() => { | ||||
|     if (modalContent === null) { | ||||
|       return null | ||||
|     } | ||||
|     return ( | ||||
|       <Modal onHide={onClose} show={!!modalContent}> | ||||
|         <div className='modal-close' onClick={onClose}>X</div> | ||||
|         <Modal.Body> | ||||
|           {modalContent} | ||||
|         </Modal.Body> | ||||
|       </Modal> | ||||
|     ) | ||||
|   }, [modalContent, onClose]) | ||||
| 
 | ||||
|   const showModal = useCallback( | ||||
|     (getContent) => { | ||||
|       setModalContent(getContent(onClose)) | ||||
|     }, | ||||
|     [onClose] | ||||
|   ) | ||||
| 
 | ||||
|   return [modal, showModal] | ||||
| } | ||||
							
								
								
									
										17
									
								
								lexical/utils/selected-node.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								lexical/utils/selected-node.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,17 @@ | ||||
| import { $isAtNodeEnd } from '@lexical/selection' | ||||
| 
 | ||||
| export function getSelectedNode (selection) { | ||||
|   const anchor = selection.anchor | ||||
|   const focus = selection.focus | ||||
|   const anchorNode = selection.anchor.getNode() | ||||
|   const focusNode = selection.focus.getNode() | ||||
|   if (anchorNode === focusNode) { | ||||
|     return anchorNode | ||||
|   } | ||||
|   const isBackward = selection.isBackward() | ||||
|   if (isBackward) { | ||||
|     return $isAtNodeEnd(focus) ? anchorNode : focusNode | ||||
|   } else { | ||||
|     return $isAtNodeEnd(anchor) ? anchorNode : focusNode | ||||
|   } | ||||
| } | ||||
							
								
								
									
										41
									
								
								lexical/utils/tooltip-position.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								lexical/utils/tooltip-position.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,41 @@ | ||||
| const VERTICAL_GAP = 5 | ||||
| const HORIZONTAL_OFFSET = 5 | ||||
| 
 | ||||
| export function setTooltipPosition ( | ||||
|   targetRect, | ||||
|   floatingElem, | ||||
|   anchorElem, | ||||
|   verticalGap = VERTICAL_GAP, | ||||
|   horizontalOffset = HORIZONTAL_OFFSET | ||||
| ) { | ||||
|   const scrollerElem = anchorElem.parentElement | ||||
| 
 | ||||
|   if (targetRect === null || !scrollerElem) { | ||||
|     floatingElem.style.opacity = '0' | ||||
|     floatingElem.style.transform = 'translate(-10000px, -10000px)' | ||||
|     return | ||||
|   } | ||||
| 
 | ||||
|   const floatingElemRect = floatingElem.getBoundingClientRect() | ||||
|   const anchorElementRect = anchorElem.getBoundingClientRect() | ||||
|   const editorScrollerRect = scrollerElem.getBoundingClientRect() | ||||
| 
 | ||||
|   let top = targetRect.top - floatingElemRect.height - verticalGap | ||||
|   let left = targetRect.left - horizontalOffset | ||||
| 
 | ||||
|   top += floatingElemRect.height + targetRect.height + verticalGap * 2 | ||||
| 
 | ||||
|   if (left + floatingElemRect.width > editorScrollerRect.right) { | ||||
|     left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset | ||||
|   } | ||||
| 
 | ||||
|   top -= anchorElementRect.top | ||||
|   left -= anchorElementRect.left | ||||
| 
 | ||||
|   if (top > 0 && left > 0) { | ||||
|     floatingElem.style.opacity = '1' | ||||
|   } else { | ||||
|     floatingElem.style.opacity = '0' | ||||
|   } | ||||
|   floatingElem.style.transform = `translate(${left}px, ${top}px)` | ||||
| } | ||||
							
								
								
									
										24
									
								
								lexical/utils/url.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								lexical/utils/url.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | ||||
| export function sanitizeUrl (url) { | ||||
|   /** A pattern that matches safe  URLs. */ | ||||
|   const SAFE_URL_PATTERN = | ||||
|     /^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi | ||||
| 
 | ||||
|   /** A pattern that matches safe data URLs. */ | ||||
|   const DATA_URL_PATTERN = | ||||
|     /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i | ||||
| 
 | ||||
|   url = String(url).trim() | ||||
| 
 | ||||
|   if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url | ||||
| 
 | ||||
|   return 'https://' | ||||
| } | ||||
| 
 | ||||
| // Source: https://stackoverflow.com/a/8234912/2013580
 | ||||
| const urlRegExp = | ||||
|   /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/ | ||||
| export function validateUrl (url) { | ||||
|   // TODO Fix UI for link insertion; it should never default to an invalid URL such as https://.
 | ||||
|   // Maybe show a dialog where they user can type the URL before inserting it.
 | ||||
|   return url === 'https://' || urlRegExp.test(url) | ||||
| } | ||||
| @ -1,11 +1,11 @@ | ||||
| import { ApolloClient, InMemoryCache, from, HttpLink } from '@apollo/client' | ||||
| import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client' | ||||
| import { decodeCursor, LIMIT } from './cursor' | ||||
| import { RetryLink } from '@apollo/client/link/retry' | ||||
| // import { RetryLink } from '@apollo/client/link/retry'
 | ||||
| 
 | ||||
| const additiveLink = from([ | ||||
|   new RetryLink(), | ||||
|   new HttpLink({ uri: '/api/graphql' }) | ||||
| ]) | ||||
| // const additiveLink = from([
 | ||||
| //   new RetryLink(),
 | ||||
| //   new HttpLink({ uri: '/api/graphql' })
 | ||||
| // ])
 | ||||
| 
 | ||||
| function isFirstPage (cursor, existingThings) { | ||||
|   if (cursor) { | ||||
| @ -19,8 +19,19 @@ function isFirstPage (cursor, existingThings) { | ||||
| } | ||||
| 
 | ||||
| export default function getApolloClient () { | ||||
|   global.apolloClient ||= new ApolloClient({ | ||||
|     link: additiveLink, | ||||
|   if (typeof window === 'undefined') { | ||||
|     const client = getClient(`${process.env.SELF_URL}/api/graphql`) | ||||
|     client.clearStore() | ||||
|     return client | ||||
|   } else { | ||||
|     global.apolloClient ||= getClient('/api/graphql') | ||||
|     return global.apolloClient | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function getClient (uri) { | ||||
|   return new ApolloClient({ | ||||
|     link: new HttpLink({ uri }), | ||||
|     cache: new InMemoryCache({ | ||||
|       typePolicies: { | ||||
|         Query: { | ||||
| @ -39,7 +50,7 @@ export default function getApolloClient () { | ||||
|               } | ||||
|             }, | ||||
|             items: { | ||||
|               keyArgs: ['sub', 'sort', 'name', 'within'], | ||||
|               keyArgs: ['sub', 'sort', 'type', 'name', 'within'], | ||||
|               merge (existing, incoming) { | ||||
|                 if (isFirstPage(incoming.cursor, existing?.items)) { | ||||
|                   return incoming | ||||
| @ -201,6 +212,4 @@ export default function getApolloClient () { | ||||
|       } | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
|   return global.apolloClient | ||||
| } | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| export const NOFOLLOW_LIMIT = 1000 | ||||
| export const NOFOLLOW_LIMIT = 100 | ||||
| export const BOOST_MIN = 5000 | ||||
| export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024 | ||||
| export const IMAGE_PIXELS_MAX = 35000000 | ||||
| @ -16,3 +16,4 @@ export const ITEM_SPAM_INTERVAL = '10m' | ||||
| export const MAX_POLL_NUM_CHOICES = 10 | ||||
| export const ITEM_FILTER_THRESHOLD = 1.2 | ||||
| export const DONT_LIKE_THIS_COST = 1 | ||||
| export const MAX_NOSTR_RELAY_NUM = 20 | ||||
|  | ||||
| @ -9,3 +9,17 @@ export const abbrNum = n => { | ||||
| export const fixedDecimal = (n, f) => { | ||||
|   return Number.parseFloat(n).toFixed(f) | ||||
| } | ||||
| 
 | ||||
| export const msatsToSats = msats => { | ||||
|   if (msats === null || msats === undefined) { | ||||
|     return null | ||||
|   } | ||||
|   return Number(BigInt(msats) / 1000n) | ||||
| } | ||||
| 
 | ||||
| export const msatsToSatsDecimal = msats => { | ||||
|   if (msats === null || msats === undefined) { | ||||
|     return null | ||||
|   } | ||||
|   return fixedDecimal(msats / 1000.0, 3) | ||||
| } | ||||
|  | ||||
| @ -5,6 +5,7 @@ export function ensureProtocol (value) { | ||||
|   return value | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| export function removeTracking (value) { | ||||
|   const exprs = [ | ||||
|     // twitter URLs
 | ||||
| @ -15,3 +16,9 @@ export function removeTracking (value) { | ||||
|   } | ||||
|   return value | ||||
| } | ||||
| 
 | ||||
| // eslint-disable-next-line
 | ||||
| export const URL_REGEXP = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i | ||||
| 
 | ||||
| // eslint-disable-next-line
 | ||||
| export const WS_REGEXP = /^(wss?:\/\/)([0-9]{1,3}(?:\.[0-9]{1,3}){3}|(?=[^\/]{1,254}(?![^\/]))(?:(?=[a-zA-Z0-9-]{1,63}\.)(?:xn--+)?[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,63})(:([0-9]{1,5}))?$/ | ||||
|  | ||||
							
								
								
									
										18
									
								
								middleware.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								middleware.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | ||||
| import { NextResponse } from 'next/server' | ||||
| 
 | ||||
| export function middleware (request) { | ||||
|   const regex = /(\/.*)?\/r\/([\w_]+)/ | ||||
|   const m = regex.exec(request.nextUrl.pathname) | ||||
| 
 | ||||
|   const url = new URL(m[1] || '/', request.url) | ||||
|   url.search = request.nextUrl.search | ||||
|   url.hash = request.nextUrl.hash | ||||
| 
 | ||||
|   const resp = NextResponse.redirect(url) | ||||
|   resp.cookies.set('sn_referrer', m[2]) | ||||
|   return resp | ||||
| } | ||||
| 
 | ||||
| export const config = { | ||||
|   matcher: ['/(.*/|)r/([\\w_]+)([?#]?.*)'] | ||||
| } | ||||
| @ -26,7 +26,7 @@ module.exports = withPlausibleProxy()({ | ||||
|       return Object.keys(RuntimeSources['stacker.news'])[0] | ||||
|     }, | ||||
|   // Use the CDN in production and localhost for development.
 | ||||
|   assetPrefix: isProd ? 'https://a.stacker.news' : '', | ||||
|   assetPrefix: isProd ? 'https://a.stacker.news' : undefined, | ||||
|   async headers () { | ||||
|     return [ | ||||
|       { | ||||
| @ -42,6 +42,18 @@ module.exports = withPlausibleProxy()({ | ||||
|             value: 'public, max-age=31536000, immutable' | ||||
|           } | ||||
|         ] | ||||
|       }, | ||||
|       { | ||||
|         source: '/.well-known/:slug*', | ||||
|         headers: [ | ||||
|           ...corsHeaders | ||||
|         ] | ||||
|       }, | ||||
|       { | ||||
|         source: '/api/lnauth', | ||||
|         headers: [ | ||||
|           ...corsHeaders | ||||
|         ] | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
| @ -71,6 +83,10 @@ module.exports = withPlausibleProxy()({ | ||||
|         source: '/.well-known/lnurlp/:username', | ||||
|         destination: '/api/lnurlp/:username' | ||||
|       }, | ||||
|       { | ||||
|         source: '/.well-known/nostr.json', | ||||
|         destination: '/api/nostr/nip05' | ||||
|       }, | ||||
|       { | ||||
|         source: '/~:sub', | ||||
|         destination: '/~/:sub' | ||||
|  | ||||
							
								
								
									
										6335
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6335
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										82
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										82
									
								
								package.json
									
									
									
									
									
								
							| @ -3,74 +3,81 @@ | ||||
|   "version": "0.1.0", | ||||
|   "private": true, | ||||
|   "scripts": { | ||||
|     "dev": "NODE_OPTIONS='--trace-warnings --inspect' next dev", | ||||
|     "dev": "NODE_OPTIONS='--trace-warnings' next dev", | ||||
|     "build": "next build", | ||||
|     "migrate": "prisma migrate deploy", | ||||
|     "start": "NODE_OPTIONS='--trace-warnings' next start -p $PORT" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@apollo/client": "^3.4.15", | ||||
|     "@opensearch-project/opensearch": "^1.0.2", | ||||
|     "@prisma/client": "^2.25.0", | ||||
|     "apollo-server-micro": "^2.21.2", | ||||
|     "@apollo/client": "^3.7.1", | ||||
|     "@lexical/react": "^0.7.5", | ||||
|     "@opensearch-project/opensearch": "^1.1.0", | ||||
|     "@prisma/client": "^2.30.3", | ||||
|     "apollo-server-micro": "^3.11.1", | ||||
|     "async-retry": "^1.3.1", | ||||
|     "aws-sdk": "^2.1056.0", | ||||
|     "aws-sdk": "^2.1248.0", | ||||
|     "babel-plugin-inline-react-svg": "^2.0.1", | ||||
|     "bech32": "^2.0.0", | ||||
|     "bolt11": "^1.3.4", | ||||
|     "bootstrap": "^4.6.0", | ||||
|     "bolt11": "^1.4.0", | ||||
|     "bootstrap": "^4.6.2", | ||||
|     "browserslist": "^4.21.4", | ||||
|     "clipboard-copy": "^4.0.1", | ||||
|     "cross-fetch": "^3.1.5", | ||||
|     "domino": "^2.1.6", | ||||
|     "formik": "^2.2.6", | ||||
|     "github-slugger": "^1.4.0", | ||||
|     "graphql": "^15.5.0", | ||||
|     "github-slugger": "^1.5.0", | ||||
|     "graphql": "^15.8.0", | ||||
|     "graphql-tools": "^8.3.10", | ||||
|     "graphql-type-json": "^0.3.2", | ||||
|     "ln-service": "^54.2.1", | ||||
|     "jquery": "^3.6.1", | ||||
|     "lexical": "^0.7.5", | ||||
|     "ln-service": "^54.2.6", | ||||
|     "mdast-util-find-and-replace": "^1.1.1", | ||||
|     "mdast-util-from-markdown": "^1.2.0", | ||||
|     "mdast-util-to-string": "^3.1.0", | ||||
|     "next": "^11.1.2", | ||||
|     "next-auth": "^3.29.3", | ||||
|     "next-plausible": "^2.1.3", | ||||
|     "next-seo": "^4.24.0", | ||||
|     "nextjs-progressbar": "^0.0.13", | ||||
|     "micro": "^9.4.1", | ||||
|     "next": "^12.3.2", | ||||
|     "next-auth": "^3.29.10", | ||||
|     "next-plausible": "^3.6.4", | ||||
|     "next-seo": "^4.29.0", | ||||
|     "nextjs-progressbar": "0.0.16", | ||||
|     "node-s3-url-encode": "^0.0.4", | ||||
|     "page-metadata-parser": "^1.1.4", | ||||
|     "pageres": "^6.3.0", | ||||
|     "pg-boss": "^7.0.2", | ||||
|     "prisma": "^2.25.0", | ||||
|     "qrcode.react": "^1.0.1", | ||||
|     "react": "^17.0.1", | ||||
|     "pageres": "^7.1.0", | ||||
|     "pg-boss": "^7.4.0", | ||||
|     "popper.js": "^1.16.1", | ||||
|     "prisma": "^2.30.3", | ||||
|     "qrcode.react": "^3.1.0", | ||||
|     "react": "^17.0.2", | ||||
|     "react-avatar-editor": "^13.0.0", | ||||
|     "react-bootstrap": "^1.5.2", | ||||
|     "react-countdown": "^2.3.2", | ||||
|     "react-bootstrap": "^1.6.6", | ||||
|     "react-countdown": "^2.3.3", | ||||
|     "react-dom": "^17.0.2", | ||||
|     "react-longpressable": "^1.1.1", | ||||
|     "react-markdown": "^8.0.0", | ||||
|     "react-markdown": "^8.0.3", | ||||
|     "react-string-replace": "^0.4.4", | ||||
|     "react-syntax-highlighter": "^15.4.3", | ||||
|     "react-textarea-autosize": "^8.3.3", | ||||
|     "react-syntax-highlighter": "^15.5.0", | ||||
|     "react-textarea-autosize": "^8.3.4", | ||||
|     "react-twitter-embed": "^4.0.4", | ||||
|     "react-youtube": "^7.14.0", | ||||
|     "recharts": "^2.1.10", | ||||
|     "recharts": "^2.1.16", | ||||
|     "remark-directive": "^2.0.1", | ||||
|     "remark-gfm": "^3.0.1", | ||||
|     "remove-markdown": "^0.3.0", | ||||
|     "sass": "^1.32.8", | ||||
|     "secp256k1": "^4.0.2", | ||||
|     "swr": "^0.5.4", | ||||
|     "unist-util-visit": "^4.1.0", | ||||
|     "sass": "^1.56.0", | ||||
|     "secp256k1": "^4.0.3", | ||||
|     "swr": "^1.3.0", | ||||
|     "unist-util-visit": "^4.1.1", | ||||
|     "use-dark-mode": "^2.3.1", | ||||
|     "uuid": "^8.3.2", | ||||
|     "webln": "^0.2.2", | ||||
|     "yup": "^0.32.9" | ||||
|     "yup": "^0.32.11" | ||||
|   }, | ||||
|   "engines": { | ||||
|     "node": "14.17.0" | ||||
|   }, | ||||
|   "standard": { | ||||
|     "parser": "babel-eslint", | ||||
|     "parser": "@babel/eslint-parser", | ||||
|     "plugins": [ | ||||
|       "eslint-plugin-compat" | ||||
|     ], | ||||
| @ -82,9 +89,10 @@ | ||||
|     } | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "babel-eslint": "^10.1.0", | ||||
|     "eslint": "^7.29.0", | ||||
|     "eslint-plugin-compat": "^3.9.0", | ||||
|     "standard": "^16.0.3" | ||||
|     "@babel/core": "^7.20.2", | ||||
|     "@babel/eslint-parser": "^7.19.1", | ||||
|     "eslint": "^7.32.0", | ||||
|     "eslint-plugin-compat": "^4.0.2", | ||||
|     "standard": "^16.0.4" | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,11 +1,9 @@ | ||||
| import '../styles/globals.scss' | ||||
| import { ApolloProvider, gql, useQuery } from '@apollo/client' | ||||
| import { Provider } from 'next-auth/client' | ||||
| import { FundErrorModal, FundErrorProvider } from '../components/fund-error' | ||||
| import { MeProvider } from '../components/me' | ||||
| import PlausibleProvider from 'next-plausible' | ||||
| import { LightningProvider } from '../components/lightning' | ||||
| import { ItemActModal, ItemActProvider } from '../components/item-act' | ||||
| import getApolloClient from '../lib/apollo' | ||||
| import NextNProgress from 'nextjs-progressbar' | ||||
| import { PriceProvider } from '../components/price' | ||||
| @ -14,6 +12,7 @@ import { useRouter } from 'next/dist/client/router' | ||||
| import { useEffect } from 'react' | ||||
| import Moon from '../svgs/moon-fill.svg' | ||||
| import Layout from '../components/layout' | ||||
| import { ShowModalProvider } from '../components/modal' | ||||
| 
 | ||||
| function CSRWrapper ({ Component, apollo, ...props }) { | ||||
|   const { data, error } = useQuery(gql`${apollo.query}`, { variables: apollo.variables, fetchPolicy: 'cache-first' }) | ||||
| @ -42,17 +41,19 @@ function MyApp ({ Component, pageProps: { session, ...props } }) { | ||||
|   const client = getApolloClient() | ||||
|   const router = useRouter() | ||||
| 
 | ||||
|   useEffect(async () => { | ||||
|   useEffect(() => { | ||||
|     // HACK: 'cause there's no way to tell Next to skip SSR
 | ||||
|     // So every page load, we modify the route in browser history
 | ||||
|     // to point to the same page but without SSR, ie ?nodata=true
 | ||||
|     // this nodata var will get passed to the server on back/foward and
 | ||||
|     // 1. prevent data from reloading and 2. perserve scroll
 | ||||
|     // (2) is not possible while intercepting nav with beforePopState
 | ||||
|     if (router.isReady) { | ||||
|       router.replace({ | ||||
|         pathname: router.pathname, | ||||
|         query: { ...router.query, nodata: true } | ||||
|       }, router.asPath, { ...router.options, scroll: false }) | ||||
|     } | ||||
|   }, [router.asPath]) | ||||
| 
 | ||||
|   /* | ||||
| @ -60,7 +61,7 @@ function MyApp ({ Component, pageProps: { session, ...props } }) { | ||||
|     ssr data | ||||
|   */ | ||||
|   const { apollo, data, me, price } = props | ||||
|   if (typeof window !== 'undefined' && apollo && data) { | ||||
|   if (apollo && data) { | ||||
|     client.writeQuery({ | ||||
|       query: gql`${apollo.query}`, | ||||
|       data: data, | ||||
| @ -87,15 +88,11 @@ function MyApp ({ Component, pageProps: { session, ...props } }) { | ||||
|             <MeProvider me={me}> | ||||
|               <PriceProvider price={price}> | ||||
|                 <LightningProvider> | ||||
|                   <FundErrorProvider> | ||||
|                     <FundErrorModal /> | ||||
|                     <ItemActProvider> | ||||
|                       <ItemActModal /> | ||||
|                   <ShowModalProvider> | ||||
|                     {data || !apollo?.query | ||||
|                       ? <Component {...props} /> | ||||
|                       : <CSRWrapper Component={Component} {...props} />} | ||||
|                     </ItemActProvider> | ||||
|                   </FundErrorProvider> | ||||
|                   </ShowModalProvider> | ||||
|                 </LightningProvider> | ||||
|               </PriceProvider> | ||||
|             </MeProvider> | ||||
|  | ||||
| @ -5,9 +5,7 @@ import prisma from '../../../api/models' | ||||
| import nodemailer from 'nodemailer' | ||||
| import { getSession } from 'next-auth/client' | ||||
| 
 | ||||
| export default (req, res) => NextAuth(req, res, options) | ||||
| 
 | ||||
| const options = { | ||||
| export default (req, res) => NextAuth(req, res, { | ||||
|   callbacks: { | ||||
|     /** | ||||
|      * @param  {object}  token     Decrypted JSON Web Token | ||||
| @ -26,8 +24,17 @@ const options = { | ||||
|         token.user = { id: Number(user.id) } | ||||
|       } | ||||
| 
 | ||||
|       if (isNewUser) { | ||||
|         // if referrer exists, set on user
 | ||||
|         if (req.cookies.sn_referrer && user?.id) { | ||||
|           const referrer = await prisma.user.findUnique({ where: { name: req.cookies.sn_referrer } }) | ||||
|           if (referrer) { | ||||
|             await prisma.user.update({ where: { id: user.id }, data: { referrerId: referrer.id } }) | ||||
|           } | ||||
|         } | ||||
| 
 | ||||
|         // sign them up for the newsletter
 | ||||
|       if (isNewUser && profile.email) { | ||||
|         if (profile.email) { | ||||
|           fetch(process.env.LIST_MONK_URL + '/api/subscribers', { | ||||
|             method: 'POST', | ||||
|             headers: { | ||||
| @ -43,6 +50,7 @@ const options = { | ||||
|             }) | ||||
|           }).then(async r => console.log(await r.json())).catch(console.log) | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       return token | ||||
|     }, | ||||
| @ -128,9 +136,10 @@ const options = { | ||||
|     signingKey: process.env.JWT_SIGNING_PRIVATE_KEY | ||||
|   }, | ||||
|   pages: { | ||||
|     signIn: '/login' | ||||
|   } | ||||
|     signIn: '/login', | ||||
|     verifyRequest: '/email' | ||||
|   } | ||||
| }) | ||||
| 
 | ||||
| function sendVerificationRequest ({ | ||||
|   identifier: email, | ||||
|  | ||||
| @ -9,7 +9,7 @@ const bucketRegion = 'us-east-1' | ||||
| const contentType = 'image/png' | ||||
| const bucketUrl = 'https://sn-capture.s3.amazonaws.com/' | ||||
| const s3PathPrefix = process.env.NODE_ENV === 'development' ? 'dev/' : '' | ||||
| var capturing = false | ||||
| let capturing = false | ||||
| 
 | ||||
| AWS.config.update({ | ||||
|   region: bucketRegion | ||||
|  | ||||
| @ -51,4 +51,11 @@ export const config = { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default apolloServer.createHandler({ path: '/api/graphql' }) | ||||
| const startServer = apolloServer.start() | ||||
| 
 | ||||
| export default async function handler (req, res) { | ||||
|   await startServer | ||||
|   await apolloServer.createHandler({ | ||||
|     path: '/api/graphql' | ||||
|   })(req, res) | ||||
| } | ||||
|  | ||||
| @ -32,7 +32,7 @@ export default async ({ query }, res) => { | ||||
|           k1: query.k1, // Random or non-random string to identify the user's LN WALLET when using the callback URL
 | ||||
|           defaultDescription: `Withdrawal for @${user.name} on SN`, // A default withdrawal invoice description
 | ||||
|           minWithdrawable: 1000, // Min amount (in millisatoshis) the user can withdraw from LN SERVICE, or 0
 | ||||
|           maxWithdrawable: user.msats - 10000 // Max amount (in millisatoshis) the user can withdraw from LN SERVICE, or equal to minWithdrawable if the user has no choice over the amounts
 | ||||
|           maxWithdrawable: Number(user.msats - 10000n) // Max amount (in millisatoshis) the user can withdraw from LN SERVICE, or equal to minWithdrawable if the user has no choice over the amounts
 | ||||
|         }) | ||||
|       } else { | ||||
|         reason = 'user not found' | ||||
|  | ||||
							
								
								
									
										28
									
								
								pages/api/nostr/nip05.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								pages/api/nostr/nip05.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| import models from '../../../api/models' | ||||
| 
 | ||||
| export default async function Nip05 ({ query: { name } }, res) { | ||||
|   const names = {} | ||||
|   let relays = {} | ||||
| 
 | ||||
|   const users = await models.user.findMany({ | ||||
|     where: { | ||||
|       name, | ||||
|       nostrPubkey: { not: null } | ||||
|     }, | ||||
|     include: { nostrRelays: true } | ||||
|   }) | ||||
| 
 | ||||
|   for (const user of users) { | ||||
|     names[user.name] = user.nostrPubkey | ||||
|     if (user.nostrRelays.length) { | ||||
|       // append relays with key pubkey
 | ||||
|       relays[user.nostrPubkey] = [] | ||||
|       for (const relay of user.nostrRelays) { | ||||
|         relays[user.nostrPubkey].push(relay.nostrRelayAddr) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   relays = Object.keys(relays).length ? relays : undefined | ||||
|   return res.status(200).json({ names, relays }) | ||||
| } | ||||
							
								
								
									
										14
									
								
								pages/email.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								pages/email.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | ||||
| import LayoutError from '../components/layout-error' | ||||
| import { Image } from 'react-bootstrap' | ||||
| 
 | ||||
| export default function Email () { | ||||
|   return ( | ||||
|     <LayoutError> | ||||
|       <div className='p-4 text-center'> | ||||
|         <h1>Check your email</h1> | ||||
|         <h4 className='pb-4'>A sign in link has been sent to your email address</h4> | ||||
|         <Image width='500' height='376' src='/hello.gif' fluid /> | ||||
|       </div> | ||||
|     </LayoutError> | ||||
|   ) | ||||
| } | ||||
| @ -6,6 +6,7 @@ import { gql } from '@apollo/client' | ||||
| import { INVITE_FIELDS } from '../../fragments/invites' | ||||
| import getSSRApolloClient from '../../api/ssrApollo' | ||||
| import Link from 'next/link' | ||||
| import LayoutCenter from '../../components/layout-center' | ||||
| 
 | ||||
| export async function getServerSideProps ({ req, res, query: { id, error = null } }) { | ||||
|   const session = await getSession({ req }) | ||||
| @ -63,7 +64,7 @@ function InviteHeader ({ invite }) { | ||||
|   } else { | ||||
|     Inner = () => ( | ||||
|       <div> | ||||
|         get <span className='text-success'>{invite.gift} free sats</span> from{' '} | ||||
|         Get <span className='text-success'>{invite.gift} free sats</span> from{' '} | ||||
|         <Link href={`/${invite.user.name}`} passHref><a>@{invite.user.name}</a></Link>{' '} | ||||
|         when you sign up today | ||||
|       </div> | ||||
| @ -71,12 +72,16 @@ function InviteHeader ({ invite }) { | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <h2 className='text-center pb-3'> | ||||
|     <h3 className='text-center pb-3'> | ||||
|       <Inner /> | ||||
|     </h2> | ||||
|     </h3> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export default function Invite ({ invite, ...props }) { | ||||
|   return <Login Header={() => <InviteHeader invite={invite} />} {...props} /> | ||||
|   return ( | ||||
|     <LayoutCenter> | ||||
|       <Login Header={() => <InviteHeader invite={invite} />} text='Sign up' {...props} /> | ||||
|     </LayoutCenter> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| @ -120,7 +120,7 @@ export default function Invites () { | ||||
|         <h2 className='mt-3 mb-0'> | ||||
|           invite links | ||||
|         </h2> | ||||
|         <small className='d-block text-muted font-weight-bold mx-5'>send these to people you trust somewhat, e.g. group chats or DMs</small> | ||||
|         <small className='d-block text-muted font-weight-bold mx-5'>send these to people you trust, e.g. group chats or DMs</small> | ||||
|       </div> | ||||
|       <InviteForm /> | ||||
|       {active.length > 0 && <InviteList name='active' invites={active} />} | ||||
|  | ||||
| @ -19,7 +19,10 @@ function LoadInvoice () { | ||||
|     pollInterval: 1000, | ||||
|     variables: { id: router.query.id } | ||||
|   }) | ||||
|   if (error) return <div>error</div> | ||||
|   if (error) { | ||||
|     console.log(error) | ||||
|     return <div>error</div> | ||||
|   } | ||||
|   if (!data || loading) { | ||||
|     return <LnQRSkeleton status='loading' /> | ||||
|   } | ||||
|  | ||||
							
								
								
									
										146
									
								
								pages/lexical.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								pages/lexical.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,146 @@ | ||||
| import LayoutCenter from '../components/layout-center' | ||||
| import styles from '../lexical/styles.module.css' | ||||
| 
 | ||||
| import Theme from '../lexical/theme' | ||||
| import ListMaxIndentLevelPlugin from '../lexical/plugins/list-max-indent' | ||||
| import AutoLinkPlugin from '../lexical/plugins/autolink' | ||||
| import ToolbarPlugin from '../lexical/plugins/toolbar' | ||||
| import LinkTooltipPlugin from '../lexical/plugins/link-tooltip' | ||||
| 
 | ||||
| import { LexicalComposer } from '@lexical/react/LexicalComposer' | ||||
| import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' | ||||
| import { ContentEditable } from '@lexical/react/LexicalContentEditable' | ||||
| import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin' | ||||
| import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' | ||||
| import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary' | ||||
| import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode' | ||||
| import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' | ||||
| import { HeadingNode, QuoteNode } from '@lexical/rich-text' | ||||
| import { TableCellNode, TableNode, TableRowNode } from '@lexical/table' | ||||
| import { ListItemNode, ListNode } from '@lexical/list' | ||||
| import { CodeHighlightNode, CodeNode } from '@lexical/code' | ||||
| import { AutoLinkNode, LinkNode } from '@lexical/link' | ||||
| import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin' | ||||
| import { ListPlugin } from '@lexical/react/LexicalListPlugin' | ||||
| import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin' | ||||
| import { useState } from 'react' | ||||
| import LinkInsertPlugin, { LinkInsertProvider } from '../lexical/plugins/link-insert' | ||||
| import { ImageNode } from '../lexical/nodes/image' | ||||
| import ImageInsertPlugin from '../lexical/plugins/image-insert' | ||||
| import { SN_TRANSFORMERS } from '../lexical/utils/image-markdown-transformer' | ||||
| import { $convertToMarkdownString, $convertFromMarkdownString } from '@lexical/markdown' | ||||
| import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' | ||||
| import Text from '../components/text' | ||||
| import { Button } from 'react-bootstrap' | ||||
| 
 | ||||
| const editorConfig = { | ||||
|   // The editor theme
 | ||||
|   theme: Theme, | ||||
|   // Handling of errors during update
 | ||||
|   onError (error) { | ||||
|     throw error | ||||
|   }, | ||||
|   // Any custom nodes go here
 | ||||
|   nodes: [ | ||||
|     HeadingNode, | ||||
|     ListNode, | ||||
|     ListItemNode, | ||||
|     QuoteNode, | ||||
|     CodeNode, | ||||
|     CodeHighlightNode, | ||||
|     TableNode, | ||||
|     TableCellNode, | ||||
|     TableRowNode, | ||||
|     AutoLinkNode, | ||||
|     LinkNode, | ||||
|     HorizontalRuleNode, | ||||
|     ImageNode | ||||
|   ] | ||||
| } | ||||
| 
 | ||||
| function Editor ({ markdown }) { | ||||
|   const [floatingAnchorElem, setFloatingAnchorElem] = useState(null) | ||||
| 
 | ||||
|   const onRef = (_floatingAnchorElem) => { | ||||
|     if (_floatingAnchorElem !== null) { | ||||
|       setFloatingAnchorElem(_floatingAnchorElem) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   let initialConfig = editorConfig | ||||
|   if (markdown) { | ||||
|     initialConfig = { ...initialConfig, editorState: () => $convertFromMarkdownString(markdown, SN_TRANSFORMERS) } | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <LexicalComposer initialConfig={initialConfig}> | ||||
|       <div className={styles.editorContainer}> | ||||
|         <div className={styles.editorInner}> | ||||
|           <LinkInsertProvider> | ||||
|             <ToolbarPlugin /> | ||||
|             <LinkTooltipPlugin anchorElem={floatingAnchorElem} /> | ||||
|             <LinkInsertPlugin /> | ||||
|           </LinkInsertProvider> | ||||
|           <RichTextPlugin | ||||
|             contentEditable={ | ||||
|               <div className={styles.editor} ref={onRef}> | ||||
|                 <ContentEditable className={styles.editorInput} /> | ||||
|               </div> | ||||
|             } | ||||
|             placeholder={null} | ||||
|             ErrorBoundary={LexicalErrorBoundary} | ||||
|           /> | ||||
|           <ImageInsertPlugin /> | ||||
|           <AutoFocusPlugin /> | ||||
|           <ListPlugin /> | ||||
|           <LinkPlugin /> | ||||
|           <AutoLinkPlugin /> | ||||
|           <HistoryPlugin /> | ||||
|           <ListMaxIndentLevelPlugin maxDepth={4} /> | ||||
|           <MarkdownShortcutPlugin transformers={SN_TRANSFORMERS} /> | ||||
|         </div> | ||||
|       </div> | ||||
|       {!markdown && <Markdown />} | ||||
|     </LexicalComposer> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function Markdown () { | ||||
|   const [editor] = useLexicalComposerContext() | ||||
|   const [markdown, setMarkdown] = useState(null) | ||||
|   const [preview, togglePreview] = useState(true) | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <div className='lexical text-left w-100'> | ||||
|         <OnChangePlugin onChange={() => editor.update(() => { | ||||
|           setMarkdown($convertToMarkdownString(SN_TRANSFORMERS)) | ||||
|         })} | ||||
|         /> | ||||
|         <Button size='sm' className='mb-2' onClick={() => togglePreview(!preview)}>{preview ? 'show markdown' : 'show preview'}</Button> | ||||
|         <div style={{ border: '1px solid var(--theme-color)', padding: '.5rem', borderRadius: '.4rem' }}> | ||||
| 
 | ||||
|           {preview | ||||
|             ? ( | ||||
|               <Text> | ||||
|                 {markdown} | ||||
|               </Text> | ||||
|               ) | ||||
|             : ( | ||||
|               <pre className='text-reset p-0 m-0'> | ||||
|                 {markdown} | ||||
|               </pre> | ||||
|               )} | ||||
|         </div> | ||||
|       </div> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export default function Lexical () { | ||||
|   return ( | ||||
|     <LayoutCenter footerLinks> | ||||
|       <Editor /> | ||||
|     </LayoutCenter> | ||||
|   ) | ||||
| } | ||||
| @ -1,4 +1,6 @@ | ||||
| import { providers, getSession } from 'next-auth/client' | ||||
| import Link from 'next/link' | ||||
| import LayoutCenter from '../components/layout-center' | ||||
| import Login from '../components/login' | ||||
| 
 | ||||
| export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) { | ||||
| @ -21,4 +23,19 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default Login | ||||
| function LoginFooter ({ callbackUrl }) { | ||||
|   return ( | ||||
|     <small className='font-weight-bold text-muted pt-4'>Don't have an account? <Link href={{ pathname: '/signup', query: { callbackUrl } }}>sign up</Link></small> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export default function LoginPage (props) { | ||||
|   return ( | ||||
|     <LayoutCenter> | ||||
|       <Login | ||||
|         Footer={() => <LoginFooter callbackUrl={props.callbackUrl} />} | ||||
|         {...props} | ||||
|       /> | ||||
|     </LayoutCenter> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| @ -1,10 +1,7 @@ | ||||
| import { Nav, Navbar } from 'react-bootstrap' | ||||
| import { getGetServerSideProps } from '../api/ssrApollo' | ||||
| import Layout from '../components/layout' | ||||
| import Notifications from '../components/notifications' | ||||
| import { NOTIFICATIONS } from '../fragments/notifications' | ||||
| import styles from '../components/header.module.css' | ||||
| import Link from 'next/link' | ||||
| import { useRouter } from 'next/router' | ||||
| 
 | ||||
| export const getServerSideProps = getGetServerSideProps(NOTIFICATIONS) | ||||
| @ -14,7 +11,6 @@ export default function NotificationPage ({ data: { notifications: { notificatio | ||||
| 
 | ||||
|   return ( | ||||
|     <Layout> | ||||
|       <NotificationHeader /> | ||||
|       <Notifications | ||||
|         notifications={notifications} cursor={cursor} | ||||
|         lastChecked={lastChecked} variables={{ inc: router.query?.inc }} | ||||
| @ -22,34 +18,3 @@ export default function NotificationPage ({ data: { notifications: { notificatio | ||||
|     </Layout> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function NotificationHeader () { | ||||
|   const router = useRouter() | ||||
|   return ( | ||||
|     <Navbar className='py-0'> | ||||
|       <Nav | ||||
|         className={`${styles.navbarNav} justify-content-around`} | ||||
|         activeKey={router.asPath} | ||||
|       > | ||||
|         <Nav.Item> | ||||
|           <Link href='/notifications' passHref> | ||||
|             <Nav.Link | ||||
|               className={styles.navLink} | ||||
|             > | ||||
|               all | ||||
|             </Nav.Link> | ||||
|           </Link> | ||||
|         </Nav.Item> | ||||
|         <Nav.Item> | ||||
|           <Link href='/notifications?inc=replies' passHref> | ||||
|             <Nav.Link | ||||
|               className={styles.navLink} | ||||
|             > | ||||
|               replies | ||||
|             </Nav.Link> | ||||
|           </Link> | ||||
|         </Nav.Item> | ||||
|       </Nav> | ||||
|     </Navbar> | ||||
|   ) | ||||
| } | ||||
|  | ||||
							
								
								
									
										22
									
								
								pages/recent/[type].js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								pages/recent/[type].js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,22 @@ | ||||
| import Layout from '../../components/layout' | ||||
| import Items from '../../components/items' | ||||
| import { getGetServerSideProps } from '../../api/ssrApollo' | ||||
| import { ITEMS } from '../../fragments/items' | ||||
| import RecentHeader from '../../components/recent-header' | ||||
| import { useRouter } from 'next/router' | ||||
| 
 | ||||
| const variables = { sort: 'recent' } | ||||
| export const getServerSideProps = getGetServerSideProps(ITEMS, variables) | ||||
| 
 | ||||
| export default function Index ({ data: { items: { items, cursor } } }) { | ||||
|   const router = useRouter() | ||||
|   return ( | ||||
|     <Layout> | ||||
|       <RecentHeader /> | ||||
|       <Items | ||||
|         items={items} cursor={cursor} | ||||
|         variables={{ ...variables, type: router.query.type }} rank | ||||
|       /> | ||||
|     </Layout> | ||||
|   ) | ||||
| } | ||||
| @ -10,7 +10,7 @@ export const getServerSideProps = getGetServerSideProps(MORE_FLAT_COMMENTS, vari | ||||
| export default function Index ({ data: { moreFlatComments: { comments, cursor } } }) { | ||||
|   return ( | ||||
|     <Layout> | ||||
|       <RecentHeader itemType='comments' /> | ||||
|       <RecentHeader type='comments' /> | ||||
|       <CommentsFlat | ||||
|         comments={comments} cursor={cursor} | ||||
|         variables={{ sort: 'recent' }} includeParent noReply | ||||
|  | ||||
| @ -10,7 +10,7 @@ export const getServerSideProps = getGetServerSideProps(ITEMS, variables) | ||||
| export default function Index ({ data: { items: { items, cursor } } }) { | ||||
|   return ( | ||||
|     <Layout> | ||||
|       <RecentHeader itemType='posts' /> | ||||
|       <RecentHeader type='posts' /> | ||||
|       <Items | ||||
|         items={items} cursor={cursor} | ||||
|         variables={variables} rank | ||||
|  | ||||
							
								
								
									
										75
									
								
								pages/referrals/[when].js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								pages/referrals/[when].js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,75 @@ | ||||
| import { gql } from 'apollo-server-micro' | ||||
| import Link from 'next/link' | ||||
| import { useRouter } from 'next/router' | ||||
| import { getGetServerSideProps } from '../../api/ssrApollo' | ||||
| import { CopyInput, Form, Select } from '../../components/form' | ||||
| import LayoutCenter from '../../components/layout-center' | ||||
| import { useMe } from '../../components/me' | ||||
| import { WhenComposedChart } from '../../components/when-charts' | ||||
| 
 | ||||
| export const getServerSideProps = getGetServerSideProps( | ||||
|   gql` | ||||
|     query Referrals($when: String!) | ||||
|     { | ||||
|       referrals(when: $when) { | ||||
|         totalSats | ||||
|         totalReferrals | ||||
|         stats { | ||||
|           time | ||||
|           data { | ||||
|             name | ||||
|             value | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }`)
 | ||||
| 
 | ||||
| export default function Referrals ({ data: { referrals: { totalSats, totalReferrals, stats } } }) { | ||||
|   const router = useRouter() | ||||
|   const me = useMe() | ||||
|   return ( | ||||
|     <LayoutCenter footerLinks> | ||||
|       <Form | ||||
|         initial={{ | ||||
|           when: router.query.when | ||||
|         }} | ||||
|       > | ||||
|         <h4 className='font-weight-bold text-muted text-center pt-5 pb-3 d-flex align-items-center justify-content-center'> | ||||
|           {totalReferrals} referrals & {totalSats} sats in the last | ||||
|           <Select | ||||
|             groupClassName='mb-0 ml-2' | ||||
|             className='w-auto' | ||||
|             name='when' | ||||
|             size='sm' | ||||
|             items={['day', 'week', 'month', 'year', 'forever']} | ||||
|             onChange={(formik, e) => router.push(`/referrals/${e.target.value}`)} | ||||
|           /> | ||||
|         </h4> | ||||
|       </Form> | ||||
|       <WhenComposedChart data={stats} lineNames={['sats']} barNames={['referrals']} /> | ||||
| 
 | ||||
|       <div | ||||
|         className='text-small pt-5 px-3 d-flex w-100 align-items-center' | ||||
|       > | ||||
|         <div className='nav-item text-muted pr-2' style={{ 'white-space': 'nowrap' }}>referral link:</div> | ||||
|         <CopyInput | ||||
|           size='sm' | ||||
|           groupClassName='mb-0 w-100' | ||||
|           readOnly | ||||
|           noForm | ||||
|           placeholder={`https://stacker.news/r/${me.name}`} | ||||
|         /> | ||||
|       </div> | ||||
|       <ul className='py-3 text-muted'> | ||||
|         <li>{`appending /r/${me.name} to any SN link makes it a ref link`} | ||||
|           <ul> | ||||
|             <li>e.g. https://stacker.news/items/1/r/{me.name}</li>
 | ||||
|           </ul> | ||||
|         </li> | ||||
|         <li>earn 21% of boost and job fees spent by referred stackers</li> | ||||
|         <li>earn 2.1% of all tips received by referred stackers</li> | ||||
|         <li><Link href='/invites' passHref><a>invite links</a></Link> are also implicitly referral links</li> | ||||
|       </ul> | ||||
|     </LayoutCenter> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										142
									
								
								pages/rewards.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								pages/rewards.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,142 @@ | ||||
| import { gql } from 'apollo-server-micro' | ||||
| import { useEffect, useRef, useState } from 'react' | ||||
| import { Button, InputGroup, Modal } from 'react-bootstrap' | ||||
| import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts' | ||||
| import { getGetServerSideProps } from '../api/ssrApollo' | ||||
| import { Form, Input, SubmitButton } from '../components/form' | ||||
| import LayoutCenter from '../components/layout-center' | ||||
| import * as Yup from 'yup' | ||||
| import { useMutation, useQuery } from '@apollo/client' | ||||
| import Link from 'next/link' | ||||
| 
 | ||||
| const REWARDS = gql` | ||||
| { | ||||
|   expectedRewards { | ||||
|     total | ||||
|     sources { | ||||
|       name | ||||
|       value | ||||
|     } | ||||
|   } | ||||
| } | ||||
| ` | ||||
| 
 | ||||
| export const getServerSideProps = getGetServerSideProps(REWARDS) | ||||
| 
 | ||||
| export default function Rewards ({ data: { expectedRewards: { total, sources } } }) { | ||||
|   const { data } = useQuery(REWARDS, { pollInterval: 1000 }) | ||||
| 
 | ||||
|   if (data) { | ||||
|     ({ expectedRewards: { total, sources } } = data) | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <LayoutCenter footerLinks> | ||||
|       <h4 className='font-weight-bold text-muted text-center'> | ||||
|         <div>{total} sats to be rewarded today</div> | ||||
|         <Link href='/faq#how-do-i-earn-sats-on-stacker-news' passHref> | ||||
|           <a className='text-reset'><small><small><small>learn about rewards</small></small></small></a> | ||||
|         </Link> | ||||
|       </h4> | ||||
|       <div className='my-3 w-100'> | ||||
|         <GrowthPieChart data={sources} /> | ||||
|       </div> | ||||
|       <DonateButton /> | ||||
|     </LayoutCenter> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| const COLORS = [ | ||||
|   'var(--secondary)', | ||||
|   'var(--info)', | ||||
|   'var(--success)', | ||||
|   'var(--boost)', | ||||
|   'var(--grey)' | ||||
| ] | ||||
| 
 | ||||
| function GrowthPieChart ({ data }) { | ||||
|   return ( | ||||
|     <ResponsiveContainer width='100%' height={250} minWidth={200}> | ||||
|       <PieChart margin={{ top: 5, right: 5, bottom: 5, left: 5 }}> | ||||
|         <Pie | ||||
|           dataKey='value' | ||||
|           isAnimationActive={false} | ||||
|           data={data} | ||||
|           cx='50%' | ||||
|           cy='50%' | ||||
|           outerRadius={80} | ||||
|           fill='var(--secondary)' | ||||
|           label | ||||
|         > | ||||
|           { | ||||
|             data.map((entry, index) => ( | ||||
|               <Cell key={`cell-${index}`} fill={COLORS[index]} /> | ||||
|             )) | ||||
|           } | ||||
|         </Pie> | ||||
|         <Tooltip /> | ||||
|       </PieChart> | ||||
|     </ResponsiveContainer> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export const DonateSchema = Yup.object({ | ||||
|   amount: Yup.number().typeError('must be a number').required('required') | ||||
|     .positive('must be positive').integer('must be whole') | ||||
| }) | ||||
| 
 | ||||
| export function DonateButton () { | ||||
|   const [show, setShow] = useState(false) | ||||
|   const inputRef = useRef(null) | ||||
|   const [donateToRewards] = useMutation( | ||||
|     gql` | ||||
|       mutation donateToRewards($sats: Int!) { | ||||
|         donateToRewards(sats: $sats) | ||||
|       }`)
 | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     inputRef.current?.focus() | ||||
|   }, [show]) | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <Button onClick={() => setShow(true)}>DONATE TO REWARDS</Button> | ||||
|       <Modal | ||||
|         show={show} | ||||
|         onHide={() => { | ||||
|           setShow(false) | ||||
|         }} | ||||
|       > | ||||
|         <div className='modal-close' onClick={() => setShow(false)}>X</div> | ||||
|         <Modal.Body> | ||||
|           <Form | ||||
|             initial={{ | ||||
|               amount: 1000 | ||||
|             }} | ||||
|             schema={DonateSchema} | ||||
|             onSubmit={async ({ amount }) => { | ||||
|               await donateToRewards({ | ||||
|                 variables: { | ||||
|                   sats: Number(amount) | ||||
|                 } | ||||
|               }) | ||||
|               setShow(false) | ||||
|             }} | ||||
|           > | ||||
|             <Input | ||||
|               label='amount' | ||||
|               name='amount' | ||||
|               innerRef={inputRef} | ||||
|               required | ||||
|               autoFocus | ||||
|               append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} | ||||
|             /> | ||||
|             <div className='d-flex'> | ||||
|               <SubmitButton variant='success' className='ml-auto mt-1 px-4' value='TIP'>donate</SubmitButton> | ||||
|             </div> | ||||
|           </Form> | ||||
|         </Modal.Body> | ||||
|       </Modal> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| @ -97,6 +97,25 @@ function Detail ({ fact }) { | ||||
|       </> | ||||
|     ) | ||||
|   } | ||||
|   if (fact.type === 'donation') { | ||||
|     return ( | ||||
|       <> | ||||
|         <div className={satusClass(fact.status)}> | ||||
|           You made a donation to <Link href='/rewards' passHref><a>daily rewards</a></Link>! | ||||
|         </div> | ||||
|       </> | ||||
|     ) | ||||
|   } | ||||
|   if (fact.type === 'referral') { | ||||
|     return ( | ||||
|       <> | ||||
|         <div className={satusClass(fact.status)}> | ||||
|           You stacked sats from <Link href='/referrals/month' passHref><a>a referral</a></Link>! | ||||
|         </div> | ||||
|       </> | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   if (!fact.item) { | ||||
|     return ( | ||||
|       <> | ||||
| @ -144,6 +163,8 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor | ||||
|       case 'invoice': | ||||
|         return `/${fact.type}s/${fact.factId}` | ||||
|       case 'earn': | ||||
|       case 'donation': | ||||
|       case 'referral': | ||||
|         return | ||||
|       default: | ||||
|         return `/items/${fact.factId}` | ||||
| @ -199,7 +220,9 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor | ||||
|             <tr> | ||||
|               <th className={styles.type}>type</th> | ||||
|               <th>detail</th> | ||||
|               <th className={styles.sats}>sats</th> | ||||
|               <th className={styles.sats}> | ||||
|                 sats | ||||
|               </th> | ||||
|             </tr> | ||||
|           </thead> | ||||
|           <tbody> | ||||
| @ -213,7 +236,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor | ||||
|                     <td className={styles.description}> | ||||
|                       <Detail fact={f} /> | ||||
|                     </td> | ||||
|                     <td className={`${styles.sats} ${satusClass(f.status)}`}>{Math.floor(f.msats / 1000)}</td> | ||||
|                     <td className={`${styles.sats} ${satusClass(f.status)}`}>{f.sats}</td> | ||||
|                   </tr> | ||||
|                 </Wrapper> | ||||
|               ) | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| import { Checkbox, Form, Input, SubmitButton, Select } from '../components/form' | ||||
| import { Checkbox, Form, Input, SubmitButton, Select, VariableInput } from '../components/form' | ||||
| import * as Yup from 'yup' | ||||
| import { Alert, Button, InputGroup, Modal } from 'react-bootstrap' | ||||
| import LayoutCenter from '../components/layout-center' | ||||
| @ -13,6 +13,10 @@ import { SETTINGS, SET_SETTINGS } from '../fragments/users' | ||||
| import { useRouter } from 'next/router' | ||||
| import Info from '../components/info' | ||||
| import { CURRENCY_SYMBOLS } from '../components/price' | ||||
| import Link from 'next/link' | ||||
| import AccordianItem from '../components/accordian-item' | ||||
| import { MAX_NOSTR_RELAY_NUM } from '../lib/constants' | ||||
| import { WS_REGEXP } from '../lib/url' | ||||
| 
 | ||||
| export const getServerSideProps = getGetServerSideProps(SETTINGS) | ||||
| 
 | ||||
| @ -21,7 +25,12 @@ const supportedCurrencies = Object.keys(CURRENCY_SYMBOLS) | ||||
| export const SettingsSchema = Yup.object({ | ||||
|   tipDefault: Yup.number().typeError('must be a number').required('required') | ||||
|     .positive('must be positive').integer('must be whole'), | ||||
|   fiatCurrency: Yup.string().required('required').oneOf(supportedCurrencies) | ||||
|   fiatCurrency: Yup.string().required('required').oneOf(supportedCurrencies), | ||||
|   nostrPubkey: Yup.string().matches(/^[0-9a-fA-F]{64}$/, 'must be 64 hex chars'), | ||||
|   nostrRelays: Yup.array().of( | ||||
|     Yup.string().matches(WS_REGEXP, 'invalid web socket address') | ||||
|   ).max(MAX_NOSTR_RELAY_NUM, | ||||
|     ({ max, value }) => `${Math.abs(max - value.length)} too many`) | ||||
| }) | ||||
| 
 | ||||
| const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again' | ||||
| @ -58,6 +67,7 @@ export default function Settings ({ data: { settings } }) { | ||||
|         <Form | ||||
|           initial={{ | ||||
|             tipDefault: settings?.tipDefault || 21, | ||||
|             turboTipping: settings?.turboTipping, | ||||
|             fiatCurrency: settings?.fiatCurrency || 'USD', | ||||
|             noteItemSats: settings?.noteItemSats, | ||||
|             noteEarning: settings?.noteEarning, | ||||
| @ -67,12 +77,28 @@ export default function Settings ({ data: { settings } }) { | ||||
|             noteInvites: settings?.noteInvites, | ||||
|             noteJobIndicator: settings?.noteJobIndicator, | ||||
|             hideInvoiceDesc: settings?.hideInvoiceDesc, | ||||
|             hideFromTopUsers: settings?.hideFromTopUsers, | ||||
|             wildWestMode: settings?.wildWestMode, | ||||
|             greeterMode: settings?.greeterMode | ||||
|             greeterMode: settings?.greeterMode, | ||||
|             nostrPubkey: settings?.nostrPubkey || '', | ||||
|             nostrRelays: settings?.nostrRelays?.length ? settings?.nostrRelays : [''] | ||||
|           }} | ||||
|           schema={SettingsSchema} | ||||
|           onSubmit={async ({ tipDefault, ...values }) => { | ||||
|             await setSettings({ variables: { tipDefault: Number(tipDefault), ...values } }) | ||||
|           onSubmit={async ({ tipDefault, nostrPubkey, nostrRelays, ...values }) => { | ||||
|             if (nostrPubkey.length === 0) { | ||||
|               nostrPubkey = null | ||||
|             } | ||||
| 
 | ||||
|             const nostrRelaysFiltered = nostrRelays?.filter(word => word.trim().length > 0) | ||||
| 
 | ||||
|             await setSettings({ | ||||
|               variables: { | ||||
|                 tipDefault: Number(tipDefault), | ||||
|                 nostrPubkey, | ||||
|                 nostrRelays: nostrRelaysFiltered, | ||||
|                 ...values | ||||
|               } | ||||
|             }) | ||||
|             setSuccess('settings saved') | ||||
|           }} | ||||
|         > | ||||
| @ -80,10 +106,44 @@ export default function Settings ({ data: { settings } }) { | ||||
|           <Input | ||||
|             label='tip default' | ||||
|             name='tipDefault' | ||||
|             groupClassName='mb-0' | ||||
|             required | ||||
|             autoFocus | ||||
|             append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} | ||||
|             hint={<small className='text-muted'>note: you can also press and hold the lightning bolt to tip custom amounts</small>} | ||||
|           /> | ||||
|           <div className='mb-2'> | ||||
|             <AccordianItem | ||||
|               show={settings?.turboTipping} | ||||
|               header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>advanced</div>} | ||||
|               body={<Checkbox | ||||
|                 name='turboTipping' | ||||
|                 label={ | ||||
|                   <div className='d-flex align-items-center'>turbo tipping | ||||
|                     <Info> | ||||
|                       <ul className='font-weight-bold'> | ||||
|                         <li>Makes every additional bolt click raise your total tip to another 10x multiple of your default tip</li> | ||||
|                         <li>e.g. if your tip default is 10 sats | ||||
|                           <ul> | ||||
|                             <li>1st click: 10 sats total tipped</li> | ||||
|                             <li>2nd click: 100 sats total tipped</li> | ||||
|                             <li>3rd click: 1000 sats total tipped</li> | ||||
|                             <li>4th click: 10000 sats total tipped</li> | ||||
|                             <li>and so on ...</li> | ||||
|                           </ul> | ||||
|                         </li> | ||||
|                         <li>You can still custom tip via long press | ||||
|                           <ul> | ||||
|                             <li>the next bolt click rounds up to the next greatest 10x multiple of your default</li> | ||||
|                           </ul> | ||||
|                         </li> | ||||
|                       </ul> | ||||
|                     </Info> | ||||
|                   </div> | ||||
|                   } | ||||
|                     />} | ||||
|             /> | ||||
|           </div> | ||||
|           <Select | ||||
|             label='fiat currency' | ||||
|             name='fiatCurrency' | ||||
| @ -108,7 +168,7 @@ export default function Settings ({ data: { settings } }) { | ||||
|             groupClassName='mb-0' | ||||
|           /> | ||||
|           <Checkbox | ||||
|             label='my invite links are redeemed' | ||||
|             label='someone joins using my invite or referral links' | ||||
|             name='noteInvites' | ||||
|             groupClassName='mb-0' | ||||
|           /> | ||||
| @ -144,6 +204,11 @@ export default function Settings ({ data: { settings } }) { | ||||
|               </div> | ||||
|             } | ||||
|             name='hideInvoiceDesc' | ||||
|             groupClassName='mb-0' | ||||
|           /> | ||||
|           <Checkbox | ||||
|             label={<>hide me from  <Link href='/top/users' passHref><a>top users</a></Link></>} | ||||
|             name='hideFromTopUsers' | ||||
|           /> | ||||
|           <div className='form-label'>content</div> | ||||
|           <Checkbox | ||||
| @ -174,6 +239,27 @@ export default function Settings ({ data: { settings } }) { | ||||
|             } | ||||
|             name='greeterMode' | ||||
|           /> | ||||
|           <AccordianItem | ||||
|             headerColor='var(--theme-color)' | ||||
|             show={settings?.nostrPubkey} | ||||
|             header={<h4 className='mb-2 text-left'>nostr <small><a href='https://github.com/nostr-protocol/nips/blob/master/05.md' target='_blank' rel='noreferrer'>NIP-05</a></small></h4>} | ||||
|             body={ | ||||
|               <> | ||||
|                 <Input | ||||
|                   label={<>pubkey <small className='text-muted ml-2'>optional</small></>} | ||||
|                   name='nostrPubkey' | ||||
|                   clear | ||||
|                 /> | ||||
|                 <VariableInput | ||||
|                   label={<>relays <small className='text-muted ml-2'>optional</small></>} | ||||
|                   name='nostrRelays' | ||||
|                   clear | ||||
|                   min={0} | ||||
|                   max={MAX_NOSTR_RELAY_NUM} | ||||
|                 /> | ||||
|               </> | ||||
|               } | ||||
|           /> | ||||
|           <div className='d-flex'> | ||||
|             <SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton> | ||||
|           </div> | ||||
|  | ||||
							
								
								
									
										54
									
								
								pages/signup.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								pages/signup.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,54 @@ | ||||
| import { providers, getSession } from 'next-auth/client' | ||||
| import Link from 'next/link' | ||||
| import LayoutCenter from '../components/layout-center' | ||||
| import Login from '../components/login' | ||||
| 
 | ||||
| export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) { | ||||
|   const session = await getSession({ req }) | ||||
| 
 | ||||
|   if (session && res && callbackUrl) { | ||||
|     res.writeHead(302, { | ||||
|       Location: callbackUrl | ||||
|     }) | ||||
|     res.end() | ||||
|     return { props: {} } | ||||
|   } | ||||
| 
 | ||||
|   return { | ||||
|     props: { | ||||
|       providers: await providers({ req, res }), | ||||
|       callbackUrl, | ||||
|       error | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function SignUpHeader () { | ||||
|   return ( | ||||
|     <> | ||||
|       <h3 className='w-100 pb-2'> | ||||
|         Sign up | ||||
|       </h3> | ||||
|       <div className='font-weight-bold text-muted pb-4'>Join 9000+ bitcoiners and start stacking sats today</div> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function SignUpFooter ({ callbackUrl }) { | ||||
|   return ( | ||||
|     <small className='font-weight-bold text-muted pt-4'>Already have an account? <Link href={{ pathname: '/login', query: { callbackUrl } }}>login</Link></small> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export default function SignUp ({ ...props }) { | ||||
|   return ( | ||||
|     <LayoutCenter> | ||||
|       <Login | ||||
|         Header={() => <SignUpHeader />} | ||||
|         Footer={() => <SignUpFooter callbackUrl={props.callbackUrl} />} | ||||
|         text='Sign up' | ||||
|         {...props} | ||||
|       /> | ||||
|     </LayoutCenter> | ||||
|   ) | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user