better link rel attr handling

This commit is contained in:
keyan 2024-03-04 19:20:14 -06:00
parent 0a0bfbbb37
commit b16234630b
13 changed files with 50 additions and 37 deletions

View File

@ -9,7 +9,7 @@ import {
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD, ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
ANON_USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST, ANON_USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL
} from '../../lib/constants' } from '../../lib/constants'
import { msatsToSats } from '../../lib/format' import { msatsToSats } from '../../lib/format'
import { parse } from 'tldts' import { parse } from 'tldts'
@ -1146,6 +1146,11 @@ export default {
} }
return item.outlawed || item.weightedVotes - item.weightedDownVotes <= -ITEM_FILTER_THRESHOLD return item.outlawed || item.weightedVotes - item.weightedDownVotes <= -ITEM_FILTER_THRESHOLD
}, },
rel: async (item, args, { me, models }) => {
const sats = item.msats ? msatsToSats(item.msats) : 0
const boost = item.boost ?? 0
return (sats + boost < NOFOLLOW_LIMIT) ? UNKNOWN_LINK_REL : 'noopener noreferrer'
},
mine: async (item, args, { me, models }) => { mine: async (item, args, { me, models }) => {
return me?.id === item.userId return me?.id === item.userId
}, },

View File

@ -123,6 +123,7 @@ export default gql`
parentOtsHash: String parentOtsHash: String
forwards: [ItemForward] forwards: [ItemForward]
imgproxyUrls: JSONObject imgproxyUrls: JSONObject
rel: String
} }
input ItemForwardInput { input ItemForwardInput {

View File

@ -9,7 +9,7 @@ import Eye from '../svgs/eye-fill.svg'
import EyeClose from '../svgs/eye-close-line.svg' import EyeClose from '../svgs/eye-close-line.svg'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import CommentEdit from './comment-edit' import CommentEdit from './comment-edit'
import { ANON_USER_ID, COMMENT_DEPTH_LIMIT, NOFOLLOW_LIMIT } from '../lib/constants' import { ANON_USER_ID, COMMENT_DEPTH_LIMIT, UNKNOWN_LINK_REL } from '../lib/constants'
import { ignoreClick } from '../lib/clicks' import { ignoreClick } from '../lib/clicks'
import PayBounty from './pay-bounty' import PayBounty from './pay-bounty'
import BountyIcon from '../svgs/bounty-bag.svg' import BountyIcon from '../svgs/bounty-bag.svg'
@ -212,7 +212,7 @@ export default function Comment ({
{item.searchText {item.searchText
? <SearchText text={item.searchText} /> ? <SearchText text={item.searchText} />
: ( : (
<Text itemId={item.id} topLevel={topLevel} nofollow={item.sats + item.boost < NOFOLLOW_LIMIT} imgproxyUrls={item.imgproxyUrls}> <Text itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} imgproxyUrls={item.imgproxyUrls}>
{item.outlawed && !me?.privates?.wildWestMode {item.outlawed && !me?.privates?.wildWestMode
? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*' ? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*'
: truncate ? truncateString(item.text) : item.text} : truncate ? truncateString(item.text) : item.text}

View File

@ -4,7 +4,7 @@ import { IMGPROXY_URL_REGEXP } from '../lib/url'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { useMe } from './me' import { useMe } from './me'
import { Dropdown } from 'react-bootstrap' import { Dropdown } from 'react-bootstrap'
import { UPLOAD_TYPES_ALLOW } from '../lib/constants' import { UNKNOWN_LINK_REL, UPLOAD_TYPES_ALLOW } from '../lib/constants'
import { useToast } from './toast' import { useToast } from './toast'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { useMutation } from '@apollo/client' import { useMutation } from '@apollo/client'
@ -19,7 +19,7 @@ export function decodeOriginalUrl (imgproxyUrl) {
return originalUrl return originalUrl
} }
function ImageOriginal ({ src, topLevel, nofollow, tab, children, onClick, ...props }) { function ImageOriginal ({ src, topLevel, rel, tab, children, onClick, ...props }) {
const me = useMe() const me = useMe()
const [showImage, setShowImage] = useState(false) const [showImage, setShowImage] = useState(false)
@ -52,9 +52,10 @@ function ImageOriginal ({ src, topLevel, nofollow, tab, children, onClick, ...pr
// This will not be the case if [text](url) format is used. Then we will show what was chosen as text. // This will not be the case if [text](url) format is used. Then we will show what was chosen as text.
const isRawURL = /^https?:\/\//.test(children?.[0]) const isRawURL = /^https?:\/\//.test(children?.[0])
return ( return (
// eslint-disable-next-line
<a <a
target='_blank' target='_blank'
rel={`noreferrer ${nofollow ? 'nofollow' : ''} noopener`} rel={rel ?? UNKNOWN_LINK_REL}
href={src} href={src}
>{isRawURL ? src : children} >{isRawURL ? src : children}
</a> </a>
@ -118,7 +119,7 @@ export default function ZoomableImage ({ src, srcSet, ...props }) {
overflow: ( overflow: (
<Dropdown.Item <Dropdown.Item
href={originalUrl} target='_blank' href={originalUrl} target='_blank'
rel={`noreferrer ${props.nofollow ? 'nofollow' : ''} noopener`} rel={props.rel ?? UNKNOWN_LINK_REL}
> >
open original open original
</Dropdown.Item>) </Dropdown.Item>)

View File

@ -7,7 +7,6 @@ import ZoomableImage from './image'
import Comments from './comments' import Comments from './comments'
import styles from '../styles/item.module.css' import styles from '../styles/item.module.css'
import itemStyles from './item.module.css' import itemStyles from './item.module.css'
import { NOFOLLOW_LIMIT } from '../lib/constants'
import { useMe } from './me' import { useMe } from './me'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import { TwitterTweetEmbed } from 'react-twitter-embed' import { TwitterTweetEmbed } from 'react-twitter-embed'
@ -26,6 +25,7 @@ import { RootProvider } from './root'
import { IMGPROXY_URL_REGEXP } from '../lib/url' import { IMGPROXY_URL_REGEXP } from '../lib/url'
import { numWithUnits } from '../lib/format' import { numWithUnits } from '../lib/format'
import { useQuoteReply } from './use-quote-reply' import { useQuoteReply } from './use-quote-reply'
import { UNKNOWN_LINK_REL } from '../lib/constants'
function BioItem ({ item, handleClick }) { function BioItem ({ item, handleClick }) {
const me = useMe() const me = useMe()
@ -99,7 +99,7 @@ function ItemEmbed ({ item }) {
} }
if (item.url?.match(IMGPROXY_URL_REGEXP)) { if (item.url?.match(IMGPROXY_URL_REGEXP)) {
return <ZoomableImage src={item.url} /> return <ZoomableImage src={item.url} rel={item.rel ?? UNKNOWN_LINK_REL} />
} }
return null return null
@ -171,7 +171,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
function ItemText ({ item }) { function ItemText ({ item }) {
return item.searchText return item.searchText
? <SearchText text={item.searchText} /> ? <SearchText text={item.searchText} />
: <Text itemId={item.id} topLevel nofollow={item.sats + item.boost < NOFOLLOW_LIMIT} imgproxyUrls={item.imgproxyUrls}>{item.text}</Text> : <Text itemId={item.id} topLevel rel={item.rel ?? UNKNOWN_LINK_REL} imgproxyUrls={item.imgproxyUrls}>{item.text}</Text>
} }
export default function ItemFull ({ item, bio, rank, ...props }) { export default function ItemFull ({ item, bio, rank, ...props }) {

View File

@ -25,7 +25,7 @@ import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header'
export default function ItemInfo ({ export default function ItemInfo ({
item, full, commentsText = 'comments', item, full, commentsText = 'comments',
commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText, commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText,
onQuoteReply, nofollow, extraBadges, nested, pinnable onQuoteReply, extraBadges, nested, pinnable
}) { }) {
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
const me = useMe() const me = useMe()
@ -79,7 +79,6 @@ export default function ItemInfo ({
<span> \ </span> <span> \ </span>
</>} </>}
<Link <Link
rel={nofollow}
href={`/items/${item.id}`} onClick={(e) => { href={`/items/${item.id}`} onClick={(e) => {
const viewedAt = commentsViewedAt(item) const viewedAt = commentsViewedAt(item)
if (viewedAt) { if (viewedAt) {
@ -107,7 +106,7 @@ export default function ItemInfo ({
{embellishUser} {embellishUser}
</Link> </Link>
<span> </span> <span> </span>
<Link rel={nofollow} href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning> <Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
{timeSince(new Date(item.createdAt))} {timeSince(new Date(item.createdAt))}
</Link> </Link>
{item.prior && {item.prior &&
@ -161,7 +160,7 @@ export default function ItemInfo ({
opentimestamp opentimestamp
</Link>} </Link>}
{item?.noteId && ( {item?.noteId && (
<Dropdown.Item onClick={() => window.open(`https://nostr.com/${item.noteId}`, '_blank', 'noopener')}> <Dropdown.Item onClick={() => window.open(`https://nostr.com/${item.noteId}`, '_blank', 'noopener,noreferrer,nofollow')}>
nostr note nostr note
</Dropdown.Item> </Dropdown.Item>
)} )}

