mute territories

This commit is contained in:
keyan 2023-12-15 12:10:29 -06:00
parent baee771d67
commit 214e863458
16 changed files with 280 additions and 144 deletions

View File

@ -191,8 +191,12 @@ const activeOrMine = (me) => {
export const muteClause = me =>
me ? `NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId")` : ''
const subClause = (sub, num, table) => {
return sub ? `${table ? `"${table}".` : ''}"subName" = $${num}` : ''
const subClause = (sub, num, table, me) => {
return sub
? `${table ? `"${table}".` : ''}"subName" = $${num}`
: me
? `NOT EXISTS (SELECT 1 FROM "MuteSub" WHERE "MuteSub"."userId" = ${me.id} AND "MuteSub"."subName" = ${table ? `"${table}".` : ''}"subName")`
: ''
}
export async function filterClause (me, models, type) {
@ -341,7 +345,7 @@ export default {
${relationClause(type)}
${whereClause(
'"Item".created_at <= $1',
subClause(sub, 4, subClauseTable(type)),
subClause(sub, 4, subClauseTable(type), me),
activeOrMine(me),
await filterClause(me, models, type),
typeClause(type),
@ -366,7 +370,7 @@ export default {
${whereClause(
'"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL',
subClause(sub, 5, subClauseTable(type)),
subClause(sub, 5, subClauseTable(type), me),
typeClause(type),
whenClause(when, 'Item'),
await filterClause(me, models, type),
@ -386,7 +390,7 @@ export default {
${whereClause(
'"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL',
subClause(sub, 5, subClauseTable(type)),
subClause(sub, 5, subClauseTable(type), me),
typeClause(type),
whenClause(when, 'Item'),
await filterClause(me, models, type),
@ -443,7 +447,7 @@ export default {
'"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL',
'"Item".bio = false',
subClause(sub, 3, 'Item', true),
subClause(sub, 3, 'Item', me),
muteClause(me))}
ORDER BY rank DESC
OFFSET $1
@ -460,7 +464,7 @@ export default {
${SELECT}
FROM "Item"
${whereClause(
subClause(sub, 3, 'Item', true),
subClause(sub, 3, 'Item', me),
muteClause(me),
'"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL',
@ -858,6 +862,10 @@ export default {
return null
}
if (item.sub) {
return item.sub
}
return await models.sub.findUnique({ where: { name: item.subName || item.root?.subName } })
},
position: async (item, args, { models }) => {

View File

@ -1,5 +1,4 @@
import { GraphQLError } from 'graphql'
import { dayMonthYearToDate } from '../../lib/time'
// this function makes america more secure apparently
export default async function assertGofacYourself ({ models, headers, ip }) {

View File

@ -65,30 +65,33 @@ export default {
sub: async (parent, { name }, { models, me }) => {
if (!name) return null
if (me && name === 'jobs') {
models.user.update({
where: {
id: me.id
},
data: {
lastCheckedJobs: new Date()
}
}).catch(console.log)
}
return await models.sub.findUnique({
where: {
name
},
include: {
MuteSub: {
where: {
userId: Number(me.id)
}
}
}
})
},
subs: async (parent, args, { models }) => {
subs: async (parent, args, { models, me }) => {
return await models.sub.findMany({
where: {
status: {
not: 'STOPPED'
}
},
include: {
MuteSub: {
where: {
userId: Number(me.id)
}
}
},
orderBy: {
name: 'asc'
}
@ -158,6 +161,22 @@ export default {
queries,
{ models, lnd, hash, hmac, me, enforceFee: sub.billingCost })
return results[1]
},
toggleMuteSub: async (parent, { name }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const lookupData = { userId: Number(me.id), subName: name }
const where = { userId_subName: lookupData }
const existing = await models.muteSub.findUnique({ where })
if (existing) {
await models.muteSub.delete({ where })
return false
} else {
await models.muteSub.create({ data: { ...lookupData } })
return true
}
}
},
Sub: {
@ -166,6 +185,9 @@ export default {
return sub.user
}
return await models.user.findUnique({ where: { id: sub.userId } })
},
meMuteSub: async (sub, args, { models }) => {
return sub.MuteSub.length > 0
}
}
}

View File

@ -13,6 +13,7 @@ export default gql`
billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, hash: String, hmac: String): Sub
paySub(name: String!, hash: String, hmac: String): Sub
toggleMuteSub(name: String!): Boolean!
}
type Sub {
@ -33,5 +34,6 @@ export default gql`
status: String!
moderated: Boolean!
moderatedCount: Int!
meMuteSub: Boolean!
}
`

View File

@ -40,7 +40,7 @@ export function WelcomeBanner () {
setHidden(me?.privates?.hideWelcomeBanner || (!me && window.localStorage.getItem('hideWelcomeBanner')))
}, [me?.privates?.hideWelcomeBanner])
if (hidden) return
if (hidden) return null
return (
<Alert className={styles.banner} key='info' variant='info' onClose={handleClose} dismissible>

View File

@ -197,7 +197,7 @@ export default function Comment ({
/>)}
{topLevel && (
<span className='d-flex ms-auto align-items-center'>
<Share item={item} />
<Share title={item?.title} path={`/items/${item?.id}`} />
</span>
)}
</div>

View File

@ -133,7 +133,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
right={
!noReply &&
<>
<Share item={item} />
<Share title={item?.title} path={`/items/${item?.id}`} />
<Toc text={item.text} />
</>
}

View File

@ -13,7 +13,7 @@ import { useMe } from './me'
import DontLikeThisDropdownItem, { OutlawDropdownItem } from './dont-link-this'
import BookmarkDropdownItem from './bookmark'
import SubscribeDropdownItem from './subscribe'
import { CopyLinkDropdownItem } from './share'
import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share'
import Hat from './hat'
import { AD_USER_ID } from '../lib/constants'
import ActionDropdown from './action-dropdown'
@ -149,11 +149,13 @@ export default function ItemInfo ({
<Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'>
opentimestamp
</Link>}
{me && item?.noteId && (
{item?.noteId && (
<Dropdown.Item onClick={() => window.open(`https://nostr.com/${item.noteId}`, '_blank', 'noopener')}>
nostr note
</Dropdown.Item>
)}
{item?.mine && !item?.noteId &&
<CrosspostDropdownItem item={item} />}
{me && !item.position &&
!item.mine && !item.deletedAt &&
(item.meDontLikeSats > meTotalSats

View File

@ -73,7 +73,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
</div>
{toc &&
<>
<Share item={item} />
<Share title={item?.title} path={`/items/${item?.id}`} />
<Toc text={item.text} />
</>}
</div>

View File

@ -8,42 +8,29 @@ import { useToast } from './toast'
import { SSR } from '../lib/constants'
import { callWithTimeout } from '../lib/nostr'
const getShareUrl = (item, me) => {
const path = `/items/${item?.id}${me ? `/r/${me.name}` : ''}`
const referrurl = (ipath, me) => {
const path = `${ipath}${me ? `/r/${me.name}` : ''}`
if (!SSR) {
return `${window.location.protocol}//${window.location.host}${path}`
}
return `https://stacker.news${path}`
}
export default function Share ({ item }) {
export default function Share ({ path, title, className = '' }) {
const me = useMe()
const crossposter = useCrossposter()
const toaster = useToast()
const url = getShareUrl(item, me)
const mine = item?.user?.id === me?.id
const [updateNoteId] = useMutation(
gql`
mutation updateNoteId($id: ID!, $noteId: String!) {
updateNoteId(id: $id, noteId: $noteId) {
id
noteId
}
}`
)
const url = referrurl(path, me)
return !SSR && navigator?.share
? (
<div className='ms-auto pointer d-flex align-items-center'>
<ShareIcon
width={20} height={20}
className='mx-2 fill-grey theme'
className={`mx-2 fill-grey theme ${className}`}
onClick={async () => {
try {
await navigator.share({
title: item.title || '',
title: title || '',
text: '',
url
})
@ -57,9 +44,8 @@ export default function Share ({ item }) {
: (
<Dropdown align='end' className='ms-auto pointer d-flex align-items-center' as='span'>
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
<ShareIcon width={20} height={20} className='mx-2 fill-grey theme' />
<ShareIcon width={20} height={20} className={`mx-2 fill-grey theme ${className}`} />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
onClick={async () => {
@ -74,43 +60,6 @@ export default function Share ({ item }) {
>
copy link
</Dropdown.Item>
{mine && !item?.noteId && (
<Dropdown.Item
onClick={async () => {
try {
const pubkey = await callWithTimeout(() => window.nostr.getPublicKey(), 5000)
if (!pubkey) {
throw new Error('not available')
}
} catch (e) {
toaster.danger(`Nostr extension error: ${e.message}`)
return
}
try {
if (item?.id) {
const crosspostResult = await crossposter({ ...item })
const noteId = crosspostResult?.noteId
if (noteId) {
await updateNoteId({
variables: {
id: item.id,
noteId
}
})
}
toaster.success('Crosspost successful')
} else {
toaster.warning('Item ID not available')
}
} catch (e) {
console.error(e)
toaster.danger('Crosspost failed')
}
}}
>
crosspost to nostr
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown>)
}
@ -118,7 +67,7 @@ export default function Share ({ item }) {
export function CopyLinkDropdownItem ({ item }) {
const me = useMe()
const toaster = useToast()
const url = getShareUrl(item, me)
const url = referrurl(`/items/${item.id}`, me)
return (
<Dropdown.Item
onClick={async () => {
@ -143,3 +92,56 @@ export function CopyLinkDropdownItem ({ item }) {
</Dropdown.Item>
)
}
export function CrosspostDropdownItem ({ item }) {
const crossposter = useCrossposter()
const toaster = useToast()
const [updateNoteId] = useMutation(
gql`
mutation updateNoteId($id: ID!, $noteId: String!) {
updateNoteId(id: $id, noteId: $noteId) {
id
noteId
}
}`
)
return (
<Dropdown.Item
onClick={async () => {
try {
const pubkey = await callWithTimeout(() => window.nostr.getPublicKey(), 5000)
if (!pubkey) {
throw new Error('not available')
}
} catch (e) {
toaster.danger(`Nostr extension error: ${e.message}`)
return
}
try {
if (item?.id) {
const crosspostResult = await crossposter({ ...item })
const noteId = crosspostResult?.noteId
if (noteId) {
await updateNoteId({
variables: {
id: item.id,
noteId
}
})
}
toaster.success('Crosspost successful')
} else {
toaster.warning('Item ID not available')
}
} catch (e) {
console.error(e)
toaster.danger('Crosspost failed')
}
}}
>
crosspost to nostr
</Dropdown.Item>
)
}

View File

@ -0,0 +1,92 @@
import { Badge, Button, CardFooter } from 'react-bootstrap'
import { AccordianCard } from './accordian-item'
import TerritoryPaymentDue, { TerritoryBillingLine } from './territory-payment-due'
import Link from 'next/link'
import Text from './text'
import { numWithUnits } from '../lib/format'
import styles from './item.module.css'
import Hat from './hat'
import { useMe } from './me'
import Share from './share'
import { gql, useMutation } from '@apollo/client'
import { useToast } from './toast'
export default function TerritoryHeader ({ sub }) {
const me = useMe()
const toaster = useToast()
const [toggleMuteSub] = useMutation(
gql`
mutation toggleMuteSub($name: String!) {
toggleMuteSub(name: $name)
}`, {
update (cache, { data: { toggleMuteSub } }) {
cache.modify({
id: `Sub:{"name":"${sub.name}"}`,
fields: {
meMuteSub: () => toggleMuteSub.meMuteSub
}
})
}
}
)
return (
<>
<TerritoryPaymentDue sub={sub} />
<div className='mb-3'>
<div>
<AccordianCard
header={
<small className='text-muted fw-bold align-items-center d-flex'>
territory details
{sub.status === 'STOPPED' && <Badge className='ms-2' bg='danger'>archived</Badge>}
{(sub.moderated || sub.moderatedCount > 0) && <Badge className='ms-2' bg='secondary'>moderated{sub.moderatedCount && ` ${sub.moderatedCount}`}</Badge>}
</small>
}
>
<div className='py-2'>
<Text>{sub.desc}</Text>
</div>
<CardFooter className={`py-1 ${styles.other}`}>
<div className='text-muted'>
<span>founded by </span>
<Link href={`/${sub.user.name}`}>
@{sub.user.name}<span> </span><Hat className='fill-grey' user={sub.user} height={12} width={12} />
</Link>
</div>
<div className='text-muted'>
<span>post cost </span>
<span className='fw-bold'>{numWithUnits(sub.baseCost)}</span>
</div>
<TerritoryBillingLine sub={sub} />
</CardFooter>
</AccordianCard>
</div>
<div className='d-flex my-2 justify-content-end'>
<Share path={`/~${sub.name}`} title={`~${sub.name} stacker news territory`} className='mx-3' />
{Number(sub.userId) === Number(me?.id)
? (
<Link href={`/~${sub.name}/edit`} className='d-flex align-items-center'>
<Button variant='outline-grey border-2 rounded py-0' size='sm'>edit territory</Button>
</Link>)
: (
<Button
variant='outline-grey border-2 py-0 rounded'
size='sm'
onClick={async () => {
try {
await toggleMuteSub({ variables: { name: sub.name } })
} catch {
toaster.danger(`failed to ${sub.meMuteSub ? 'join' : 'mute'} territory`)
return
}
toaster.success(`${sub.meMuteSub ? 'joined' : 'muted'} territory`)
}}
>{sub.meMuteSub ? 'join' : 'mute'} territory
</Button>)}
</div>
</div>
</>
)
}

View File

@ -18,6 +18,7 @@ export const SUB_FIELDS = gql`
status
moderated
moderatedCount
meMuteSub
}`
export const SUB_FULL_FIELDS = gql`

View File

@ -5,19 +5,9 @@ import Layout from '../../components/layout'
import { SUB_FULL, SUB_ITEMS } from '../../fragments/subs'
import Snl from '../../components/snl'
import { WelcomeBanner } from '../../components/banners'
import { AccordianCard } from '../../components/accordian-item'
import Text from '../../components/text'
import { useMe } from '../../components/me'
import Gear from '../../svgs/settings-5-fill.svg'
import Link from 'next/link'
import { useQuery } from '@apollo/client'
import PageLoading from '../../components/page-loading'
import CardFooter from 'react-bootstrap/CardFooter'
import Hat from '../../components/hat'
import styles from '../../components/item.module.css'
import TerritoryPaymentDue, { TerritoryBillingLine } from '../../components/territory-payment-due'
import Badge from 'react-bootstrap/Badge'
import { numWithUnits } from '../../lib/format'
import TerritoryHeader from '../../components/territory-header'
export const getServerSideProps = getGetServerSideProps({
query: SUB_ITEMS,
@ -26,7 +16,6 @@ export const getServerSideProps = getGetServerSideProps({
export default function Sub ({ ssrData }) {
const router = useRouter()
const me = useMe()
const variables = { ...router.query }
const { data } = useQuery(SUB_FULL, { variables })
@ -36,44 +25,7 @@ export default function Sub ({ ssrData }) {
return (
<Layout sub={variables.sub}>
{sub
? (
<>
<TerritoryPaymentDue sub={sub} />
<div className='mb-3 d-flex'>
<div className='flex-grow-1'>
<AccordianCard
header={
<small className='text-muted fw-bold align-items-center d-flex'>
territory details
{sub.status === 'STOPPED' && <Badge className='ms-2' bg='danger'>archived</Badge>}
{(sub.moderated || sub.moderatedCount) && <Badge className='ms-2' bg='secondary'>moderated{sub.moderatedCount && ` ${sub.moderatedCount}`}</Badge>}
</small>
}
>
<div className='py-2'>
<Text>{sub.desc}</Text>
</div>
<CardFooter className={`py-1 ${styles.other}`}>
<div className='text-muted'>
<span>founded by </span>
<Link href={`/${sub.user.name}`}>
@{sub.user.name}<span> </span><Hat className='fill-grey' user={sub.user} height={12} width={12} />
</Link>
</div>
<div className='text-muted'>
<span>post cost </span>
<span className='fw-bold'>{numWithUnits(sub.baseCost)}</span>
</div>
<TerritoryBillingLine sub={sub} />
</CardFooter>
</AccordianCard>
</div>
{Number(sub.userId) === Number(me?.id) &&
<Link href={`/~${sub.name}/edit`} className='d-flex align-items-center flex-shrink-1 ps-2'>
<Gear className='fill-grey' width={22} height={22} />
</Link>}
</div>
</>)
? <TerritoryHeader sub={sub} />
: (
<>
<Snl />

View File

@ -0,0 +1,36 @@
/*
Warnings:
- You are about to drop the `Subscription` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Subscription" DROP CONSTRAINT "Subscription_subName_fkey";
-- DropForeignKey
ALTER TABLE "Subscription" DROP CONSTRAINT "Subscription_userId_fkey";
-- DropTable
DROP TABLE "Subscription";
-- CreateTable
CREATE TABLE "MuteSub" (
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"subName" CITEXT NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "MuteSub_pkey" PRIMARY KEY ("userId","subName")
);
-- CreateIndex
CREATE INDEX "MuteSub_subName_idx" ON "MuteSub"("subName");
-- CreateIndex
CREATE INDEX "MuteSub_created_at_idx" ON "MuteSub"("created_at");
-- AddForeignKey
ALTER TABLE "MuteSub" ADD CONSTRAINT "MuteSub_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MuteSub" ADD CONSTRAINT "MuteSub_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -77,7 +77,6 @@ model User {
PushSubscriptions PushSubscription[]
ReferralAct ReferralAct[]
Streak Streak[]
Subscriptions Subscription[]
ThreadSubscriptions ThreadSubscription[]
Upload Upload[] @relation("Uploads")
nostrRelays UserNostrRelay[]
@ -102,6 +101,7 @@ model User {
ArcIn Arc[] @relation("toUser")
Sub Sub[]
SubAct SubAct[]
MuteSub MuteSub[]
@@index([photoId])
@@index([createdAt], map: "users.created_at_index")
@ -419,12 +419,12 @@ model Sub {
moderated Boolean @default(false)
moderatedCount Int @default(0)
parent Sub? @relation("ParentChildren", fields: [parentName], references: [name])
children Sub[] @relation("ParentChildren")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
Item Item[]
Subscription Subscription[]
SubAct SubAct[]
parent Sub? @relation("ParentChildren", fields: [parentName], references: [name])
children Sub[] @relation("ParentChildren")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
Item Item[]
SubAct SubAct[]
MuteSub MuteSub[]
@@index([parentName])
@@index([createdAt])
@ -451,14 +451,17 @@ model SubAct {
@@index([userId, createdAt, type])
}
model Subscription {
id Int @id @default(autoincrement())
model MuteSub {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
subName String @db.Citext
userId Int
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, subName])
@@index([subName])
@@index([createdAt])
}
model Pin {

View File

@ -187,6 +187,23 @@ $accordion-button-active-icon-dark: $accordion-button-icon;
}
}
.btn-outline-grey {
--bs-btn-color: var(--theme-grey);
--bs-btn-border-color: var(--theme-grey);
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: var(--theme-grey);
--bs-btn-hover-border-color: var(--theme-grey);
--bs-btn-focus-shadow-rgb: 233, 236, 239;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: var(--theme-grey);
--bs-btn-active-border-color: var(--theme-grey);
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: var(--theme-grey);
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: var(--theme-grey);
--bs-gradient: none;
}
.text-monospace {
font-family: monospace;
}