Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/hashtag/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useAuth } from '@/contexts/auth-context'
import { useSettingsStore } from '@/lib/store'
import { checkBlockedForAuthors } from '@/hooks/use-block'
import { isCashtagStorage, cashtagStorageToDisplay } from '@/lib/post-helpers'
import { LegacyYapprLink } from '@/components/ui/legacy-yappr-link'

function HashtagPageContent() {
const router = useRouter()
Expand Down Expand Up @@ -163,6 +164,7 @@ function HashtagPageContent() {
<p className="text-gray-500 mb-4">
Be the first to post with {tagSymbol}{displayTag}
</p>
<LegacyYapprLink />
</div>
) : (
posts.map((post, index) => (
Expand Down
20 changes: 16 additions & 4 deletions app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
UserPlusIcon,
LockClosedIcon,
CloudArrowUpIcon,
NoSymbolIcon,
} from '@heroicons/react/24/outline'
import { Sidebar } from '@/components/layout/sidebar'
import { RightSidebar } from '@/components/layout/right-sidebar'
Expand All @@ -41,14 +42,18 @@ import { BlockListSettings } from '@/components/settings/block-list-settings'
import { SavedAddressesSettings } from '@/components/settings/saved-addresses-settings'
import { StorachaSettings } from '@/components/settings/storacha-settings'
import { PinataSettings } from '@/components/settings/pinata-settings'
import { ModerationSettings } from '@/components/settings/moderation-settings'
import { YAPP_TOKEN_AUTHORITY_ID } from '@/lib/constants'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useDashPayContactsModal } from '@/hooks/use-dashpay-contacts-modal'
import { useSettingsStore } from '@/lib/store'
import { CORS_PROXY_INFO } from '@/hooks/use-link-preview'
import { UsernameModal } from '@/components/dpns/username-modal'

type SettingsSection = 'main' | 'account' | 'contacts' | 'notifications' | 'privacy' | 'privateFeed' | 'storage' | 'appearance' | 'about'
const VALID_SECTIONS: SettingsSection[] = ['main', 'account', 'contacts', 'notifications', 'privacy', 'privateFeed', 'storage', 'appearance', 'about']
type SettingsSection = 'main' | 'account' | 'contacts' | 'notifications' | 'privacy' | 'privateFeed' | 'storage' | 'appearance' | 'about' | 'moderation'
const VALID_SECTIONS: SettingsSection[] = ['main', 'account', 'contacts', 'notifications', 'privacy', 'privateFeed', 'storage', 'appearance', 'about', 'moderation']

const MODERATION_SECTION = { id: 'moderation', label: 'Moderation', icon: NoSymbolIcon, description: 'Freeze or slash YAPP balances (token authority only)' }

const settingsSections = [
{ id: 'account', label: 'Account', icon: UserIcon, description: 'Manage your account details' },
Expand Down Expand Up @@ -85,6 +90,7 @@ function SettingsPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { user, logout } = useAuth()
const isAuthority = user?.identityId === YAPP_TOKEN_AUTHORITY_ID
const { theme, setTheme } = useTheme()
const linkPreviewsChoice = useSettingsStore((s) => s.linkPreviewsChoice)
const setLinkPreviewsChoice = useSettingsStore((s) => s.setLinkPreviewsChoice)
Expand Down Expand Up @@ -206,7 +212,7 @@ function SettingsPage() {

const renderMainSettings = () => (
<div className="divide-y divide-gray-200 dark:divide-gray-800">
{settingsSections.map((section) => (
{(isAuthority ? [...settingsSections, MODERATION_SECTION] : settingsSections).map((section) => (
<button
key={section.id}
onClick={() => setActiveSection(section.id as SettingsSection)}
Expand Down Expand Up @@ -734,14 +740,20 @@ function SettingsPage() {
return renderAppearanceSettings()
case 'about':
return renderAboutSettings()
case 'moderation':
return isAuthority ? <ModerationSettings /> : renderMainSettings()
default:
return renderMainSettings()
}
}

const getSectionTitle = () => {
if (activeSection === 'main') return 'Settings'
const section = settingsSections.find(s => s.id === activeSection)
// Only surface the Moderation title to the authority (matches renderSection's
// gate) so a non-authority hitting ?section=moderation doesn't see a
// "Moderation" header over the fallback main menu.
const sections = isAuthority ? [...settingsSections, MODERATION_SECTION] : settingsSections
const section = sections.find(s => s.id === activeSection)
return section?.label || 'Settings'
}

Expand Down
13 changes: 11 additions & 2 deletions components/compose/compose-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { extractAllTags, extractMentions } from '@/lib/post-helpers'
import { hashtagService } from '@/lib/services/hashtag-service'
import { mentionService } from '@/lib/services/mention-service'
import { extractErrorMessage, isTimeoutError, categorizeError } from '@/lib/error-utils'
import { handleInsufficientYapp } from '@/hooks/use-buy-yapp-modal'
import {
PostingProgress,
PostButtonContent,
Expand Down Expand Up @@ -884,7 +885,13 @@ export function ComposeModal() {
}
const successPart = parts.join(', ')

const errorMsg = failureError?.message || 'Unknown error'
const ranOutOfYapp = handleInsufficientYapp(
failureError,
'You ran out of YAPP mid-thread. Buy some to post the rest.'
)
const errorMsg = ranOutOfYapp
? 'not enough YAPP'
: failureError?.message || 'Unknown error'
toast.error(
`Thread partially created: ${successPart}. ` +
`Post ${(failedAtIndex ?? 0) + 1} failed: ${errorMsg}. Press Post to retry.`,
Expand All @@ -902,7 +909,9 @@ export function ComposeModal() {
}
} catch (error) {
logger.error('Failed to create post:', error)
toast.error(categorizeError(error))
if (!handleInsufficientYapp(error, 'You need YAPP to post. Buy some to continue.')) {
toast.error(categorizeError(error))
}
} finally {
setIsPosting(false)
setPostingProgress(null)
Expand Down
9 changes: 9 additions & 0 deletions components/feed/feed-post-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Post } from '@/lib/types';
import ErrorBoundary from '@/components/error-boundary';
import { LoadingState } from '@/components/ui/loading-state';
import { LegacyYapprLink } from '@/components/ui/legacy-yappr-link';
import { PostCard } from '@/components/post/post-card';

interface FeedPostListProps {
Expand Down Expand Up @@ -66,6 +67,7 @@ export function FeedPostList({
? 'Follow some people to see their posts here!'
: 'Be the first to share something!'
}
emptyAction={<LegacyYapprLink />}
>
<div>
{posts?.map((post) => (
Expand All @@ -89,6 +91,13 @@ export function FeedPostList({
</button>
</div>
)}

{!hasMore && posts && posts.length > 0 && (
<div className="p-6 flex flex-col items-center gap-2 border-t border-gray-200 dark:border-gray-800 text-center">
<p className="text-sm text-gray-500">You&apos;ve reached the end.</p>
<LegacyYapprLink />
</div>
)}
</div>
</LoadingState>
</ErrorBoundary>
Expand Down
13 changes: 10 additions & 3 deletions components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { useAppStore } from '@/lib/store'
import { useNotificationStore } from '@/lib/stores/notification-store'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { UserAvatar } from '@/components/ui/avatar-image'
import { YappBalanceItem } from '@/components/token/yapp-balance-item'
import { useAuth } from '@/contexts/auth-context'
import { notificationService } from '@/lib/services'
import { useLoginModal } from '@/hooks/use-login-modal'
Expand Down Expand Up @@ -308,8 +309,13 @@ export function Sidebar() {
onClick={async (e) => {
e.stopPropagation()
setIsRefreshingBalance(true)
await refreshBalance()
setIsRefreshingBalance(false)
try {
await refreshBalance()
} catch (error) {
logger.error('Failed to refresh balance:', error)
} finally {
setIsRefreshingBalance(false)
}
}}
disabled={isRefreshingBalance}
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
Expand All @@ -320,7 +326,8 @@ export function Sidebar() {
</div>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-gray-200 dark:bg-gray-800 my-1" />
<DropdownMenu.Item
<YappBalanceItem />
<DropdownMenu.Item
className="px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-900 cursor-pointer outline-none flex items-center gap-2"
onClick={logout}
>
Expand Down
9 changes: 7 additions & 2 deletions components/post/post-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { EmbeddedPostCard, EmbeddedPostSkeleton } from './embedded-post-card'
import { EmbeddedBlogPostCard, isEmbeddedBlogPostLike } from '@/components/blog/embedded-blog-post-card'
import { ProfileHoverCard } from '@/components/profile/profile-hover-card'
import { useTipModal } from '@/hooks/use-tip-modal'
import { handleInsufficientYapp } from '@/hooks/use-buy-yapp-modal'
import { useBlock } from '@/hooks/use-block'
import { useFollow } from '@/hooks/use-follow'
import { useHashtagValidation } from '@/hooks/use-hashtag-validation'
Expand Down Expand Up @@ -364,7 +365,9 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e
setLiked(wasLiked)
setLikes(prevLikes)
logger.error('Like error:', error)
toast.error('Failed to update like. Please try again.')
if (!handleInsufficientYapp(error, 'You need YAPP to like posts. Buy some to continue.')) {
toast.error('Failed to update like. Please try again.')
}
} finally {
setLikeLoading(false)
}
Expand Down Expand Up @@ -397,7 +400,9 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e
setReposted(wasReposted)
setReposts(prevReposts)
logger.error('Repost error:', error)
toast.error('Failed to update repost. Please try again.')
if (!handleInsufficientYapp(error, 'You need YAPP to repost. Buy some to continue.')) {
toast.error('Failed to update repost. Please try again.')
}
} finally {
setRepostLoading(false)
}
Expand Down
13 changes: 13 additions & 0 deletions components/post/tip-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Dialog from '@radix-ui/react-dialog'
import { XMarkIcon, CurrencyDollarIcon, QrCodeIcon, WalletIcon, BookmarkIcon } from '@heroicons/react/24/outline'
import { CheckCircleIcon, ExclamationCircleIcon } from '@heroicons/react/24/solid'
import { motion, AnimatePresence } from 'framer-motion'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { useTipModal } from '@/hooks/use-tip-modal'
Expand Down Expand Up @@ -204,6 +205,18 @@ export function TipModal() {
// Update global balance in auth context (persists to localStorage)
refreshBalance().catch(err => logger.error('Failed to refresh balance:', err))

// The tip (credit transfer) succeeded, but the public "tip" reply is a
// YAPP-costed doc — tell the user if it couldn't be posted rather than
// letting it vanish silently, and only blame YAPP when that was the cause.
if (result.announcementPosted === false) {
toast(
result.announcementError === 'INSUFFICIENT_YAPP'
? 'Tip sent — but the public tip note couldn’t be posted (needs YAPP).'
: 'Tip sent — but the public tip note couldn’t be posted. You can reply to the post manually.',
{ icon: '⚠️', duration: 5000 }
)
}
Comment thread
thepastaclaw marked this conversation as resolved.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// If key was manually entered and not already saved, offer to save
if (keySource === 'manual' && usedTransferKeyRef.current && !hasTransferKey(user.identityId)) {
setState('save-prompt')
Expand Down
2 changes: 2 additions & 0 deletions components/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { MentionRecoveryModal } from '@/components/post/mention-recovery-modal'
import { DeleteConfirmationModal } from '@/components/post/delete-confirmation-modal'
import { DashPayContactsModal } from '@/components/contacts/dashpay-contacts-modal'
import { EncryptionKeyModal } from '@/components/auth/encryption-key-modal'
import { BuyYappModal } from '@/components/token/buy-yapp-modal'

export function Providers({ children }: { children: React.ReactNode }) {
return (
Expand All @@ -26,6 +27,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
<UsernameModalProvider />
<KeyBackupModal />
<TipModal />
<BuyYappModal />
<HashtagRecoveryModal />
<MentionRecoveryModal />
<DeleteConfirmationModal />
Expand Down
97 changes: 97 additions & 0 deletions components/settings/moderation-settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client'

import { useState } from 'react'
import toast from 'react-hot-toast'
import { NoSymbolIcon, ArrowUturnLeftIcon, FireIcon } from '@heroicons/react/24/outline'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/contexts/auth-context'
import { tokenService } from '@/lib/services/token-service'

/**
* Owner-only YAPP moderation: freeze (suspend), unfreeze (reinstate), and
* destroyFrozen (slash the staked balance). Rendered only for the token
* authority identity (gated in the settings page).
*/
export function ModerationSettings() {
const { user } = useAuth()
const [targetId, setTargetId] = useState('')
const [note, setNote] = useState('')
const [busy, setBusy] = useState<null | 'freeze' | 'unfreeze' | 'destroyFrozen'>(null)

const run = async (action: 'freeze' | 'unfreeze' | 'destroyFrozen') => {
if (!user) return
const id = targetId.trim()
if (!id) {
toast.error('Enter the identity ID to moderate')
return
}
if (action === 'destroyFrozen' && !window.confirm(
`Permanently burn ${id}'s YAPP balance? This slashes their stake and cannot be undone. The account must already be frozen.`
)) return

setBusy(action)
const fn = action === 'freeze' ? tokenService.freeze
: action === 'unfreeze' ? tokenService.unfreeze
: tokenService.destroyFrozen
const result = await fn.call(tokenService, user.identityId, id, note.trim() || undefined)
setBusy(null)

if (result.success) {
toast.success(
action === 'freeze' ? 'Identity frozen (suspended)'
: action === 'unfreeze' ? 'Identity unfrozen (reinstated)'
: 'Frozen balance destroyed (slashed)'
)
} else {
toast.error(result.error || 'Action failed')
}
}

return (
<Card>
<CardHeader>
<CardTitle>YAPP Moderation</CardTitle>
<CardDescription>
Freeze suspends an account (blocks posting and transfers immediately). Slash permanently
burns a frozen account&apos;s YAPP stake. Use unfreeze to reinstate.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label htmlFor="moderation-target-id" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Identity ID</label>
<input
id="moderation-target-id"
type="text"
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
placeholder="Base58 identity ID to moderate"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-neutral-800 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-yappr-500"
/>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<div>
<label htmlFor="moderation-note" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Public note (optional)</label>
<input
id="moderation-note"
type="text"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Reason recorded on-chain"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-yappr-500"
/>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" disabled={busy !== null} onClick={() => run('freeze')} className="gap-2">
<NoSymbolIcon className="h-4 w-4" /> {busy === 'freeze' ? 'Freezing…' : 'Freeze'}
</Button>
<Button variant="outline" disabled={busy !== null} onClick={() => run('unfreeze')} className="gap-2">
<ArrowUturnLeftIcon className="h-4 w-4" /> {busy === 'unfreeze' ? 'Unfreezing…' : 'Unfreeze'}
</Button>
<Button variant="destructive" disabled={busy !== null} onClick={() => run('destroyFrozen')} className="gap-2">
<FireIcon className="h-4 w-4" /> {busy === 'destroyFrozen' ? 'Slashing…' : 'Slash'}
</Button>
</div>
</CardContent>
</Card>
)
}
Loading
Loading