View File

@ -2,7 +2,7 @@ import Link from 'next/link'
import styles from './item.module.css' import styles from './item.module.css'
import UpVote from './upvote' import UpVote from './upvote'
import { useRef } from 'react' import { useRef } from 'react'
import { AD_USER_ID, NOFOLLOW_LIMIT } from '../lib/constants' import { AD_USER_ID, UNKNOWN_LINK_REL } from '../lib/constants'
import Pin from '../svgs/pushpin-fill.svg' import Pin from '../svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace' import reactStringReplace from 'react-string-replace'
import PollIcon from '../svgs/bar-chart-horizontal-fill.svg' import PollIcon from '../svgs/bar-chart-horizontal-fill.svg'
@ -29,7 +29,6 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
const router = useRouter() const router = useRouter()
const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL) const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL)
const nofollow = item.sats + item.boost < NOFOLLOW_LIMIT && !item.position ? 'nofollow' : ''
return ( return (
<> <>
@ -50,7 +49,6 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
<div className={styles.hunk}> <div className={styles.hunk}>
<div className={`${styles.main} flex-wrap`}> <div className={`${styles.main} flex-wrap`}>
<Link <Link
rel={nofollow}
href={`/items/${item.id}`} href={`/items/${item.id}`}
onClick={(e) => { onClick={(e) => {
const viewedAt = commentsViewedAt(item) const viewedAt = commentsViewedAt(item)
@ -82,19 +80,17 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
{image && <span className={styles.icon}><ImageIcon className='fill-grey ms-2' height={16} width={16} /></span>} {image && <span className={styles.icon}><ImageIcon className='fill-grey ms-2' height={16} width={16} /></span>}
</Link> </Link>
{item.url && !image && {item.url && !image &&
<> // eslint-disable-next-line
<a <a
className={styles.link} target='_blank' href={item.url} className={styles.link} target='_blank' href={item.url}
rel={`noreferrer ${nofollow} noopener`} rel={item.rel ?? UNKNOWN_LINK_REL}
> >
{item.url.replace(/(^https?:|^)\/\//, '')} {item.url.replace(/(^https?:|^)\/\//, '')}
</a> </a>}
</>}
</div> </div>
<ItemInfo <ItemInfo
full={full} item={item} full={full} item={item}
onQuoteReply={onQuoteReply} onQuoteReply={onQuoteReply}
nofollow={nofollow}
pinnable={pinnable} pinnable={pinnable}
extraBadges={Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>} extraBadges={Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>}
/> />

View File

@ -11,7 +11,7 @@ import { dayMonthYear, timeSince } from '../lib/time'
import Link from 'next/link' import Link from 'next/link'
import Check from '../svgs/check-double-line.svg' import Check from '../svgs/check-double-line.svg'
import HandCoin from '../svgs/hand-coin-fill.svg' import HandCoin from '../svgs/hand-coin-fill.svg'
import { LOST_BLURBS, FOUND_BLURBS } from '../lib/constants' import { LOST_BLURBS, FOUND_BLURBS, UNKNOWN_LINK_REL } from '../lib/constants'
import CowboyHatIcon from '../svgs/cowboy.svg' import CowboyHatIcon from '../svgs/cowboy.svg'
import BaldIcon from '../svgs/bald.svg' import BaldIcon from '../svgs/bald.svg'
import { RootProvider } from './root' import { RootProvider } from './root'
@ -230,12 +230,15 @@ function NostrZap ({ n }) {
<> <>
<div className='fw-bold text-nostr ms-2 py-1'> <div className='fw-bold text-nostr ms-2 py-1'>
<NostrIcon width={24} height={24} className='fill-nostr me-1' />{numWithUnits(n.earnedSats)} zap from <NostrIcon width={24} height={24} className='fill-nostr me-1' />{numWithUnits(n.earnedSats)} zap from
<Link className='mx-1 text-reset text-underline' target='_blank' href={`https://snort.social/p/${npub}`} rel='noreferrer'> {// eslint-disable-next-line
<Link className='mx-1 text-reset text-underline' target='_blank' href={`https://snort.social/p/${npub}`} rel={UNKNOWN_LINK_REL}>
{npub.slice(0, 10)}... {npub.slice(0, 10)}...
</Link> </Link>
}
on {note on {note
? ( ? (
<Link className='mx-1 text-reset text-underline' target='_blank' href={`https://snort.social/e/${note}`} rel='noreferrer'> // eslint-disable-next-line
<Link className='mx-1 text-reset text-underline' target='_blank' href={`https://snort.social/e/${note}`} rel={UNKNOWN_LINK_REL}>
{note.slice(0, 12)}... {note.slice(0, 12)}...
</Link>) </Link>)
: 'nostr'} : 'nostr'}

View File

@ -19,6 +19,7 @@ import { rehypeInlineCodeProperty } from '../lib/md'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import Link from 'next/link' import Link from 'next/link'
import { UNKNOWN_LINK_REL } from '../lib/constants'
export function SearchText ({ text }) { export function SearchText ({ text }) {
return ( return (
@ -33,7 +34,7 @@ export function SearchText ({ text }) {
} }
// this is one of the slowest components to render // this is one of the slowest components to render
export default memo(function Text ({ nofollow, imgproxyUrls, children, tab, itemId, ...outerProps }) { export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, ...outerProps }) {
const [overflowing, setOverflowing] = useState(false) const [overflowing, setOverflowing] = useState(false)
const router = useRouter() const router = useRouter()
const [show, setShow] = useState(false) const [show, setShow] = useState(false)
@ -139,7 +140,7 @@ export default memo(function Text ({ nofollow, imgproxyUrls, children, tab, item
const Img = useCallback(({ node, src, ...props }) => { const Img = useCallback(({ node, src, ...props }) => {
const url = IMGPROXY_URL_REGEXP.test(src) ? decodeOriginalUrl(src) : src const url = IMGPROXY_URL_REGEXP.test(src) ? decodeOriginalUrl(src) : src
const srcSet = imgproxyUrls?.[url] const srcSet = imgproxyUrls?.[url]
return <ZoomableImage srcSet={srcSet} tab={tab} src={src} {...props} {...outerProps} /> return <ZoomableImage srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} {...outerProps} />
}, [imgproxyUrls, outerProps, tab]) }, [imgproxyUrls, outerProps, tab])
return ( return (
@ -174,14 +175,14 @@ export default memo(function Text ({ nofollow, imgproxyUrls, children, tab, item
<Link <Link
{...props} {...props}
id={props.id && itemId ? `${props.id}-${itemId}` : props.id} id={props.id && itemId ? `${props.id}-${itemId}` : props.id}
rel={`noreferrer ${nofollow ? 'nofollow' : ''} noopener`}
href={itemId ? `${href}-${itemId}` : href} href={itemId ? `${href}-${itemId}` : href}
>{text} >{text}
</Link> </Link>
) )
} }
return ( return (
<a id={props.id} target='_blank' rel={`noreferrer ${nofollow ? 'nofollow' : ''} noopener`} href={href}>{text}</a> // eslint-disable-next-line
<a id={props.id} target='_blank' rel={rel ?? UNKNOWN_LINK_REL} href={href}>{text}</a>
) )
} }
@ -211,7 +212,7 @@ export default memo(function Text ({ nofollow, imgproxyUrls, children, tab, item
} }
// assume the link is an image which will fallback to link if it's not // assume the link is an image which will fallback to link if it's not
return <Img src={href} nofollow={nofollow} {...props}>{children}</Img> return <Img src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</Img>
}, },
img: Img img: Img
}} }}

View File

@ -28,6 +28,7 @@ import { hexToBech32 } from '../lib/nostr'
import NostrIcon from '../svgs/nostr.svg' import NostrIcon from '../svgs/nostr.svg'
import GithubIcon from '../svgs/github-fill.svg' import GithubIcon from '../svgs/github-fill.svg'
import TwitterIcon from '../svgs/twitter-fill.svg' import TwitterIcon from '../svgs/twitter-fill.svg'
import { UNKNOWN_LINK_REL } from '../lib/constants'
export default function UserHeader ({ user }) { export default function UserHeader ({ user }) {
const router = useRouter() const router = useRouter()
@ -206,21 +207,24 @@ function SocialLink ({ name, id }) {
if (name === 'Nostr') { if (name === 'Nostr') {
const npub = hexToBech32(id) const npub = hexToBech32(id)
return ( return (
<Link className={className} target='_blank' href={`https://nostr.com/${npub}`} rel='noreferrer'> // eslint-disable-next-line
<Link className={className} target='_blank' href={`https://nostr.com/${npub}`} rel={UNKNOWN_LINK_REL}>
<NostrIcon width={20} height={20} className='me-1' /> <NostrIcon width={20} height={20} className='me-1' />
{npub.slice(0, 10)}...{npub.slice(-10)} {npub.slice(0, 10)}...{npub.slice(-10)}
</Link> </Link>
) )
} else if (name === 'Github') { } else if (name === 'Github') {
return ( return (
<Link className={className} target='_blank' href={`https://github.com/${id}`} rel='noreferrer'> // eslint-disable-next-line
<Link className={className} target='_blank' href={`https://github.com/${id}`} rel={UNKNOWN_LINK_REL}>
<GithubIcon width={20} height={20} className='me-1' /> <GithubIcon width={20} height={20} className='me-1' />
{id} {id}
</Link> </Link>
) )
} else if (name === 'Twitter') { } else if (name === 'Twitter') {
return ( return (
<Link className={className} target='_blank' href={`https://twitter.com/${id}`} rel='noreferrer'> // eslint-disable-next-line
<Link className={className} target='_blank' href={`https://twitter.com/${id}`} rel={UNKNOWN_LINK_REL}>
<TwitterIcon width={20} height={20} className='me-1' /> <TwitterIcon width={20} height={20} className='me-1' />
@{id} @{id}
</Link> </Link>

View File

@ -33,6 +33,7 @@ export const COMMENT_FIELDS = gql`
otsHash otsHash
ncomments ncomments
imgproxyUrls imgproxyUrls
rel
} }
` `

View File

@ -58,6 +58,7 @@ export const ITEM_FIELDS = gql`
uploadId uploadId
mine mine
imgproxyUrls imgproxyUrls
rel
}` }`
export const ITEM_FULL_FIELDS = gql` export const ITEM_FULL_FIELDS = gql`

View File

@ -4,6 +4,7 @@ export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs') export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')
export const NOFOLLOW_LIMIT = 1000 export const NOFOLLOW_LIMIT = 1000
export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener'
export const BOOST_MULT = 5000 export const BOOST_MULT = 5000
export const BOOST_MIN = BOOST_MULT * 5 export const BOOST_MIN = BOOST_MULT * 5
export const UPLOAD_SIZE_MAX = 25 * 1024 * 1024 export const UPLOAD_SIZE_MAX = 25 * 1024 * 1024