From d42be758b8da88ad519232376bd5df4190691a60 Mon Sep 17 00:00:00 2001
From: Soxasora <sora@soxa.dev>
Date: Fri, 27 Dec 2024 16:26:12 +0100
Subject: [PATCH 1/7] feat: comment fee control

---
 api/paidAction/itemCreate.js                  | 28 +++++++++++++++++--
 api/typeDefs/sub.js                           |  2 ++
 components/reply.js                           |  3 +-
 components/territory-form.js                  |  8 ++++++
 components/territory-header.js                |  4 +++
 fragments/items.js                            |  1 +
 fragments/paidAction.js                       |  8 +++---
 fragments/subs.js                             |  1 +
 lib/validate.js                               |  3 ++
 .../migration.sql                             |  2 ++
 prisma/schema.prisma                          |  1 +
 11 files changed, 54 insertions(+), 7 deletions(-)
 create mode 100644 prisma/migrations/20241227132729_comment_fee_control/migration.sql

diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js
index 9f6a23fb3..60769fe10 100644
--- a/api/paidAction/itemCreate.js
+++ b/api/paidAction/itemCreate.js
@@ -12,8 +12,32 @@ export const paymentMethods = [
 ]
 
 export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
-  const sub = (parentId || bio) ? null : await models.sub.findUnique({ where: { name: subName } })
-  const baseCost = sub ? satsToMsats(sub.baseCost) : 1000n
+  const BIO_COST = 1000n
+  async function findRootItem (models, parentId) {
+    const item = await models.item.findFirst({
+      where: { id: Number(parentId) },
+      select: { rootId: true }
+    })
+
+    return models.item.findFirst({
+      where: { id: Number(item.rootId || parentId) },
+      include: { sub: true }
+    })
+  }
+  async function getBaseCost ({ models, bio, parentId, subName }) {
+    if (bio) return BIO_COST
+
+    if (parentId) {
+      const rootItem = await findRootItem(models, parentId)
+      return rootItem.bio ? BIO_COST : satsToMsats(rootItem.sub?.replyCost)
+    }
+
+    const sub = await models.sub.findUnique({ where: { name: subName } })
+    return satsToMsats(sub.baseCost)
+  }
+  const baseCost = await getBaseCost({ models, bio, parentId, subName })
+  // const sub = (parentId || bio) ? null : await models.sub.findUnique({ where: { name: subName } })
+  // const baseCost = sub ? satsToMsats(sub.baseCost) : 1000n
 
   // cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + upload fees + boost
   const [{ cost }] = await models.$queryRaw`
diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js
index 29fc8c7f5..4d550a413 100644
--- a/api/typeDefs/sub.js
+++ b/api/typeDefs/sub.js
@@ -16,6 +16,7 @@ export default gql`
 
   extend type Mutation {
     upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!,
+      replyCost: Int!,
       postTypes: [String!]!,
       billingType: String!, billingAutoRenew: Boolean!,
       moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
@@ -45,6 +46,7 @@ export default gql`
     billedLastAt: Date!
     billPaidUntil: Date
     baseCost: Int!
+    replyCost: Int!
     status: String!
     moderated: Boolean!
     moderatedCount: Int!
diff --git a/components/reply.js b/components/reply.js
index 71427b471..c5aab3253 100644
--- a/components/reply.js
+++ b/components/reply.js
@@ -45,6 +45,7 @@ export default forwardRef(function Reply ({
   const replyInput = useRef(null)
   const showModal = useShowModal()
   const root = useRoot()
+  const bio = root?.bio
   const sub = item?.sub || root?.sub
 
   useEffect(() => {
@@ -173,7 +174,7 @@ export default forwardRef(function Reply ({
       {reply &&
         <div className={styles.reply}>
           <FeeButtonProvider
-            baseLineItems={postCommentBaseLineItems({ baseCost: 1, comment: true, me: !!me })}
+            baseLineItems={postCommentBaseLineItems({ baseCost: bio ? 1 : sub.replyCost, comment: true, me: !!me })}
             useRemoteLineItems={postCommentUseRemoteLineItems({ parentId: item.id, me: !!me })}
           >
             <Form
diff --git a/components/territory-form.js b/components/territory-form.js
index 55ce75f25..0d6347a85 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -91,6 +91,7 @@ export default function TerritoryForm ({ sub }) {
           name: sub?.name || '',
           desc: sub?.desc || '',
           baseCost: sub?.baseCost || 10,
+          replyCost: sub?.replyCost || 1,
           postTypes: sub?.postTypes || POST_TYPES,
           billingType: sub?.billingType || 'MONTHLY',
           billingAutoRenew: sub?.billingAutoRenew || false,
@@ -136,6 +137,13 @@ export default function TerritoryForm ({ sub }) {
           required
           append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
         />
+        <Input
+          label='reply cost'
+          name='replyCost'
+          type='number'
+          required
+          append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
+        />
         <CheckboxGroup label='post types' name='postTypes'>
           <Row>
             <Col xs={4} sm='auto'>
diff --git a/components/territory-header.js b/components/territory-header.js
index 566a8afce..a53a99e8c 100644
--- a/components/territory-header.js
+++ b/components/territory-header.js
@@ -61,6 +61,10 @@ export function TerritoryInfo ({ sub }) {
           <span>post cost </span>
           <span className='fw-bold'>{numWithUnits(sub.baseCost)}</span>
         </div>
+        <div className='text-muted'>
+          <span>reply cost </span>
+          <span className='fw-bold'>{numWithUnits(sub.replyCost)}</span>
+        </div>
         <TerritoryBillingLine sub={sub} />
       </CardFooter>
     </>
diff --git a/fragments/items.js b/fragments/items.js
index 6e1a9f407..66b1ef86e 100644
--- a/fragments/items.js
+++ b/fragments/items.js
@@ -34,6 +34,7 @@ export const ITEM_FIELDS = gql`
       meMuteSub
       meSubscription
       nsfw
+      replyCost
     }
     otsHash
     position
diff --git a/fragments/paidAction.js b/fragments/paidAction.js
index c47fa7005..a086887ff 100644
--- a/fragments/paidAction.js
+++ b/fragments/paidAction.js
@@ -250,10 +250,10 @@ export const UPDATE_COMMENT = gql`
 export const UPSERT_SUB = gql`
   ${PAID_ACTION}
   mutation upsertSub($oldName: String, $name: String!, $desc: String, $baseCost: Int!,
-    $postTypes: [String!]!, $billingType: String!,
+    $replyCost: Int!, $postTypes: [String!]!, $billingType: String!,
     $billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) {
       upsertSub(oldName: $oldName, name: $name, desc: $desc, baseCost: $baseCost,
-        postTypes: $postTypes, billingType: $billingType,
+        replyCost: $replyCost, postTypes: $postTypes, billingType: $billingType,
         billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) {
       result {
         name
@@ -265,10 +265,10 @@ export const UPSERT_SUB = gql`
 export const UNARCHIVE_TERRITORY = gql`
   ${PAID_ACTION}
   mutation unarchiveTerritory($name: String!, $desc: String, $baseCost: Int!,
-    $postTypes: [String!]!, $billingType: String!,
+    $replyCost: Int!, $postTypes: [String!]!, $billingType: String!,
     $billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) {
       unarchiveTerritory(name: $name, desc: $desc, baseCost: $baseCost,
-        postTypes: $postTypes, billingType: $billingType,
+        replyCost: $replyCost, postTypes: $postTypes, billingType: $billingType,
         billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) {
       result {
         name
diff --git a/fragments/subs.js b/fragments/subs.js
index 8b84f8d78..1ff2c492e 100644
--- a/fragments/subs.js
+++ b/fragments/subs.js
@@ -25,6 +25,7 @@ export const SUB_FIELDS = gql`
     billedLastAt
     billPaidUntil
     baseCost
+    replyCost
     userId
     desc
     status
diff --git a/lib/validate.js b/lib/validate.js
index 86b03bded..fc238e181 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -317,6 +317,9 @@ export function territorySchema (args) {
     baseCost: intValidator
       .min(1, 'must be at least 1')
       .max(100000, 'must be at most 100k'),
+    replyCost: intValidator
+      .min(1, 'must be at least 1')
+      .max(100000, 'must be at most 100k'),
     postTypes: array().of(string().oneOf(POST_TYPES)).min(1, 'must support at least one post type'),
     billingType: string().required('required').oneOf(TERRITORY_BILLING_TYPES, 'required'),
     nsfw: boolean()
diff --git a/prisma/migrations/20241227132729_comment_fee_control/migration.sql b/prisma/migrations/20241227132729_comment_fee_control/migration.sql
new file mode 100644
index 000000000..241212a0b
--- /dev/null
+++ b/prisma/migrations/20241227132729_comment_fee_control/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Sub" ADD COLUMN     "replyCost" INTEGER NOT NULL DEFAULT 1;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 59685b931..5063a3c98 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -729,6 +729,7 @@ model Sub {
   rankingType      RankingType
   allowFreebies    Boolean     @default(true)
   baseCost         Int         @default(1)
+  replyCost        Int         @default(1)
   rewardsPct       Int         @default(50)
   desc             String?
   status           Status      @default(ACTIVE)

From f16b31c65218526deae6334e79ab7b4bbf0e1ef8 Mon Sep 17 00:00:00 2001
From: Soxasora <sora@soxa.dev>
Date: Fri, 27 Dec 2024 17:30:55 +0100
Subject: [PATCH 2/7] update typeDefs for unarchiving territories

---
 api/typeDefs/sub.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js
index 4d550a413..a7b00aeef 100644
--- a/api/typeDefs/sub.js
+++ b/api/typeDefs/sub.js
@@ -25,7 +25,7 @@ export default gql`
     toggleSubSubscription(name: String!): Boolean!
     transferTerritory(subName: String!, userName: String!): Sub
     unarchiveTerritory(name: String!, desc: String, baseCost: Int!,
-      postTypes: [String!]!,
+      replyCost: Int!, postTypes: [String!]!,
       billingType: String!, billingAutoRenew: Boolean!,
       moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
   }

From 72ed633f504033a42818a84aab7736a8253ac241 Mon Sep 17 00:00:00 2001
From: Soxasora <sora@soxa.dev>
Date: Sun, 29 Dec 2024 11:45:47 +0100
Subject: [PATCH 3/7] review: move functions to top level; consider saloon
 items

---
 api/paidAction/itemCreate.js | 45 ++++++++++++++++++------------------
 components/reply.js          |  2 +-
 2 files changed, 24 insertions(+), 23 deletions(-)

diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js
index 60769fe10..238323eb5 100644
--- a/api/paidAction/itemCreate.js
+++ b/api/paidAction/itemCreate.js
@@ -11,33 +11,34 @@ export const paymentMethods = [
   PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
 ]
 
-export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
-  const BIO_COST = 1000n
-  async function findRootItem (models, parentId) {
-    const item = await models.item.findFirst({
-      where: { id: Number(parentId) },
-      select: { rootId: true }
-    })
+export const BIO_COST = 1000n
 
-    return models.item.findFirst({
-      where: { id: Number(item.rootId || parentId) },
-      include: { sub: true }
-    })
-  }
-  async function getBaseCost ({ models, bio, parentId, subName }) {
-    if (bio) return BIO_COST
+export async function findRootItem (models, parentId) {
+  const item = await models.item.findFirst({
+    where: { id: Number(parentId) },
+    select: { rootId: true }
+  })
 
-    if (parentId) {
-      const rootItem = await findRootItem(models, parentId)
-      return rootItem.bio ? BIO_COST : satsToMsats(rootItem.sub?.replyCost)
-    }
+  return models.item.findFirst({
+    where: { id: Number(item.rootId || parentId) },
+    include: { sub: true }
+  })
+}
 
-    const sub = await models.sub.findUnique({ where: { name: subName } })
-    return satsToMsats(sub.baseCost)
+export async function getBaseCost ({ models, bio, parentId, subName }) {
+  if (bio) return BIO_COST
+
+  if (parentId) {
+    const rootItem = await findRootItem(models, parentId)
+    return rootItem.bio ? BIO_COST : satsToMsats(rootItem.sub?.replyCost)
   }
+
+  const sub = await models.sub.findUnique({ where: { name: subName } })
+  return satsToMsats(sub.baseCost)
+}
+
+export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
   const baseCost = await getBaseCost({ models, bio, parentId, subName })
-  // const sub = (parentId || bio) ? null : await models.sub.findUnique({ where: { name: subName } })
-  // const baseCost = sub ? satsToMsats(sub.baseCost) : 1000n
 
   // cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + upload fees + boost
   const [{ cost }] = await models.$queryRaw`
diff --git a/components/reply.js b/components/reply.js
index c5aab3253..093059048 100644
--- a/components/reply.js
+++ b/components/reply.js
@@ -174,7 +174,7 @@ export default forwardRef(function Reply ({
       {reply &&
         <div className={styles.reply}>
           <FeeButtonProvider
-            baseLineItems={postCommentBaseLineItems({ baseCost: bio ? 1 : sub.replyCost, comment: true, me: !!me })}
+            baseLineItems={postCommentBaseLineItems({ baseCost: bio || !sub ? 1 : sub.replyCost, comment: true, me: !!me })}
             useRemoteLineItems={postCommentUseRemoteLineItems({ parentId: item.id, me: !!me })}
           >
             <Form

From 06e642f9e183016b3f9baf6b0187c200d74bb320 Mon Sep 17 00:00:00 2001
From: Soxasora <sora@soxa.dev>
Date: Sun, 29 Dec 2024 11:48:56 +0100
Subject: [PATCH 4/7] ux: cleaner post/reply cost section

---
 components/territory-header.js | 17 ++++++++++-------
 1 file changed, 10 insertions(+), 7 deletions(-)

diff --git a/components/territory-header.js b/components/territory-header.js
index a53a99e8c..8d64f0144 100644
--- a/components/territory-header.js
+++ b/components/territory-header.js
@@ -57,13 +57,16 @@ export function TerritoryInfo ({ sub }) {
           <span> on </span>
           <span className='fw-bold'>{new Date(sub.createdAt).toDateString()}</span>
         </div>
-        <div className='text-muted'>
-          <span>post cost </span>
-          <span className='fw-bold'>{numWithUnits(sub.baseCost)}</span>
-        </div>
-        <div className='text-muted'>
-          <span>reply cost </span>
-          <span className='fw-bold'>{numWithUnits(sub.replyCost)}</span>
+        <div className='d-flex'>
+          <div className='text-muted'>
+            <span>post cost </span>
+            <span className='fw-bold'>{numWithUnits(sub.baseCost)}</span>
+          </div>
+          <span className='px-1'> \ </span>
+          <div className='text-muted'>
+            <span>reply cost </span>
+            <span className='fw-bold'>{numWithUnits(sub.replyCost)}</span>
+          </div>
         </div>
         <TerritoryBillingLine sub={sub} />
       </CardFooter>

From c6a323ef636a5e96827c52f9e7e060c5174d6919 Mon Sep 17 00:00:00 2001
From: Soxasora <sora@soxa.dev>
Date: Sun, 29 Dec 2024 21:27:10 +0100
Subject: [PATCH 5/7] hotfix: handle salon replies

---
 api/paidAction/itemCreate.js | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js
index 238323eb5..c640f0c61 100644
--- a/api/paidAction/itemCreate.js
+++ b/api/paidAction/itemCreate.js
@@ -11,7 +11,7 @@ export const paymentMethods = [
   PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
 ]
 
-export const BIO_COST = 1000n
+export const ITEM_COST = 1000n
 
 export async function findRootItem (models, parentId) {
   const item = await models.item.findFirst({
@@ -26,11 +26,13 @@ export async function findRootItem (models, parentId) {
 }
 
 export async function getBaseCost ({ models, bio, parentId, subName }) {
-  if (bio) return BIO_COST
+  if (bio) return ITEM_COST
 
   if (parentId) {
     const rootItem = await findRootItem(models, parentId)
-    return rootItem.bio ? BIO_COST : satsToMsats(rootItem.sub?.replyCost)
+
+    if (rootItem.bio || !rootItem.sub) return ITEM_COST
+    return satsToMsats(rootItem.sub.replyCost)
   }
 
   const sub = await models.sub.findUnique({ where: { name: subName } })

From a51f2ff4de0f1a6df0a9111a80a931e9e39f3355 Mon Sep 17 00:00:00 2001
From: k00b <k00b@stacker.news>
Date: Thu, 6 Feb 2025 20:17:48 -0600
Subject: [PATCH 6/7] bios don't have subs + simplify root query

---
 api/paidAction/itemCreate.js | 31 ++++++++++++++-----------------
 components/reply.js          |  3 +--
 2 files changed, 15 insertions(+), 19 deletions(-)

diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js
index a7f0418f9..be7a77d60 100644
--- a/api/paidAction/itemCreate.js
+++ b/api/paidAction/itemCreate.js
@@ -13,28 +13,25 @@ export const paymentMethods = [
   PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
 ]
 
-export const ITEM_COST = 1000n
-
-export async function findRootItem (models, parentId) {
-  const item = await models.item.findFirst({
-    where: { id: Number(parentId) },
-    select: { rootId: true }
-  })
-
-  return models.item.findFirst({
-    where: { id: Number(item.rootId || parentId) },
-    include: { sub: true }
-  })
-}
+export const DEFAULT_ITEM_COST = 1000n
 
 export async function getBaseCost ({ models, bio, parentId, subName }) {
-  if (bio) return ITEM_COST
+  if (bio) return DEFAULT_ITEM_COST
 
   if (parentId) {
-    const rootItem = await findRootItem(models, parentId)
+    // the subname is stored in the root item of the thread
+    const parent = await models.item.findFirst({
+      where: { id: Number(parentId) },
+      include: {
+        root: { include: { sub: true } },
+        sub: true
+      }
+    })
+
+    const root = parent.root ?? parent
 
-    if (rootItem.bio || !rootItem.sub) return ITEM_COST
-    return satsToMsats(rootItem.sub.replyCost)
+    if (!root.sub) return DEFAULT_ITEM_COST
+    return satsToMsats(root.sub.replyCost)
   }
 
   const sub = await models.sub.findUnique({ where: { name: subName } })
diff --git a/components/reply.js b/components/reply.js
index da79fdaf2..ec12d0a5f 100644
--- a/components/reply.js
+++ b/components/reply.js
@@ -28,7 +28,6 @@ export default forwardRef(function Reply ({
   const replyInput = useRef(null)
   const showModal = useShowModal()
   const root = useRoot()
-  const bio = root?.bio
   const sub = item?.sub || root?.sub
 
   useEffect(() => {
@@ -162,7 +161,7 @@ export default forwardRef(function Reply ({
       {reply &&
         <div className={styles.reply}>
           <FeeButtonProvider
-            baseLineItems={postCommentBaseLineItems({ baseCost: bio || !sub ? 1 : sub.replyCost, comment: true, me: !!me })}
+            baseLineItems={postCommentBaseLineItems({ baseCost: sub?.replyCost ?? 1, comment: true, me: !!me })}
             useRemoteLineItems={postCommentUseRemoteLineItems({ parentId: item.id, me: !!me })}
           >
             <Form

From dae5430c82a468feb2a4dc3b6c1331b340c5a819 Mon Sep 17 00:00:00 2001
From: k00b <k00b@stacker.news>
Date: Thu, 6 Feb 2025 20:18:03 -0600
Subject: [PATCH 7/7] move reply cost to accordian

---
 components/territory-form.js | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/components/territory-form.js b/components/territory-form.js
index 0d6347a85..0983002c8 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -137,13 +137,6 @@ export default function TerritoryForm ({ sub }) {
           required
           append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
         />
-        <Input
-          label='reply cost'
-          name='replyCost'
-          type='number'
-          required
-          append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
-        />
         <CheckboxGroup label='post types' name='postTypes'>
           <Row>
             <Col xs={4} sm='auto'>
@@ -242,6 +235,13 @@ export default function TerritoryForm ({ sub }) {
           header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
           body={
             <>
+              <Input
+                label='reply cost'
+                name='replyCost'
+                type='number'
+                required
+                append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
+              />
               <BootstrapForm.Label>moderation</BootstrapForm.Label>
               <Checkbox
                 inline