mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ['react-app'],
|
||||
rules: {
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'warn'
|
||||
},
|
||||
ignorePatterns: ['android/**', 'build/**', 'node_modules/**', 'public/**', 'electron/**']
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 170,
|
||||
"bracketSpacing": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"jsxSingleQuote": true,
|
||||
"arrowParens": "always",
|
||||
"useTabs": false,
|
||||
"ignorePatterns": []
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
5chan is a serverless, adminless, decentralized 4chan alternative built on the Plebbit protocol.
|
||||
|
||||
## Stack
|
||||
|
||||
- **React 19** with TypeScript
|
||||
- **Zustand** for state management
|
||||
- **React Router v6** for routing
|
||||
- **Vite** for bundling
|
||||
- **plebbit-react-hooks** for Plebbit protocol integration
|
||||
- **i18next** for translations
|
||||
- **yarn** as package manager
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
yarn install # Install dependencies
|
||||
yarn start # Start dev server (port 3000)
|
||||
yarn build # Production build
|
||||
yarn test # Run tests
|
||||
yarn prettier # Format code
|
||||
yarn electron # Run Electron app
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- TypeScript strict mode
|
||||
- Prettier for formatting (runs on pre-commit)
|
||||
- Follow DRY principle—extract reusable components
|
||||
|
||||
## React Patterns (Critical)
|
||||
|
||||
AI tends to overuse `useState` and `useEffect`. This project follows modern React patterns instead.
|
||||
|
||||
### Do NOT
|
||||
|
||||
- Use `useState` for shared/global state → use **Zustand stores** in `src/stores/`
|
||||
- Use `useEffect` for data fetching → use **plebbit-react-hooks** (already handles caching, loading states)
|
||||
- Copy-paste logic across components → extract into **custom hooks** in `src/hooks/`
|
||||
- Use boolean flag soup (`isLoading`, `isError`, `isSuccess`) → use **state machines** with Zustand
|
||||
- Use `useEffect` to sync derived state → **calculate during render** instead
|
||||
|
||||
### Do
|
||||
|
||||
- Use Zustand for any state shared between components
|
||||
- Use plebbit-react-hooks (`useComment`, `useFeed`, `useSubplebbit`, etc.) for all Plebbit data
|
||||
- Extract reusable logic into custom hooks
|
||||
- Calculate derived values during render, not in effects
|
||||
- Use `useMemo` only when profiling shows it's needed
|
||||
- Use React Router for navigation (no manual history manipulation)
|
||||
|
||||
### Quick Reference
|
||||
|
||||
| Concern | ❌ Avoid | ✅ Use Instead |
|
||||
|---------|----------|----------------|
|
||||
| Shared state | `useState` + prop drilling | Zustand store |
|
||||
| Data fetching | `useEffect` + fetch | plebbit-react-hooks |
|
||||
| Derived state | `useEffect` to sync | Calculate during render |
|
||||
| Side effects | Effects without cleanup | AbortController or query libs |
|
||||
| Complex flows | Boolean flags | State machine in Zustand |
|
||||
| Logic reuse | Copy-paste | Custom hooks |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # Reusable UI components
|
||||
├── views/ # Page-level components (routes)
|
||||
├── hooks/ # Custom React hooks
|
||||
├── stores/ # Zustand stores
|
||||
├── lib/ # Utilities and helpers
|
||||
└── data/ # Static data (default subplebbits, etc.)
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
The following docs exist for deeper guidance. **Do not read them automatically**—they are large and will bloat the context window. Instead:
|
||||
- Be aware they exist
|
||||
- Consult them when relevant to the task or when the user asks
|
||||
- Offer to read them if the user seems to need React pattern guidance
|
||||
|
||||
Available docs:
|
||||
- **[docs/react-guide.md](docs/react-guide.md)** — Bad vs good React patterns with code examples
|
||||
- **[docs/you-might-not-need-an-effect.md](docs/you-might-not-need-an-effect.md)** — When to avoid useEffect (comprehensive)
|
||||
|
||||
## Context Window Warning
|
||||
|
||||
MCP servers can silently bloat the context window with tool definitions, even when unused. If you notice:
|
||||
- Responses getting cut off or degraded
|
||||
- The agent "forgetting" earlier conversation context
|
||||
- Unusually slow responses
|
||||
|
||||
**Tell the user**: "You may have too many MCP servers enabled. Consider disabling unused ones to free up context window space." The user may not be aware this is happening—MCP tool definitions consume tokens even if you never call them.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Never commit secrets or API keys
|
||||
- Use yarn, not npm
|
||||
- Keep components focused—split large components
|
||||
- Add comments for complex logic, skip obvious code
|
||||
- Test on mobile viewport (this is a responsive app)
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
# React guide
|
||||
|
||||
A lot of AI-generated React code is heavily biased toward using useState and useEffect, often in verbose or naive ways. That’s because these hooks are:
|
||||
|
||||
1. Explicit, making them easy for LLMs to pattern-match.
|
||||
2. Safe defaults, requiring less architectural decision-making.
|
||||
3. Well-documented, so the model’s training data is oversaturated with examples.
|
||||
|
||||
But modern, well-architected React apps rarely rely on raw useState/useEffect beyond simple UI state or lifecycle quirks. Here’s a breakdown of what serious devs use instead—and why this is the real moat for human engineers.
|
||||
|
||||
Alternatives to useState and useEffect
|
||||
|
||||
1. State Machines / Statecharts (e.g. Zustand)
|
||||
- Why: Replaces ad-hoc local state management with deterministic, declarative logic.
|
||||
- Advantage: Less bugs, easier to test, clearer state transitions.
|
||||
- AI gap: LLMs struggle to model state transitions declaratively. LLM Agent must overcome this.
|
||||
|
||||
2. React Query / SWR for Data Fetching
|
||||
- Why: Avoids useEffect-based data fetching and loading/error state juggling.
|
||||
- Advantage: Caching, stale-while-revalidate, retries, pagination—all handled out of the box.
|
||||
- AI gap: AI often uses useEffect + fetch and forgets cleanup, race conditions, or caching.
|
||||
|
||||
3. Global State with Zustand, Jotai, Recoil, or Redux Toolkit
|
||||
- Why: Local useState doesn’t scale for shared or persistent state.
|
||||
- Advantage: Better performance, devtools support, persistence.
|
||||
- AI gap: Defaults to prop-drilling or bloated context APIs.
|
||||
|
||||
4. Custom Hooks and Composition
|
||||
- Why: Reuse logic cleanly without bloating components or copy-pasting useEffect.
|
||||
- Advantage: Separation of concerns, encapsulation.
|
||||
- AI gap: Often fails to factor logic out, keeping everything in one component.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
AI can mimic patterns, but it can’t:
|
||||
- Architect a system for long-term maintainability.
|
||||
- Predict runtime performance tradeoffs.
|
||||
- Refactor spaghetti effects into custom hooks or state machines.
|
||||
- Decide when state should be colocated vs globalized.
|
||||
- Avoid footguns like stale closures, unnecessary re-renders, or race conditions.
|
||||
|
||||
In short: AI is great at writing code, but bad at engineering software.
|
||||
|
||||
Takeaway
|
||||
- Use fewer useEffect and useState hooks.
|
||||
- Learn to design systems, not just code components.
|
||||
- Reach for declarative, composable patterns.
|
||||
- Understand caching, reactivity, and scalability beyond local state.
|
||||
|
||||
Here’s a side-by-side breakdown of common AI-generated React patterns (bad) vs what experienced engineers write (good) across 4 key areas: state management, data fetching, side effects, and logic reuse.
|
||||
|
||||
## 1. State Management
|
||||
|
||||
❌ Bad (AI-style useState)
|
||||
|
||||
```typescript
|
||||
function TodoApp() {
|
||||
const [todos, setTodos] = useState([]);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const addTodo = () => {
|
||||
setTodos([...todos, { text: input }]);
|
||||
setInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
<button onClick={addTodo}>Add</button>
|
||||
<ul>
|
||||
{todos.map((todo, i) => <li key={i}>{todo.text}</li>)}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
✅ Good (Zustand store, state extracted)
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand';
|
||||
|
||||
const useTodoStore = create((set) => ({
|
||||
todos: [],
|
||||
input: '',
|
||||
addTodo: () => set((state) => ({
|
||||
todos: [...state.todos, { text: state.input }],
|
||||
input: '',
|
||||
})),
|
||||
setInput: (val) => set({ input: val }),
|
||||
}));
|
||||
|
||||
function TodoApp() {
|
||||
const { todos, input, setInput, addTodo } = useTodoStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<input value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
<button onClick={addTodo}>Add</button>
|
||||
<ul>
|
||||
{todos.map((todo, i) => <li key={i}>{todo.text}</li>)}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 2. Data Fetching
|
||||
|
||||
❌ Bad (AI-style useEffect)
|
||||
|
||||
```typescript
|
||||
function UserList() {
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/users')
|
||||
.then(res => res.json())
|
||||
.then(setUsers);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{users.map(u => <li key={u.id}>{u.name}</li>)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
✅ Good (React Query)
|
||||
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
function UserList() {
|
||||
const { data: users = [], isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => fetch('/api/users').then(res => res.json()),
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{users.map(u => <li key={u.id}>{u.name}</li>)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Side Effects
|
||||
|
||||
❌ Bad (naive effect with race condition)
|
||||
|
||||
```typescript
|
||||
function Search({ term }) {
|
||||
const [results, setResults] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/search?q=${term}`)
|
||||
.then(res => res.json())
|
||||
.then(setResults);
|
||||
}, [term]);
|
||||
|
||||
return <div>{results.length} results</div>;
|
||||
}
|
||||
```
|
||||
|
||||
✅ Good (abort previous fetch with AbortController)
|
||||
|
||||
```typescript
|
||||
function Search({ term }) {
|
||||
const [results, setResults] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
fetch(`/api/search?q=${term}`, { signal: controller.signal })
|
||||
.then(res => res.json())
|
||||
.then(setResults)
|
||||
.catch((e) => {
|
||||
if (e.name !== 'AbortError') console.error(e);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [term]);
|
||||
|
||||
return <div>{results.length} results</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Better yet? Don’t use useEffect at all. Use React Query with term as a key.
|
||||
|
||||
## 4. Logic Reuse
|
||||
|
||||
❌ Bad (repeated logic inline)
|
||||
|
||||
```typescript
|
||||
function ComponentA() {
|
||||
const [count, setCount] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setCount(c => c + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return <div>{count}</div>;
|
||||
}
|
||||
|
||||
function ComponentB() {
|
||||
const [count, setCount] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setCount(c => c + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return <div>{count}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
✅ Good (custom hook)
|
||||
|
||||
```typescript
|
||||
function useCounter(intervalMs = 1000) {
|
||||
const [count, setCount] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setCount(c => c + 1), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
return count;
|
||||
}
|
||||
|
||||
function ComponentA() {
|
||||
const count = useCounter();
|
||||
return <div>{count}</div>;
|
||||
}
|
||||
|
||||
function ComponentB() {
|
||||
const count = useCounter();
|
||||
return <div>{count}</div>;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 5. State Machines with Zustand
|
||||
|
||||
❌ Bad (AI-style complex useState with boolean flags)
|
||||
|
||||
```typescript
|
||||
function OrderProcess() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [cart, setCart] = useState([]);
|
||||
const [paymentDetails, setPaymentDetails] = useState(null);
|
||||
const [orderPlaced, setOrderPlaced] = useState(false);
|
||||
|
||||
const addToCart = (item) => {
|
||||
setCart([...cart, item]);
|
||||
};
|
||||
|
||||
const submitPayment = async (details) => {
|
||||
setIsLoading(true);
|
||||
setIsError(false);
|
||||
|
||||
try {
|
||||
await processPayment(details);
|
||||
setPaymentDetails(details);
|
||||
setIsSuccess(true);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setIsError(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const placeOrder = async () => {
|
||||
setIsLoading(true);
|
||||
setIsError(false);
|
||||
|
||||
try {
|
||||
await submitOrder(cart, paymentDetails);
|
||||
setOrderPlaced(true);
|
||||
setIsSuccess(true);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setIsError(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Complex conditional rendering based on multiple state variables */}
|
||||
{isLoading && <LoadingSpinner />}
|
||||
{isError && <ErrorMessage />}
|
||||
{!isLoading && !isError && !orderPlaced && (
|
||||
<>
|
||||
<CartItems items={cart} onAddItem={addToCart} />
|
||||
<PaymentForm onSubmit={submitPayment} />
|
||||
{paymentDetails && <OrderButton onClick={placeOrder} />}
|
||||
</>
|
||||
)}
|
||||
{orderPlaced && <OrderConfirmation />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
✅ Good (Zustand state machine)
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Create a Zustand store with state machine pattern
|
||||
const useOrderStore = create((set, get) => ({
|
||||
// State machine's current state
|
||||
state: 'browsing',
|
||||
|
||||
// Context/data
|
||||
cart: [],
|
||||
paymentDetails: null,
|
||||
error: null,
|
||||
|
||||
// Actions that transition between states
|
||||
addToCart: (item) => set((state) => ({
|
||||
cart: [...state.cart, item]
|
||||
})),
|
||||
|
||||
goToCheckout: () => set({ state: 'paymentEntry' }),
|
||||
|
||||
goBack: () => {
|
||||
const { state: currentState } = get();
|
||||
if (currentState === 'paymentEntry') {
|
||||
set({ state: 'browsing' });
|
||||
} else if (currentState === 'confirmOrder') {
|
||||
set({ state: 'paymentEntry' });
|
||||
}
|
||||
},
|
||||
|
||||
submitPayment: async (details) => {
|
||||
set({ state: 'processingPayment', error: null });
|
||||
|
||||
try {
|
||||
const result = await processPayment(details);
|
||||
set({
|
||||
state: 'confirmOrder',
|
||||
paymentDetails: result
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
state: 'paymentEntry',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
placeOrder: async () => {
|
||||
const { cart, paymentDetails } = get();
|
||||
set({ state: 'processingOrder', error: null });
|
||||
|
||||
try {
|
||||
await submitOrder(cart, paymentDetails);
|
||||
set({ state: 'orderComplete' });
|
||||
} catch (error) {
|
||||
set({
|
||||
state: 'confirmOrder',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
function OrderProcess() {
|
||||
const {
|
||||
state,
|
||||
cart,
|
||||
paymentDetails,
|
||||
error,
|
||||
addToCart,
|
||||
goToCheckout,
|
||||
goBack,
|
||||
submitPayment,
|
||||
placeOrder
|
||||
} = useOrderStore();
|
||||
|
||||
// Render UI based on current state
|
||||
return (
|
||||
<div>
|
||||
{error && <ErrorMessage message={error} />}
|
||||
|
||||
{state === 'browsing' && (
|
||||
<CartItems
|
||||
items={cart}
|
||||
onAddItem={addToCart}
|
||||
onCheckout={goToCheckout}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'paymentEntry' && (
|
||||
<PaymentForm
|
||||
onSubmit={submitPayment}
|
||||
onBack={goBack}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'processingPayment' && (
|
||||
<LoadingSpinner message="Processing payment..." />
|
||||
)}
|
||||
|
||||
{state === 'confirmOrder' && (
|
||||
<OrderSummary
|
||||
cart={cart}
|
||||
paymentDetails={paymentDetails}
|
||||
onConfirm={placeOrder}
|
||||
onBack={goBack}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'processingOrder' && (
|
||||
<LoadingSpinner message="Placing your order..." />
|
||||
)}
|
||||
|
||||
{state === 'orderComplete' && (
|
||||
<OrderConfirmation orderDetails={{ cart, paymentDetails }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Key Benefits of the Zustand State Machine Approach:
|
||||
|
||||
1. **Explicit states**: The system can only be in one well-defined state at a time
|
||||
2. **Centralized logic**: All state transitions are defined in one place
|
||||
3. **Predictable transitions**: State changes follow clear patterns
|
||||
4. **Simplified UI logic**: Components just render based on the current state
|
||||
5. **Async handling**: Asynchronous operations are contained within the state transitions
|
||||
6. **Shared state**: Any component can access the store without prop drilling
|
||||
7. **Improved debugging**: The current state is always clear and predictable
|
||||
8. **No impossible states**: Unlike boolean flags that could create invalid combinations
|
||||
9. **Simpler API**: Compared to XState, Zustand offers a more approachable API for many developers
|
||||
10. **Smaller bundle size**: Zustand is significantly smaller than XState
|
||||
|
||||
This approach gives most of the benefits of formal state machines while maintaining the simplicity and familiarity of React state management. It's a pragmatic middle ground that's often easier to adopt in existing projects.
|
||||
|
||||
## 6. Concurrent UI Techniques
|
||||
|
||||
❌ Bad (Blocking UI updates without concurrency):
|
||||
```jsx
|
||||
// Bad example: Synchronously fetching data in useEffect can cause UI jank.
|
||||
function SearchResults({ query }) {
|
||||
const [results, setResults] = React.useState([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch(`/api/search?q=${query}`)
|
||||
.then(res => res.json())
|
||||
.then(data => setResults(data));
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{results.map(item => <p key={item.id}>{item.title}</p>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
✅ Good (Using useTransition for non-blocking updates):
|
||||
```jsx
|
||||
// Good example: Leveraging useTransition to mark state updates as low-priority.
|
||||
function SearchResults({ query }) {
|
||||
const [results, setResults] = React.useState([]);
|
||||
const [isPending, startTransition] = React.useTransition();
|
||||
|
||||
React.useEffect(() => {
|
||||
startTransition(() => {
|
||||
fetch(`/api/search?q=${query}`)
|
||||
.then(res => res.json())
|
||||
.then(data => setResults(data));
|
||||
});
|
||||
}, [query, startTransition]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isPending && <div>Loading...</div>}
|
||||
{results.map(item => <p key={item.id}>{item.title}</p>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Routing and Navigation (Not applicable with Next.js apps)
|
||||
|
||||
❌ Bad (Manual routing without a dedicated library):
|
||||
```jsx
|
||||
// Bad example: Handling routing manually using state and window history.
|
||||
function App() {
|
||||
const [route, setRoute] = React.useState(window.location.pathname);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onPopState = () => setRoute(window.location.pathname);
|
||||
window.addEventListener('popstate', onPopState);
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{route === '/' && <Home />}
|
||||
{route === '/about' && <About />}
|
||||
<button onClick={() => {
|
||||
window.history.pushState({}, '', '/about');
|
||||
setRoute('/about');
|
||||
}}>
|
||||
About
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
✅ Good (Using React Router v6 with lazy-loaded routes):
|
||||
```jsx
|
||||
// Good example: Utilizing React Router to manage routes and lazy load components.
|
||||
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
|
||||
const Home = lazy(() => import('./Home'));
|
||||
const About = lazy(() => import('./About'));
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<nav>
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/about">About</Link>
|
||||
</nav>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Error Handling
|
||||
|
||||
❌ Bad (No error boundary; crashes on errors):
|
||||
```jsx
|
||||
// Bad example: A component that throws errors without an error boundary.
|
||||
function MyComponent({ data }) {
|
||||
if (!data) {
|
||||
throw new Error("Data not found");
|
||||
}
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return <MyComponent data={null} />;
|
||||
}
|
||||
```
|
||||
|
||||
✅ Good (Wrapping components with an Error Boundary):
|
||||
```jsx
|
||||
// Good example: Using an ErrorBoundary component to catch and handle errors gracefully.
|
||||
import React from 'react';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error("Error caught in boundary:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <h1>Something went wrong.</h1>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function MyComponent({ data }) {
|
||||
if (!data) {
|
||||
throw new Error("Data not found");
|
||||
}
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<MyComponent data={null} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Form Management
|
||||
|
||||
❌ Bad (Manual state handling with no validation):
|
||||
```jsx
|
||||
// Bad example: Uncontrolled form without validation; error handling is omitted.
|
||||
function ContactForm() {
|
||||
const [formData, setFormData] = React.useState({ name: '', email: '' });
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
console.log(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Name"
|
||||
/>
|
||||
<input
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="Email"
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
✅ Good (Using React Hook Form with Zod for schema validation):
|
||||
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
// 1. Define your schema with Zod
|
||||
const contactSchema = z.object({
|
||||
name: z.string().nonempty('Name is required'),
|
||||
email: z
|
||||
.string()
|
||||
.nonempty('Email is required')
|
||||
.email('Invalid email'),
|
||||
});
|
||||
|
||||
// 2. Infer the TypeScript type of your form data
|
||||
type ContactFormData = z.infer<typeof contactSchema>;
|
||||
|
||||
export function ContactForm() {
|
||||
// 3. Hook up RHF with the zodResolver
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<ContactFormData>({
|
||||
resolver: zodResolver(contactSchema),
|
||||
});
|
||||
|
||||
const onSubmit = (data: ContactFormData) => {
|
||||
console.log(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<input
|
||||
{...register('name')}
|
||||
placeholder="Name"
|
||||
aria-invalid={!!errors.name}
|
||||
/>
|
||||
{errors.name && <p role="alert">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
{...register('email')}
|
||||
placeholder="Email"
|
||||
aria-invalid={!!errors.email}
|
||||
/>
|
||||
{errors.email && <p role="alert">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 10. Code Splitting and Lazy Loading
|
||||
|
||||
❌ Bad (Eagerly importing heavy components, increasing the initial bundle size):
|
||||
```jsx
|
||||
// Bad example: Directly importing a large component causing a heavier bundle.
|
||||
import HeavyComponent from './HeavyComponent';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<HeavyComponent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
✅ Good (Lazy loading heavy components with React.lazy and Suspense):
|
||||
```jsx
|
||||
// Good example: Dynamically importing components to reduce initial load time.
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
|
||||
const HeavyComponent = lazy(() => import('./HeavyComponent'));
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading Component...</div>}>
|
||||
<HeavyComponent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 11. Testing Strategies
|
||||
|
||||
❌ Bad (Testing implementation details, resulting in brittle tests):
|
||||
```jsx
|
||||
// Bad example: Relying on internal DOM structure which may change.
|
||||
import { render } from '@testing-library/react';
|
||||
import MyComponent from './MyComponent';
|
||||
|
||||
test('MyComponent renders data', () => {
|
||||
const { container } = render(<MyComponent />);
|
||||
expect(container.querySelector('.data')).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
✅ Good (Focusing on user-centric behaviors using React Testing Library):
|
||||
```jsx
|
||||
// Good example: Testing component behavior by simulating user interactions.
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MyComponent from './MyComponent';
|
||||
|
||||
test('MyComponent displays loaded data after user action', async () => {
|
||||
render(<MyComponent />);
|
||||
|
||||
// Check for an initial loading state
|
||||
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
|
||||
|
||||
// Simulate user action that triggers data loading
|
||||
const button = screen.getByRole('button', { name: /load data/i });
|
||||
userEvent.click(button);
|
||||
|
||||
// Wait and assert that data is displayed
|
||||
expect(await screen.findByText(/Data loaded/i)).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
## 12. Accessibility & Theming
|
||||
|
||||
❌ Bad (Non-semantic elements and missing ARIA labels):
|
||||
```jsx
|
||||
// Bad example: Using a div as a clickable element without semantics or accessibility.
|
||||
function ThemedButton() {
|
||||
return <div onClick={() => console.log('Clicked!')}>Click me</div>;
|
||||
}
|
||||
```
|
||||
|
||||
✅ Good (Semantic button with ARIA attributes and theming via CSS variables):
|
||||
```jsx
|
||||
// Good example: Accessible button component that utilizes theming.
|
||||
function ThemedButton() {
|
||||
return (
|
||||
<button
|
||||
aria-label="Click me"
|
||||
onClick={() => console.log('Clicked!')}
|
||||
style={{ padding: '8px 16px', backgroundColor: 'var(--primary-color)', color: '#fff' }}
|
||||
>
|
||||
Click me
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* CSS (in a separate file or styled-components):
|
||||
:root {
|
||||
--primary-color: #007bff;
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
# Summary Table
|
||||
|
||||
| Concern | Bad AI Pattern | Solid Engineering Practice |
|
||||
|---------|---------------|----------------------------|
|
||||
| **State Management** | `useState` everywhere, prop‑drilling | Central store (Zustand / Redux Toolkit) or colocated custom hooks |
|
||||
| **Data Fetching** | `useEffect` + `fetch`, manual loading/error juggling | React Query / SWR with caching, retries, stale‑while‑revalidate |
|
||||
| **Side Effects** | Effects without cleanup → race conditions, leaks | AbortController or query library handles cancellation & retries |
|
||||
| **Logic Reuse** | Copy‑pasted hooks and state in every component | Extract cross‑cutting logic into custom hooks |
|
||||
| **Workflow / State Machine** | Boolean‑flag soup, impossible states | Deterministic statechart in a single store (Zustand / XState) |
|
||||
| **Concurrent UI Updates** | Blocking synchronous work inside effects | `useTransition` / `startTransition` for low‑priority updates |
|
||||
| **Routing & Navigation** | DIY history manipulation, inline route state | React Router v6 (or Next.js built‑ins) with lazy‑loaded routes |
|
||||
| **Error Handling** | No error boundary → full‑app crashes | Top‑level `ErrorBoundary` plus logging in `componentDidCatch` |
|
||||
| **Form Management** | Manual state, zero validation or feedback | React Hook Form + Yup/Zod schema for declarative validation |
|
||||
| **Code Splitting & Lazy Loading** | Eager imports ballooning bundle size | `React.lazy` + `Suspense` for on‑demand loading |
|
||||
| **Testing Strategy** | Brittle tests keyed to DOM structure | User‑centric tests with React Testing Library & jest‑dom |
|
||||
| **Accessibility & Theming** | Non‑semantic elements, missing ARIA, inline colors | Semantic HTML, ARIA labels, CSS variables / theme provider |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"plugins": ["react"],
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/exhaustive-deps": "warn"
|
||||
},
|
||||
"ignore": [
|
||||
"android/**",
|
||||
"build/**",
|
||||
"node_modules/**",
|
||||
"public/**",
|
||||
"electron/**"
|
||||
]
|
||||
}
|
||||
+18
-18
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "5chan",
|
||||
"version": "0.5.3",
|
||||
"description": "A GUI for plebbit similar to 4chan",
|
||||
"description": "A serverless, adminless, decentralized 4chan alternative",
|
||||
"type": "module",
|
||||
"author": "Plebbit Labs",
|
||||
"license": "GPL-2.0-only",
|
||||
@@ -11,7 +11,7 @@
|
||||
"@capacitor/status-bar": "7.0.1",
|
||||
"@capawesome/capacitor-android-edge-to-edge-support": "7.2.2",
|
||||
"@floating-ui/react": "0.26.1",
|
||||
"@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#3323ee84401a3ad53d5cbc620bf4d33498bcc102",
|
||||
"@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#d17fa5d9b50f6e489f11d67f7e652c69be33cc6d",
|
||||
"@react-spring/web": "9.7.5",
|
||||
"@testing-library/jest-dom": "5.14.1",
|
||||
"@testing-library/react": "13.0.0",
|
||||
@@ -33,7 +33,6 @@
|
||||
"json-stringify-pretty-compact": "4.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"memoizee": "0.4.15",
|
||||
"prettier": "3.0.3",
|
||||
"react": "19.1.2",
|
||||
"react-dom": "19.1.2",
|
||||
"react-i18next": "13.2.2",
|
||||
@@ -72,7 +71,9 @@
|
||||
"electron:before:download-ipfs": "node electron/download-ipfs",
|
||||
"electron:before:delete-data": "rimraf .plebbit",
|
||||
"android:build:icons": "cordova-res android --skip-config --copy --resources /tmp/plebbit-react-android-icons --icon-source ./android/icons/icon.png --splash-source ./android/icons/splash.png --icon-foreground-source ./android/icons/icon-foreground.png --icon-background-source '#ffffee'",
|
||||
"prettier": "prettier {src,electron}/**/*.{js,ts,tsx} --write",
|
||||
"lint": "oxlint src/**/*.{js,ts,tsx}",
|
||||
"type-check": "tsgo --noEmit",
|
||||
"prettier": "oxfmt src/**/*.{js,ts,tsx} electron/**/*.{js,mjs}",
|
||||
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
|
||||
"android:build": "yarn build && npx cap sync android && npx cap run android",
|
||||
"i18n:update": "node scripts/update-translations.js",
|
||||
@@ -99,8 +100,7 @@
|
||||
"@electron/rebuild": "4.0.0",
|
||||
"@react-scan/vite-plugin-react-scan": "0.1.8",
|
||||
"@types/memoizee": "0.4.9",
|
||||
"@typescript-eslint/eslint-plugin": "8.29.0",
|
||||
"@typescript-eslint/parser": "8.29.0",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20251226.1",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"assert": "2.1.0",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
@@ -114,28 +114,25 @@
|
||||
"decompress": "4.2.1",
|
||||
"electron": "36.9.5",
|
||||
"electron-builder": "24.13.2",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
"eslint-plugin-react": "7.37.4",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"husky": "4.3.8",
|
||||
"isomorphic-fetch": "3.0.0",
|
||||
"jsdom": "27.3.0",
|
||||
"lint-staged": "12.3.8",
|
||||
"oxfmt": "0.20.0",
|
||||
"oxlint": "1.35.0",
|
||||
"progress": "2.0.3",
|
||||
"react-grab": "0.0.18",
|
||||
"react-scan": "0.4.3",
|
||||
"stream-browserify": "3.0.0",
|
||||
"vite": "6.4.1",
|
||||
"vite-plugin-eslint": "1.8.1",
|
||||
"vite-plugin-node-polyfills": "0.23.0",
|
||||
"vite": "npm:rolldown-vite@7.2.11",
|
||||
"vite-plugin-node-polyfills": "0.24.0",
|
||||
"vite-plugin-pwa": "0.21.1",
|
||||
"vitest": "4.0.15",
|
||||
"wait-on": "9.0.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"js-yaml": "4.1.1"
|
||||
"js-yaml": "4.1.1",
|
||||
"baseline-browser-mapping": "^2.9.11",
|
||||
"vite": "npm:rolldown-vite@7.2.11"
|
||||
},
|
||||
"main": "electron/main.js",
|
||||
"build": {
|
||||
@@ -176,8 +173,11 @@
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"{src,electron}/**/*.{js,ts,tsx}": [
|
||||
"prettier --write"
|
||||
"src/**/*.{js,ts,tsx}": [
|
||||
"oxfmt --write"
|
||||
],
|
||||
"electron/**/*.{js,mjs}": [
|
||||
"oxfmt --write"
|
||||
]
|
||||
},
|
||||
"husky": {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
module.exports = {
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
printWidth: 170,
|
||||
bracketSpacing: true,
|
||||
tabWidth: 2,
|
||||
trailingComma: 'all',
|
||||
jsxSingleQuote: true,
|
||||
arrowParens: 'always',
|
||||
useTabs: false,
|
||||
}
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "نسخ معرف المحتوى",
|
||||
"delete_avatar": "حذف الصورة الرمزية",
|
||||
"updates": "التحديثات",
|
||||
"downloading_board": "جارٍ تنزيل المنتدى"
|
||||
"downloading_board": "جارٍ تنزيل المنتدى",
|
||||
"subscriptions_subtitle": "لقد اشتركت في {{count}} لوحة",
|
||||
"subscriptions_subtitle_one": "لقد اشتركت في لوحة واحدة"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "কনটেন্ট আইডি কপি করুন",
|
||||
"delete_avatar": "অবতার মুছুন",
|
||||
"updates": "আপডেট",
|
||||
"downloading_board": "বোর্ড ডাউনলোড করা হচ্ছে"
|
||||
"downloading_board": "বোর্ড ডাউনলোড করা হচ্ছে",
|
||||
"subscriptions_subtitle": "আপনি {{count}}টি board-এ সাবস্ক্রাইব করেছেন",
|
||||
"subscriptions_subtitle_one": "আপনি ১টি board-এ সাবস্ক্রাইব করেছেন"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "kopírovat ID obsahu",
|
||||
"delete_avatar": "Smazat avatar",
|
||||
"updates": "Aktualizace",
|
||||
"downloading_board": "stahování fóra"
|
||||
"downloading_board": "stahování fóra",
|
||||
"subscriptions_subtitle": "Přihlásili jste se k {{count}} boardu",
|
||||
"subscriptions_subtitle_one": "Přihlásili jste se k {{count}} boardu"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "kopier indhold-ID",
|
||||
"delete_avatar": "Slet avatar",
|
||||
"updates": "Opdateringer",
|
||||
"downloading_board": "downloader forum"
|
||||
"downloading_board": "downloader forum",
|
||||
"subscriptions_subtitle": "Du har abonneret på {{count}} board",
|
||||
"subscriptions_subtitle_one": "Du har abonneret på {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "Inhalts-ID kopieren",
|
||||
"delete_avatar": "Avatar löschen",
|
||||
"updates": "Aktualisierungen",
|
||||
"downloading_board": "Forum wird heruntergeladen"
|
||||
"downloading_board": "Forum wird heruntergeladen",
|
||||
"subscriptions_subtitle": "Sie haben {{count}} Board abonniert",
|
||||
"subscriptions_subtitle_one": "Sie haben {{count}} Board abonniert"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "αντιγραφή ID περιεχομένου",
|
||||
"delete_avatar": "Διαγραφή avatar",
|
||||
"updates": "Ενημερώσεις",
|
||||
"downloading_board": "λήψη φόρουμ"
|
||||
"downloading_board": "λήψη φόρουμ",
|
||||
"subscriptions_subtitle": "Έχετε εγγραφεί σε {{count}} board",
|
||||
"subscriptions_subtitle_one": "Έχετε εγγραφεί σε {{count}} board"
|
||||
}
|
||||
|
||||
@@ -298,5 +298,7 @@
|
||||
"more_threads_last_year": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Year</1>]",
|
||||
"moderator_of_this_board": "This user is a moderator of this board",
|
||||
"administrator_of_this_board": "This user is an administrator of this board",
|
||||
"copy_content_id": "Copy content ID"
|
||||
"copy_content_id": "Copy content ID",
|
||||
"subscriptions_subtitle": "You have subscribed to {{count}} boards",
|
||||
"subscriptions_subtitle_one": "You have subscribed to {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "Copiar ID de contenido",
|
||||
"delete_avatar": "Eliminar avatar",
|
||||
"updates": "Actualizaciones",
|
||||
"downloading_board": "descargando foro"
|
||||
"downloading_board": "descargando foro",
|
||||
"subscriptions_subtitle": "Te has suscrito a {{count}} board",
|
||||
"subscriptions_subtitle_one": "Te has suscrito a {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "کپی شناسه محتوا",
|
||||
"delete_avatar": "حذف آواتار",
|
||||
"updates": "بهروزرسانیها",
|
||||
"downloading_board": "در حال دانلود تالار"
|
||||
"downloading_board": "در حال دانلود تالار",
|
||||
"subscriptions_subtitle": "شما به {{count}} board مشترک شدهاید",
|
||||
"subscriptions_subtitle_one": "شما به {{count}} board مشترک شدهاید"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "kopioi sisältö-ID",
|
||||
"delete_avatar": "Poista avatar",
|
||||
"updates": "Päivitykset",
|
||||
"downloading_board": "ladataan foorumia"
|
||||
"downloading_board": "ladataan foorumia",
|
||||
"subscriptions_subtitle": "Olet tilannut {{count}} boardia",
|
||||
"subscriptions_subtitle_one": "Olet tilannut {{count}} boardin"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "kopyahin ang content ID",
|
||||
"delete_avatar": "Tanggalin ang avatar",
|
||||
"updates": "Mga Update",
|
||||
"downloading_board": "nagda-download ng board"
|
||||
"downloading_board": "nagda-download ng board",
|
||||
"subscriptions_subtitle": "Nag-subscribe ka sa {{count}} na board",
|
||||
"subscriptions_subtitle_one": "Nag-subscribe ka sa {{count}} na board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "Copier l'ID du contenu",
|
||||
"delete_avatar": "Supprimer l'avatar",
|
||||
"updates": "Mises à jour",
|
||||
"downloading_board": "téléchargement du forum"
|
||||
"downloading_board": "téléchargement du forum",
|
||||
"subscriptions_subtitle": "Vous vous êtes abonné à {{count}} board",
|
||||
"subscriptions_subtitle_one": "Vous vous êtes abonné à {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "העתק מזהה תוכן",
|
||||
"delete_avatar": "מחק אווטר",
|
||||
"updates": "עדכונים",
|
||||
"downloading_board": "מוריד פורום"
|
||||
"downloading_board": "מוריד פורום",
|
||||
"subscriptions_subtitle": "נרשמת ל-{{count}} board",
|
||||
"subscriptions_subtitle_one": "נרשמת ל-board אחד"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "कंटेंट ID कॉपी करें",
|
||||
"delete_avatar": "अवतार हटाएं",
|
||||
"updates": "अपडेट",
|
||||
"downloading_board": "फोरम डाउनलोड हो रहा है"
|
||||
"downloading_board": "फोरम डाउनलोड हो रहा है",
|
||||
"subscriptions_subtitle": "आपने {{count}} board की सदस्यता ली है",
|
||||
"subscriptions_subtitle_one": "आपने {{count}} board की सदस्यता ली है"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "tartalom ID másolása",
|
||||
"delete_avatar": "Avatar törlése",
|
||||
"updates": "Frissítések",
|
||||
"downloading_board": "fórum letöltése"
|
||||
"downloading_board": "fórum letöltése",
|
||||
"subscriptions_subtitle": "Feliratkoztál {{count}} boardra",
|
||||
"subscriptions_subtitle_one": "Feliratkoztál {{count}} boardra"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "salin ID konten",
|
||||
"delete_avatar": "Hapus avatar",
|
||||
"updates": "Pembaruan",
|
||||
"downloading_board": "mengunduh forum"
|
||||
"downloading_board": "mengunduh forum",
|
||||
"subscriptions_subtitle": "Anda telah berlangganan {{count}} board",
|
||||
"subscriptions_subtitle_one": "Anda telah berlangganan {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "Copia ID contenuto",
|
||||
"delete_avatar": "Elimina avatar",
|
||||
"updates": "Aggiornamenti",
|
||||
"downloading_board": "scaricando board"
|
||||
"downloading_board": "scaricando board",
|
||||
"subscriptions_subtitle": "Ti sei iscritto a {{count}} board",
|
||||
"subscriptions_subtitle_one": "Ti sei iscritto a {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "コンテンツIDをコピー",
|
||||
"delete_avatar": "アバターを削除",
|
||||
"updates": "更新",
|
||||
"downloading_board": "掲示板をダウンロード中"
|
||||
"downloading_board": "掲示板をダウンロード中",
|
||||
"subscriptions_subtitle": "{{count}}個のboardを購読しています",
|
||||
"subscriptions_subtitle_one": "{{count}}個のboardを購読しています"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "콘텐츠 ID 복사",
|
||||
"delete_avatar": "아바타 삭제",
|
||||
"updates": "업데이트",
|
||||
"downloading_board": "게시판 다운로드 중"
|
||||
"downloading_board": "게시판 다운로드 중",
|
||||
"subscriptions_subtitle": "{{count}}개의 board를 구독했습니다",
|
||||
"subscriptions_subtitle_one": "{{count}}개의 board를 구독했습니다"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "कंटेंट ID कॉपी करा",
|
||||
"delete_avatar": "अवतार हटवा",
|
||||
"updates": "अपडेट्स",
|
||||
"downloading_board": "फोरम डाउनलोड करत आहे"
|
||||
"downloading_board": "फोरम डाउनलोड करत आहे",
|
||||
"subscriptions_subtitle": "तुम्ही {{count}} board ला सदस्यता घेतली आहे",
|
||||
"subscriptions_subtitle_one": "तुम्ही {{count}} board ला सदस्यता घेतली आहे"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "kopieer inhoud-ID",
|
||||
"delete_avatar": "Avatar verwijderen",
|
||||
"updates": "Updates",
|
||||
"downloading_board": "board downloaden"
|
||||
"downloading_board": "board downloaden",
|
||||
"subscriptions_subtitle": "Je bent geabonneerd op {{count}} board",
|
||||
"subscriptions_subtitle_one": "Je bent geabonneerd op {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "kopier innhold-ID",
|
||||
"delete_avatar": "Slett avatar",
|
||||
"updates": "Oppdateringer",
|
||||
"downloading_board": "laster ned forum"
|
||||
"downloading_board": "laster ned forum",
|
||||
"subscriptions_subtitle": "Du har abonnert på {{count}} board",
|
||||
"subscriptions_subtitle_one": "Du har abonnert på {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "skopiuj ID treści",
|
||||
"delete_avatar": "Usuń avatar",
|
||||
"updates": "Aktualizacje",
|
||||
"downloading_board": "pobieranie forum"
|
||||
"downloading_board": "pobieranie forum",
|
||||
"subscriptions_subtitle": "Subskrybujesz {{count}} board",
|
||||
"subscriptions_subtitle_one": "Subskrybujesz {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "Copiar ID do conteúdo",
|
||||
"delete_avatar": "Excluir avatar",
|
||||
"updates": "Atualizações",
|
||||
"downloading_board": "baixando fórum"
|
||||
"downloading_board": "baixando fórum",
|
||||
"subscriptions_subtitle": "Você se inscreveu em {{count}} board",
|
||||
"subscriptions_subtitle_one": "Você se inscreveu em {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "copiați ID-ul conținutului",
|
||||
"delete_avatar": "Șterge avatar",
|
||||
"updates": "Actualizări",
|
||||
"downloading_board": "se descarcă forumul"
|
||||
"downloading_board": "se descarcă forumul",
|
||||
"subscriptions_subtitle": "Te-ai abonat la {{count}} board",
|
||||
"subscriptions_subtitle_one": "Te-ai abonat la {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "копировать ID контента",
|
||||
"delete_avatar": "Удалить аватар",
|
||||
"updates": "Обновления",
|
||||
"downloading_board": "загрузка форума"
|
||||
"downloading_board": "загрузка форума",
|
||||
"subscriptions_subtitle": "Вы подписались на {{count}} board",
|
||||
"subscriptions_subtitle_one": "Вы подписались на {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "kopjo ID e përmbajtjes",
|
||||
"delete_avatar": "Fshi avatarin",
|
||||
"updates": "Përditësime",
|
||||
"downloading_board": "duke shkarkuar forum"
|
||||
"downloading_board": "duke shkarkuar forum",
|
||||
"subscriptions_subtitle": "Jeni abonuar në {{count}} board",
|
||||
"subscriptions_subtitle_one": "Jeni abonuar në {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "kopiera innehålls-ID",
|
||||
"delete_avatar": "Ta bort avatar",
|
||||
"updates": "Uppdateringar",
|
||||
"downloading_board": "laddar ner forum"
|
||||
"downloading_board": "laddar ner forum",
|
||||
"subscriptions_subtitle": "Du har prenumererat på {{count}} board",
|
||||
"subscriptions_subtitle_one": "Du har prenumererat på {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "కంటెంట్ ID కాపీ చేయండి",
|
||||
"delete_avatar": "అవతార్ను తొలగించు",
|
||||
"updates": "నవీకరణలు",
|
||||
"downloading_board": "ఫోరం డౌన్లోడ్ చేయడం"
|
||||
"downloading_board": "ఫోరం డౌన్లోడ్ చేయడం",
|
||||
"subscriptions_subtitle": "మీరు {{count}} boardకు సభ్యత్వం పొందారు",
|
||||
"subscriptions_subtitle_one": "మీరు {{count}} boardకు సభ్యత్వం పొందారు"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "คัดลอก ID เนื้อหา",
|
||||
"delete_avatar": "ลบอวตาร",
|
||||
"updates": "อัปเดต",
|
||||
"downloading_board": "กำลังดาวน์โหลดกระดาน"
|
||||
"downloading_board": "กำลังดาวน์โหลดกระดาน",
|
||||
"subscriptions_subtitle": "คุณได้สมัครสมาชิก {{count}} board แล้ว",
|
||||
"subscriptions_subtitle_one": "คุณได้สมัครสมาชิก {{count}} board แล้ว"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "içerik ID'sini kopyala",
|
||||
"delete_avatar": "Avatarı sil",
|
||||
"updates": "Güncellemeler",
|
||||
"downloading_board": "forum indiriliyor"
|
||||
"downloading_board": "forum indiriliyor",
|
||||
"subscriptions_subtitle": "{{count}} board'a abone oldunuz",
|
||||
"subscriptions_subtitle_one": "{{count}} board'a abone oldunuz"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "копіювати ID контенту",
|
||||
"delete_avatar": "Видалити аватар",
|
||||
"updates": "Оновлення",
|
||||
"downloading_board": "завантаження форуму"
|
||||
"downloading_board": "завантаження форуму",
|
||||
"subscriptions_subtitle": "Ви підписались на {{count}} board",
|
||||
"subscriptions_subtitle_one": "Ви підписались на {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "مواد کی شناخت کاپی کریں",
|
||||
"delete_avatar": "اوتار حذف کریں",
|
||||
"updates": "اپڈیٹس",
|
||||
"downloading_board": "فورم ڈاؤن لوڈ ہو رہا ہے"
|
||||
"downloading_board": "فورم ڈاؤن لوڈ ہو رہا ہے",
|
||||
"subscriptions_subtitle": "آپ نے {{count}} board کی سبسکرپشن لی ہے",
|
||||
"subscriptions_subtitle_one": "آپ نے {{count}} board کی سبسکرپشن لی ہے"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "sao chép ID nội dung",
|
||||
"delete_avatar": "Xóa avatar",
|
||||
"updates": "Cập nhật",
|
||||
"downloading_board": "đang tải diễn đàn"
|
||||
"downloading_board": "đang tải diễn đàn",
|
||||
"subscriptions_subtitle": "Bạn đã đăng ký {{count}} board",
|
||||
"subscriptions_subtitle_one": "Bạn đã đăng ký {{count}} board"
|
||||
}
|
||||
|
||||
@@ -299,5 +299,7 @@
|
||||
"copy_content_id": "复制内容ID",
|
||||
"delete_avatar": "删除头像",
|
||||
"updates": "更新",
|
||||
"downloading_board": "正在下载论坛"
|
||||
"downloading_board": "正在下载论坛",
|
||||
"subscriptions_subtitle": "您已订阅 {{count}} 个 board",
|
||||
"subscriptions_subtitle_one": "您已订阅 {{count}} 个 board"
|
||||
}
|
||||
|
||||
@@ -164,13 +164,8 @@ const App = () => (
|
||||
<Route path='/:boardIdentifier/catalog' element={null} />
|
||||
<Route path='/:boardIdentifier/catalog/settings' element={null} />
|
||||
|
||||
<Route path='/all/description' element={<Post />} />
|
||||
<Route path='/:boardIdentifier/thread/:commentCid' element={<Post />} />
|
||||
<Route path='/:boardIdentifier/thread/:commentCid/settings' element={<Post />} />
|
||||
<Route path='/:boardIdentifier/description' element={<Post />} />
|
||||
<Route path='/:boardIdentifier/description/settings' element={<Post />} />
|
||||
<Route path='/:boardIdentifier/rules' element={<Post />} />
|
||||
<Route path='/:boardIdentifier/rules/settings' element={<Post />} />
|
||||
|
||||
<Route path='/pending/:accountCommentIndex' element={<PendingPost />} />
|
||||
<Route path='/pending/:accountCommentIndex/settings' element={<PendingPost />} />
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAccountComment, useSubscribe } from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages';
|
||||
import { isAllView, isCatalogView, isDescriptionView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { isAllView, isCatalogView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
|
||||
import { getBoardPath, isDirectoryBoard } from '../../lib/utils/route-utils';
|
||||
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
||||
@@ -364,24 +363,19 @@ export const MobileBoardButtons = () => {
|
||||
const PostPageStats = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const isInDescriptionView = isDescriptionView(location.pathname, params);
|
||||
const resolvedAddress = useResolvedSubplebbitAddress();
|
||||
|
||||
const comment = useSubplebbitsPagesStore((state) => state.comments[params?.commentCid as string]);
|
||||
const subplebbit = useSubplebbitsStore((state) => state.subplebbits[resolvedAddress as string]);
|
||||
|
||||
const descriptionReplyCount = location?.pathname.startsWith('/all/') ? 0 : subplebbit?.rules?.length > 0 ? 1 : 0;
|
||||
const { closed, pinned, replyCount } = comment || {};
|
||||
const linkCount = useCountLinksInReplies(comment);
|
||||
|
||||
const displayReplyCount = replyCount !== undefined ? replyCount.toString() : isInDescriptionView ? descriptionReplyCount : '?';
|
||||
const replyCountTooltip = replyCount !== undefined || isInDescriptionView ? _.capitalize(t('replies')) : t('loading');
|
||||
const displayReplyCount = replyCount !== undefined ? replyCount.toString() : '?';
|
||||
const replyCountTooltip = replyCount !== undefined ? _.capitalize(t('replies')) : t('loading');
|
||||
|
||||
return (
|
||||
<span>
|
||||
{(pinned || isInDescriptionView) && `${_.capitalize(t('sticky'))} / `}
|
||||
{(closed || isInDescriptionView) && `${_.capitalize(t('closed'))} / `}
|
||||
{pinned && `${_.capitalize(t('sticky'))} / `}
|
||||
{closed && `${_.capitalize(t('closed'))} / `}
|
||||
<Tooltip children={displayReplyCount} content={replyCountTooltip} /> / <Tooltip children={linkCount?.toString()} content={_.capitalize(t('links'))} />
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ const BoardHeader = () => {
|
||||
|
||||
const account = useAccount() || {};
|
||||
const subscriptions = account?.subscriptions || [];
|
||||
const subscriptionsSubtitle = subscriptions?.length === 1 ? `${subscriptions?.length} subscription` : `${subscriptions?.length} subscriptions`;
|
||||
const subscriptionsSubtitle = t('subscriptions_subtitle', { count: subscriptions?.length || 0 });
|
||||
|
||||
const title = isInAllView
|
||||
? multisubMetadata?.title || '/all/ - 5chan Directories'
|
||||
@@ -71,7 +71,7 @@ const BoardHeader = () => {
|
||||
? shortAddress.endsWith('.eth') || shortAddress.endsWith('.sol')
|
||||
? shortAddress.slice(0, -4)
|
||||
: shortAddress
|
||||
: subplebbitAddress && Plebbit.getShortAddress(subplebbitAddress))}
|
||||
: subplebbitAddress && Plebbit.getShortAddress({ address: subplebbitAddress }))}
|
||||
{(isOffline || isOnlineStatusLoading) && !isInAllView && !isInSubscriptionsView && !isInModView && (
|
||||
<span className={styles.offlineIconWrapper}>
|
||||
<Tooltip content={offlineTitle}>
|
||||
|
||||
@@ -34,7 +34,7 @@ interface CatalogPostMediaProps {
|
||||
linkHeight?: number;
|
||||
}
|
||||
|
||||
export const CatalogPostMedia = ({ cid, commentMediaInfo, isOutOfFeed, linkWidth, linkHeight }: CatalogPostMediaProps) => {
|
||||
export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight }: CatalogPostMediaProps) => {
|
||||
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
|
||||
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
||||
const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
||||
@@ -58,7 +58,7 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, isOutOfFeed, linkWidth
|
||||
displayHeight = `${maxThumbnailSize}px`;
|
||||
}
|
||||
|
||||
if (type === 'audio' || isOutOfFeed) {
|
||||
if (type === 'audio') {
|
||||
displayWidth = 'unset';
|
||||
displayHeight = 'unset';
|
||||
}
|
||||
@@ -112,24 +112,7 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, isOutOfFeed, linkWidth
|
||||
|
||||
const CatalogPost = ({ post }: { post: Comment }) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
author,
|
||||
cid,
|
||||
content,
|
||||
isDescription,
|
||||
isRules,
|
||||
link,
|
||||
linkHeight,
|
||||
linkWidth,
|
||||
locked,
|
||||
pinned,
|
||||
replyCount,
|
||||
spoiler,
|
||||
subplebbitAddress,
|
||||
timestamp,
|
||||
title,
|
||||
thumbnailUrl,
|
||||
} = post || {};
|
||||
const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, spoiler, subplebbitAddress, timestamp, title, thumbnailUrl } = post || {};
|
||||
const linkCount = useCountLinksInReplies(post);
|
||||
|
||||
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||
@@ -145,7 +128,7 @@ const CatalogPost = ({ post }: { post: Comment }) => {
|
||||
const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : '';
|
||||
const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]);
|
||||
|
||||
const postLink = isInAllView && isDescription ? '/all/description' : `/${boardPath}/${isDescription ? 'description' : isRules ? 'rules' : `thread/${cid}`}`;
|
||||
const postLink = boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`;
|
||||
|
||||
const threadIcons = (
|
||||
<div className={styles.threadIcons}>
|
||||
@@ -236,12 +219,12 @@ const CatalogPost = ({ post }: { post: Comment }) => {
|
||||
'--maxHeight': maxHeight,
|
||||
} as React.CSSProperties;
|
||||
|
||||
const isTextOnlyThread = !hasThumbnail || isRules;
|
||||
const isTextOnlyThread = !hasThumbnail;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${styles.post} ${imageSize === 'Large' ? styles.large : ''}`} style={CSSProperties}>
|
||||
<div onMouseOver={() => setHoveredCid(isDescription ? 'd' : isRules ? 'r' : cid)} onMouseLeave={() => setHoveredCid(null)}>
|
||||
<div onMouseOver={() => setHoveredCid(cid)} onMouseLeave={() => setHoveredCid(null)}>
|
||||
{hidden ? (
|
||||
<Link to={postLink}>
|
||||
<span className={styles.hiddenThumbnail} />
|
||||
@@ -266,13 +249,7 @@ const CatalogPost = ({ post }: { post: Comment }) => {
|
||||
{spoiler ? (
|
||||
<img src='assets/spoiler.png' alt='' />
|
||||
) : (
|
||||
<CatalogPostMedia
|
||||
cid={cid}
|
||||
commentMediaInfo={commentMediaInfo}
|
||||
isOutOfFeed={isDescription || isRules}
|
||||
linkWidth={linkWidth}
|
||||
linkHeight={linkHeight}
|
||||
/>
|
||||
<CatalogPostMedia cid={cid} commentMediaInfo={commentMediaInfo} linkWidth={linkWidth} linkHeight={linkHeight} />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -295,7 +272,7 @@ const CatalogPost = ({ post }: { post: Comment }) => {
|
||||
<div className={styles.postContent}>{(showOPComment || isTextOnlyThread) && (hasThumbnail ? postContent : <Link to={postLink}>{postContent}</Link>)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{(hoveredCid === cid || isDescription) &&
|
||||
{hoveredCid === cid &&
|
||||
showPortal &&
|
||||
createPortal(
|
||||
<div className={styles.postPreview} ref={refs.setFloating} style={floatingStyles}>
|
||||
@@ -307,11 +284,11 @@ const CatalogPost = ({ post }: { post: Comment }) => {
|
||||
) : (
|
||||
t('posted_by')
|
||||
)}{' '}
|
||||
<span className={`${styles.postAuthor} ${(isCatalogPostAuthorMod || isRules || isDescription) && styles.capcode}`}>
|
||||
<span className={`${styles.postAuthor} ${isCatalogPostAuthorMod && styles.capcode}`}>
|
||||
{author?.displayName || _.capitalize(t('anonymous'))}
|
||||
{isCatalogPostAuthorMod && <span className='capitalize'>{` ## Board ${catalogPostAuthorRole}`}</span>}
|
||||
</span>
|
||||
{(isInAllView || isInSubscriptionsView) && subplebbitAddress && ` to p/${Plebbit.getShortAddress(subplebbitAddress)}`}
|
||||
{(isInAllView || isInSubscriptionsView) && subplebbitAddress && ` to p/${Plebbit.getShortAddress({ address: subplebbitAddress })}`}
|
||||
<span className={styles.postAgo}> {getFormattedTimeAgo(timestamp)}</span>
|
||||
{replyCount > 0 && (
|
||||
<div className={styles.postLast}>
|
||||
|
||||
@@ -23,7 +23,7 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// TODO: commentAuthor is not yet available outside of editedComment, wait for API to be updated
|
||||
const { cid, content, deleted, edit, isRules, original, parentCid, postCid, reason, removed, state } = post || {};
|
||||
const { cid, content, deleted, edit, original, parentCid, postCid, reason, removed, state } = post || {};
|
||||
// const banned = !!post?.commentAuthor?.banExpiresAt;
|
||||
|
||||
const [showFullComment, setShowFullComment] = useState(false);
|
||||
@@ -45,7 +45,7 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
||||
const loadingString = <div className={styles.stateString}>{stateString !== 'Failed' ? <LoadingEllipsis string={stateString || t('loading')} /> : stateString}</div>;
|
||||
|
||||
return (
|
||||
<blockquote className={`${styles.postMessage} ${!isReply && isMobile && styles.clampLines} ${isRules && styles.rulesMessage}`}>
|
||||
<blockquote className={`${styles.postMessage} ${!isReply && isMobile && styles.clampLines}`}>
|
||||
{isReply && state !== 'failed' && isReplyingToReply && !(deleted || removed) && <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={quotelinkReply} />}
|
||||
{removed ? (
|
||||
reason ? (
|
||||
@@ -116,7 +116,7 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
||||
<Tooltip
|
||||
children={`(${t('user_banned')})`}
|
||||
content={`${t('ban_expires_at', {
|
||||
address: subplebbitAddress && Plebbit.getShortAddress(subplebbitAddress),
|
||||
address: subplebbitAddress && Plebbit.getShortAddress({ address: subplebbitAddress }),
|
||||
timestamp: getFormattedDate(commentAuthor?.banExpiresAt),
|
||||
interpolation: { escapeValue: false },
|
||||
})}${reason ? `. ${_.capitalize(t('reason'))}: "${reason}"` : ''}`}
|
||||
|
||||
@@ -13,8 +13,6 @@ interface MediaProps {
|
||||
deleted?: boolean;
|
||||
displayHeight?: string;
|
||||
displayWidth?: string;
|
||||
isDescription?: boolean;
|
||||
isRules?: boolean;
|
||||
isFloatingEmbed?: boolean;
|
||||
isOutOfFeed?: boolean;
|
||||
isReply?: boolean;
|
||||
@@ -204,9 +202,7 @@ const Image = ({ commentMediaInfo, displayHeight, displayWidth, isOutOfFeed, par
|
||||
const CommentMedia = ({
|
||||
commentMediaInfo,
|
||||
deleted,
|
||||
isDescription,
|
||||
isFloatingEmbed,
|
||||
isRules,
|
||||
linkHeight,
|
||||
linkWidth,
|
||||
parentCid,
|
||||
@@ -251,7 +247,7 @@ const CommentMedia = ({
|
||||
displayWidth = '100%';
|
||||
displayHeight = '100%';
|
||||
}
|
||||
const isOutOfFeed = isDescription || isRules || isFloatingEmbed || spoiler || false; // virtuoso wrapper unneeded
|
||||
const isOutOfFeed = isFloatingEmbed || spoiler || false; // virtuoso wrapper unneeded
|
||||
|
||||
return (
|
||||
<span className={styles.content}>
|
||||
|
||||
@@ -32,7 +32,7 @@ const timestampToDays = (timestamp: number) => {
|
||||
const EditMenu = ({ post }: { post: Comment }) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const { author, cid, content, deleted, isRules, locked, parentCid, pinned, postCid, reason, removed, spoiler, subplebbitAddress } = post || {};
|
||||
const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler, subplebbitAddress } = post || {};
|
||||
const [isEditMenuOpen, setIsEditMenuOpen] = useState(false);
|
||||
const [isContentEditorOpen, setIsContentEditorOpen] = useState(false);
|
||||
|
||||
@@ -216,7 +216,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
setIsEditMenuOpen(!isEditMenuOpen);
|
||||
} else {
|
||||
setIsEditMenuOpen(false);
|
||||
alert(parentCid || isRules ? t('cannot_edit_reply') : t('cannot_edit_thread'));
|
||||
alert(parentCid ? t('cannot_edit_reply') : t('cannot_edit_thread'));
|
||||
}
|
||||
}}
|
||||
checked={isEditMenuOpen}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { Comment, useAuthorAvatar, useEditedComment } from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import Plebbit from '@plebbit/plebbit-js';
|
||||
import styles from '../../views/post/post.module.css';
|
||||
import { CommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getMediaDimensions } from '../../lib/utils/media-utils';
|
||||
@@ -53,13 +52,12 @@ const useShowOmittedReplies = create<ShowOmittedRepliesState>((set) => ({
|
||||
|
||||
const PostInfo = ({ post, postReplyCount = 0, roles, isHidden }: PostProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, shortCid, state, subplebbitAddress, timestamp } = post || {};
|
||||
const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, state, subplebbitAddress, timestamp } = post || {};
|
||||
const title = post?.title?.trim();
|
||||
const replies = useReplies(post);
|
||||
const { address, shortAddress } = author || {};
|
||||
const displayName = author?.displayName?.trim();
|
||||
const { isDescription, isRules } = post || {}; // custom properties, not from api
|
||||
const authorRole = roles?.[address]?.role.replace('moderator', 'mod') || (isDescription || isRules ? 'mod' : undefined);
|
||||
const authorRole = roles?.[address]?.role?.replace('moderator', 'mod');
|
||||
const stateString = useStateString(post);
|
||||
const isReply = parentCid;
|
||||
const { showOmittedReplies } = useShowOmittedReplies();
|
||||
@@ -71,10 +69,9 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden }: PostProps) => {
|
||||
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const isInPostPageView = isPostPageView(location.pathname, params);
|
||||
|
||||
const userID = address && Plebbit.getShortAddress(address);
|
||||
const userID = address && Plebbit.getShortAddress({ address }); // should not be shortened to less than 12 characters, because users can create unlimited addresses/IDs before authenticating or passing challenges, so if the ID is short enough they can spoof it to troll users with the same ID
|
||||
const userIDBackgroundColor = hashStringToColor(userID);
|
||||
const userIDTextColor = getTextColorForBackground(userIDBackgroundColor);
|
||||
|
||||
@@ -139,66 +136,60 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden }: PostProps) => {
|
||||
</span>
|
||||
)}{' '}
|
||||
</span>
|
||||
{!(isDescription || isRules) && (
|
||||
<>
|
||||
{author?.avatar && !(deleted || removed) && !hideAvatars && avatarImageUrl ? (
|
||||
<span className={styles.authorAvatar}>
|
||||
<img src={avatarImageUrl} alt='' />
|
||||
{author?.avatar && !(deleted || removed) && !hideAvatars && avatarImageUrl ? (
|
||||
<span className={styles.authorAvatar}>
|
||||
<img src={avatarImageUrl} alt='' />
|
||||
</span>
|
||||
) : null}
|
||||
(ID:{' '}
|
||||
{deleted ? (
|
||||
t('deleted')
|
||||
) : removed ? (
|
||||
t('removed')
|
||||
) : (
|
||||
<Tooltip
|
||||
children={
|
||||
<span
|
||||
title={t('highlight_posts')}
|
||||
className={styles.userAddress}
|
||||
onClick={() => handleUserAddressClick(userID, postCid)}
|
||||
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
|
||||
>
|
||||
{userID}
|
||||
</span>
|
||||
) : null}
|
||||
(ID:{' '}
|
||||
{deleted ? (
|
||||
t('deleted')
|
||||
) : removed ? (
|
||||
t('removed')
|
||||
) : (
|
||||
<Tooltip
|
||||
children={
|
||||
<span
|
||||
title={t('highlight_posts')}
|
||||
className={styles.userAddress}
|
||||
onClick={() => handleUserAddressClick(userID, postCid)}
|
||||
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
|
||||
>
|
||||
{userID}
|
||||
</span>
|
||||
}
|
||||
content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`}
|
||||
showTooltip={isInPostPageView || showOmittedReplies[postCid] || (postReplyCount < 6 && !pinned)}
|
||||
/>
|
||||
)}
|
||||
){' '}
|
||||
</>
|
||||
}
|
||||
content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`}
|
||||
showTooltip={isInPostPageView || showOmittedReplies[postCid] || (postReplyCount < 6 && !pinned)}
|
||||
/>
|
||||
)}
|
||||
){' '}
|
||||
</span>
|
||||
<span className={styles.dateTime}>
|
||||
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />
|
||||
{isDescription || isRules ? '' : ' '}
|
||||
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />{' '}
|
||||
</span>
|
||||
<span className={styles.postNum}>
|
||||
{!(isDescription || isRules) &&
|
||||
(cid ? (
|
||||
<span className={styles.postNumLink}>
|
||||
<Link
|
||||
to={boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`}
|
||||
className={styles.linkToPost}
|
||||
title={t('link_to_post')}
|
||||
onClick={(e) => !cid && e.preventDefault()}
|
||||
>
|
||||
CID:
|
||||
</Link>
|
||||
<span className={styles.replyToPost} title={t('reply_to_post')} onMouseDown={onReplyModalClick}>
|
||||
{shortCid}
|
||||
</span>
|
||||
{cid ? (
|
||||
<span className={styles.postNumLink}>
|
||||
<Link
|
||||
to={boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`}
|
||||
className={styles.linkToPost}
|
||||
title={t('link_to_post')}
|
||||
onClick={(e) => !cid && e.preventDefault()}
|
||||
>
|
||||
No.
|
||||
</Link>
|
||||
<span className={styles.replyToPost} title={t('reply_to_post')} onMouseDown={onReplyModalClick}>
|
||||
{post?.number || '?'}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span>CID:</span>
|
||||
<span className={styles.pendingCid}>
|
||||
{state === 'failed' || stateString === 'Failed' ? _.capitalize(t('failed')) : state === 'pending' ? _.capitalize(t('pending')) : ''}
|
||||
</span>
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span>CID:</span>
|
||||
<span className={styles.pendingCid}>
|
||||
{state === 'failed' || stateString === 'Failed' ? _.capitalize(t('failed')) : state === 'pending' ? _.capitalize(t('pending')) : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{pinned && (
|
||||
<span className={`${styles.stickyIconWrapper} ${!locked && styles.addPaddingBeforeReply}`}>
|
||||
<img src='assets/icons/sticky.gif' alt='' className={styles.stickyIcon} title={t('sticky')} />
|
||||
@@ -212,16 +203,7 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden }: PostProps) => {
|
||||
{!isInPostPageView && !isReply && !isHidden && (
|
||||
<span className={styles.replyButton}>
|
||||
[
|
||||
<Link
|
||||
to={
|
||||
isInAllView && isDescription
|
||||
? '/all/description'
|
||||
: boardPath
|
||||
? `/${boardPath}/${isDescription ? 'description' : isRules ? 'rules' : `thread/${postCid}`}`
|
||||
: `/${isDescription ? 'description' : isRules ? 'rules' : `thread/${postCid}`}`
|
||||
}
|
||||
onClick={(e) => !cid && !isDescription && !isRules && e.preventDefault()}
|
||||
>
|
||||
<Link to={boardPath ? `/${boardPath}/thread/${postCid}` : `/thread/${postCid}`} onClick={(e) => !cid && e.preventDefault()}>
|
||||
{_.capitalize(t('reply'))}
|
||||
</Link>
|
||||
]
|
||||
@@ -246,8 +228,6 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden }: PostProps) => {
|
||||
interface PostMediaProps {
|
||||
commentMediaInfo: CommentMediaInfo | undefined;
|
||||
hasThumbnail: boolean;
|
||||
isDescription: boolean;
|
||||
isRules: boolean;
|
||||
spoiler: boolean;
|
||||
deleted: boolean;
|
||||
removed: boolean;
|
||||
@@ -262,8 +242,6 @@ interface PostMediaProps {
|
||||
const PostMedia = ({
|
||||
commentMediaInfo,
|
||||
hasThumbnail,
|
||||
isDescription,
|
||||
isRules,
|
||||
spoiler,
|
||||
deleted,
|
||||
removed,
|
||||
@@ -330,14 +308,11 @@ const PostMedia = ({
|
||||
<CommentMedia
|
||||
commentMediaInfo={commentMediaInfo}
|
||||
deleted={deleted}
|
||||
isDescription={isDescription}
|
||||
isRules={isRules}
|
||||
removed={removed}
|
||||
linkHeight={linkHeight}
|
||||
linkWidth={linkWidth}
|
||||
showThumbnail={showThumbnail}
|
||||
setShowThumbnail={setShowThumbnail}
|
||||
isOutOfFeed={isDescription || isRules}
|
||||
parentCid={parentCid}
|
||||
spoiler={spoiler}
|
||||
/>
|
||||
@@ -356,7 +331,6 @@ const Reply = ({ postReplyCount, reply, roles }: PostProps) => {
|
||||
}
|
||||
|
||||
const { author, cid, deleted, link, linkHeight, linkWidth, postCid, reason, removed, spoiler, subplebbitAddress, thumbnailUrl, parentCid } = post || {};
|
||||
const { isDescription, isRules } = post || {}; // custom properties, not from api
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : undefined;
|
||||
|
||||
@@ -381,8 +355,6 @@ const Reply = ({ postReplyCount, reply, roles }: PostProps) => {
|
||||
<PostMedia
|
||||
commentMediaInfo={commentMediaInfo}
|
||||
hasThumbnail={hasThumbnail}
|
||||
isDescription={isDescription}
|
||||
isRules={isRules}
|
||||
spoiler={spoiler}
|
||||
deleted={deleted}
|
||||
removed={removed}
|
||||
@@ -403,7 +375,6 @@ const Reply = ({ postReplyCount, reply, roles }: PostProps) => {
|
||||
const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { author, cid, content, deleted, link, linkHeight, linkWidth, pinned, postCid, removed, spoiler, state, subplebbitAddress, thumbnailUrl, parentCid } = post || {};
|
||||
const { isDescription, isRules } = post || {}; // custom properties, not from api
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const isInPendingPostView = isPendingPostView(location.pathname, params);
|
||||
@@ -427,17 +398,6 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr
|
||||
|
||||
const stateString = useStateString(post) || t('downloading_board');
|
||||
|
||||
const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
|
||||
|
||||
const subplebbitRulesReply = {
|
||||
isRules: true,
|
||||
subplebbitAddress,
|
||||
timestamp: subplebbit?.createdAt,
|
||||
author: { displayName: _.capitalize(t('anonymous')) },
|
||||
content: `${subplebbit?.rules?.map((rule: string, index: number) => `${index + 1}. ${rule}`).join('\n')}`,
|
||||
replyCount: 0,
|
||||
};
|
||||
|
||||
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
||||
|
||||
@@ -451,7 +411,7 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr
|
||||
<div className={styles.replyQuotePreviewSpacer} />
|
||||
)}
|
||||
<div className={isHidden ? styles.postDesktopHidden : ''}>
|
||||
{!isInPostPageView && !isDescription && !isRules && showReplies && (
|
||||
{!isInPostPageView && showReplies && (
|
||||
<span className={`${styles.hideButtonWrapper} ${!hasThumbnail ? styles.hideButtonWrapperNoImage : ''}`}>
|
||||
<span className={`${styles.hideButton} ${hidden ? styles.unhideThread : styles.hideThread}`} onClick={hidden ? unhide : hide} />
|
||||
</span>
|
||||
@@ -462,8 +422,6 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr
|
||||
<PostMedia
|
||||
commentMediaInfo={commentMediaInfo}
|
||||
hasThumbnail={hasThumbnail}
|
||||
isDescription={isDescription}
|
||||
isRules={isRules}
|
||||
spoiler={spoiler}
|
||||
deleted={deleted}
|
||||
removed={removed}
|
||||
@@ -479,7 +437,7 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr
|
||||
{!isHidden && !content && !(deleted || removed) && <div className={styles.spacer} />}
|
||||
{!isHidden && <CommentContent comment={post} />}
|
||||
</div>
|
||||
{!isHidden && !isDescription && !isRules && !isInPendingPostView && (replyCount > 5 || (pinned && repliesCount > 0)) && !isInPostPageView && (
|
||||
{!isHidden && !isInPendingPostView && (replyCount > 5 || (pinned && repliesCount > 0)) && !isInPostPageView && (
|
||||
<span className={styles.summary}>
|
||||
<span
|
||||
className={`${showOmittedReplies[cid] ? styles.hideOmittedReplies : styles.showOmittedReplies} ${styles.omittedRepliesButtonWrapper}`}
|
||||
@@ -517,20 +475,8 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr
|
||||
<Reply reply={reply} roles={roles} postReplyCount={replyCount} />
|
||||
</div>
|
||||
))}
|
||||
{isDescription && subplebbit?.rules && subplebbit?.rules.length > 0 && (
|
||||
<div className={styles.replyContainer}>
|
||||
<Reply reply={subplebbitRulesReply} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isInPendingPostView &&
|
||||
(!isDescription || (isDescription && !subplebbit?.updatedAt)) &&
|
||||
!isRules &&
|
||||
stateString &&
|
||||
stateString !== 'Failed' &&
|
||||
state !== 'succeeded' &&
|
||||
isInPostPageView &&
|
||||
!(!showReplies && !showAllReplies) ? (
|
||||
{!isInPendingPostView && stateString && stateString !== 'Failed' && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? (
|
||||
<div className={styles.stateString}>
|
||||
<br />
|
||||
<LoadingEllipsis string={stateString} />
|
||||
|
||||
@@ -27,7 +27,11 @@ const CopyLinkButton = ({ cid, subplebbitAddress, linkType, onClose }: CopyLinkB
|
||||
<div
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType, cid);
|
||||
if (linkType === 'thread') {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType, cid);
|
||||
} else {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy share link', error);
|
||||
} finally {
|
||||
@@ -120,7 +124,7 @@ type PostMenuDesktopProps = {
|
||||
|
||||
const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { authorAddress, cid, isDescription, isRules, link, thumbnailUrl, linkWidth, linkHeight, postCid, subplebbitAddress } = postMenu || {};
|
||||
const { authorAddress, cid, link, thumbnailUrl, linkWidth, linkHeight, postCid, subplebbitAddress } = postMenu || {};
|
||||
const commentMediaInfo = getCommentMediaInfo(link || '', thumbnailUrl || '', linkWidth ?? 0, linkHeight ?? 0);
|
||||
const { thumbnail, type, url } = commentMediaInfo || {};
|
||||
const [menuBtnRotated, setMenuBtnRotated] = useState(false);
|
||||
@@ -148,7 +152,7 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
|
||||
const headingId = useId();
|
||||
|
||||
const handleMenuClick = () => {
|
||||
if (cid || isDescription || isRules) {
|
||||
if (cid) {
|
||||
setMenuBtnRotated((prev) => !prev);
|
||||
}
|
||||
};
|
||||
@@ -162,21 +166,19 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
|
||||
className={isInCatalogView ? styles.postMenuBtnCatalog : styles.postMenuBtn}
|
||||
title='Post menu'
|
||||
onClick={handleMenuClick}
|
||||
style={{ transform: menuBtnRotated && (cid || isDescription || isRules) ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
style={{ transform: menuBtnRotated && cid ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
</span>
|
||||
{menuBtnRotated &&
|
||||
(cid || isDescription || isRules) &&
|
||||
cid &&
|
||||
createPortal(
|
||||
<FloatingFocusManager context={context} modal={false}>
|
||||
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
|
||||
{cid && subplebbitAddress && <CopyLinkButton cid={cid} subplebbitAddress={subplebbitAddress} linkType='thread' onClose={handleClose} />}
|
||||
{cid && <CopyContentIdButton cid={cid} onClose={handleClose} />}
|
||||
{!cid && isDescription && subplebbitAddress && <CopyLinkButton subplebbitAddress={subplebbitAddress} linkType='description' onClose={handleClose} />}
|
||||
{!cid && isRules && subplebbitAddress && <CopyLinkButton subplebbitAddress={subplebbitAddress} linkType='rules' onClose={handleClose} />}
|
||||
{!(isInPostPageView && postCid === cid) && !isDescription && !isRules && (
|
||||
{!(isInPostPageView && postCid === cid) && (
|
||||
<div
|
||||
className={styles.postMenuItem}
|
||||
onClick={() => {
|
||||
@@ -188,8 +190,8 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
|
||||
</div>
|
||||
)}
|
||||
{link && isValidURL(link) && (type === 'image' || type === 'gif' || thumbnail) && url && <ImageSearchButton url={url} onClose={handleClose} />}
|
||||
{!isDescription && !isRules && authorAddress && <BlockUserButton address={authorAddress} />}
|
||||
{!isDescription && !isRules && (isInAllView || isInSubscriptionsView) && subplebbitAddress && <BlockBoardButton address={subplebbitAddress} />}
|
||||
{authorAddress && <BlockUserButton address={authorAddress} />}
|
||||
{(isInAllView || isInSubscriptionsView) && subplebbitAddress && <BlockBoardButton address={subplebbitAddress} />}
|
||||
</div>
|
||||
</FloatingFocusManager>,
|
||||
document.body,
|
||||
|
||||
@@ -8,7 +8,7 @@ import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/s
|
||||
import { getHasThumbnail, getLinkMediaInfo } from '../../lib/utils/media-utils';
|
||||
import { formatMarkdown } from '../../lib/utils/post-utils';
|
||||
import { isValidURL } from '../../lib/utils/url-utils';
|
||||
import { isAllView, isDescriptionView, isModView, isPostPageView, isRulesView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { isAllView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
|
||||
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||
@@ -335,7 +335,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
{isInModView &&
|
||||
accountSubplebbitAddresses.map((address: string) => (
|
||||
<option key={address} value={address}>
|
||||
{address && Plebbit.getShortAddress(address)}
|
||||
{address && Plebbit.getShortAddress({ address })}
|
||||
</option>
|
||||
))}
|
||||
{isInSubscriptionsView &&
|
||||
@@ -357,9 +357,7 @@ const PostForm = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const isInDescriptionView = isDescriptionView(location.pathname, params);
|
||||
const isInPostView = isPostPageView(location.pathname, params);
|
||||
const isInRulesView = isRulesView(location.pathname, params);
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const isInModView = isModView(location.pathname);
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
||||
@@ -374,7 +372,7 @@ const PostForm = () => {
|
||||
}
|
||||
|
||||
const { deleted, locked, removed, postCid } = comment || {};
|
||||
const isThreadClosed = deleted || locked || removed || isInDescriptionView || isInRulesView;
|
||||
const isThreadClosed = deleted || locked || removed;
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
|
||||
@@ -35,7 +35,11 @@ const CopyLinkButton = ({ cid, subplebbitAddress, linkType, onClose }: CopyLinkB
|
||||
<div
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType, cid);
|
||||
if (linkType === 'thread') {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType, cid);
|
||||
} else {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy share link', error);
|
||||
} finally {
|
||||
@@ -86,7 +90,7 @@ const ImageSearchButtons = ({ url, onClose }: { url: string; onClose: () => void
|
||||
|
||||
const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { hide, hidden, unhide } = useHide({ cid });
|
||||
const { hide, hidden, unhide } = useHide({ cid: cid || '' });
|
||||
const isInPostView = isPostPageView(useLocation().pathname, useParams());
|
||||
|
||||
return (
|
||||
@@ -131,10 +135,12 @@ type PostMenuMobileProps = {
|
||||
};
|
||||
|
||||
const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
|
||||
const { authorAddress, cid, deleted, isDescription, isRules, link, linkHeight, linkWidth, parentCid, postCid, removed, subplebbitAddress, thumbnailUrl } =
|
||||
postMenu || {};
|
||||
const { isAccountMod, isAccountCommentAuthor } = useEditCommentPrivileges({ commentAuthorAddress: authorAddress, subplebbitAddress });
|
||||
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||
const { authorAddress, cid, deleted, link, linkHeight, linkWidth, parentCid, postCid, removed, subplebbitAddress, thumbnailUrl } = postMenu || {};
|
||||
const { isAccountMod, isAccountCommentAuthor } = useEditCommentPrivileges({
|
||||
commentAuthorAddress: authorAddress || '',
|
||||
subplebbitAddress: subplebbitAddress || '',
|
||||
});
|
||||
const commentMediaInfo = getCommentMediaInfo(link || '', thumbnailUrl || '', linkWidth || 0, linkHeight || 0);
|
||||
const { thumbnail, type, url } = commentMediaInfo || {};
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
@@ -151,7 +157,7 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
|
||||
const headingId = useId();
|
||||
|
||||
const handleMenuClick = () => {
|
||||
if (cid || isDescription || isRules) {
|
||||
if (cid) {
|
||||
setIsMenuOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
@@ -168,17 +174,15 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
|
||||
...
|
||||
</span>
|
||||
{isMenuOpen &&
|
||||
(cid || isDescription || isRules) &&
|
||||
cid &&
|
||||
createPortal(
|
||||
<FloatingFocusManager context={context} modal={false}>
|
||||
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
|
||||
{cid && subplebbitAddress && <CopyLinkButton cid={cid} subplebbitAddress={subplebbitAddress} linkType='thread' onClose={handleClose} />}
|
||||
{cid && <CopyContentIdButton cid={cid} onClose={handleClose} />}
|
||||
{!cid && isDescription && subplebbitAddress && <CopyLinkButton subplebbitAddress={subplebbitAddress} linkType='description' onClose={handleClose} />}
|
||||
{!cid && isRules && subplebbitAddress && <CopyLinkButton subplebbitAddress={subplebbitAddress} linkType='rules' onClose={handleClose} />}
|
||||
{cid && subplebbitAddress && <HidePostButton cid={cid} isReply={!!parentCid} postCid={postCid} onClose={handleClose} />}
|
||||
{cid && subplebbitAddress && !isDescription && !isRules && authorAddress && <BlockUserButton address={authorAddress} />}
|
||||
{cid && subplebbitAddress && !isInBoardView && !isDescription && !isRules && <BlockBoardButton address={subplebbitAddress} />}
|
||||
{cid && subplebbitAddress && authorAddress && <BlockUserButton address={authorAddress} />}
|
||||
{cid && subplebbitAddress && !isInBoardView && <BlockBoardButton address={subplebbitAddress} />}
|
||||
{link && isValidURL(link) && (type === 'image' || type === 'gif' || thumbnail) && url && <ImageSearchButtons url={url} onClose={handleClose} />}
|
||||
</div>
|
||||
</FloatingFocusManager>,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { Comment, useAuthorAvatar, useEditedComment } from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import Plebbit from '@plebbit/plebbit-js';
|
||||
import styles from '../../views/post/post.module.css';
|
||||
import { shouldShowSnow } from '../../lib/snow';
|
||||
@@ -33,32 +32,14 @@ import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
||||
const PostInfoAndMedia = ({ post, postReplyCount = 0, roles }: PostProps) => {
|
||||
const { t } = useTranslation();
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
const {
|
||||
author,
|
||||
cid,
|
||||
deleted,
|
||||
link,
|
||||
linkHeight,
|
||||
linkWidth,
|
||||
locked,
|
||||
parentCid,
|
||||
pinned,
|
||||
postCid,
|
||||
reason,
|
||||
removed,
|
||||
shortCid,
|
||||
state,
|
||||
subplebbitAddress,
|
||||
timestamp,
|
||||
thumbnailUrl,
|
||||
} = post || {};
|
||||
const { author, cid, deleted, link, linkHeight, linkWidth, locked, parentCid, pinned, postCid, reason, removed, state, subplebbitAddress, timestamp, thumbnailUrl } =
|
||||
post || {};
|
||||
const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : undefined;
|
||||
const isReply = parentCid;
|
||||
const title = post?.title?.trim();
|
||||
const { isDescription, isRules } = post || {}; // custom properties, not from api
|
||||
const { address, shortAddress } = author || {};
|
||||
const displayName = author?.displayName?.trim();
|
||||
const authorRole = roles?.[address]?.role.replace('moderator', 'mod') || (isDescription || isRules ? 'mod' : undefined);
|
||||
const authorRole = roles?.[address]?.role?.replace('moderator', 'mod');
|
||||
const { imageUrl: avatarImageUrl } = useAuthorAvatar({ author });
|
||||
const { hideAvatars } = useAvatarVisibilityStore();
|
||||
|
||||
@@ -77,7 +58,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles }: PostProps) => {
|
||||
const handleUserAddressClick = useAuthorAddressClick();
|
||||
const numberOfPostsByAuthor = document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
|
||||
|
||||
const userID = address && Plebbit.getShortAddress(address);
|
||||
const userID = address && Plebbit.getShortAddress({ address }); // should not be shortened to less than 12 characters, because users can create unlimited addresses/IDs before authenticating or passing challenges, so if the ID is short enough they can spoof it to troll users with the same ID
|
||||
const userIDBackgroundColor = hashStringToColor(userID);
|
||||
const userIDTextColor = getTextColorForBackground(userIDBackgroundColor);
|
||||
|
||||
@@ -119,7 +100,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles }: PostProps) => {
|
||||
)
|
||||
) : (
|
||||
_.capitalize(t('anonymous'))
|
||||
)}
|
||||
)}{' '}
|
||||
{!(deleted || removed) && authorRole && (
|
||||
<span className='capitalize'>
|
||||
{' '}
|
||||
@@ -134,37 +115,33 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles }: PostProps) => {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!(isDescription || isRules) && (
|
||||
<>
|
||||
{author?.avatar && !(deleted || removed) && !hideAvatars && avatarImageUrl ? (
|
||||
<span className={styles.authorAvatar}>
|
||||
<img src={avatarImageUrl} alt='' />
|
||||
{author?.avatar && !(deleted || removed) && !hideAvatars && avatarImageUrl ? (
|
||||
<span className={styles.authorAvatar}>
|
||||
<img src={avatarImageUrl} alt='' />
|
||||
</span>
|
||||
) : null}
|
||||
(ID: {''}
|
||||
{removed ? (
|
||||
_.lowerCase(t('removed'))
|
||||
) : deleted ? (
|
||||
_.lowerCase(t('deleted'))
|
||||
) : (
|
||||
<Tooltip
|
||||
children={
|
||||
<span
|
||||
title={t('highlight_posts')}
|
||||
className={styles.userAddress}
|
||||
onClick={() => handleUserAddressClick(userID, postCid)}
|
||||
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
|
||||
>
|
||||
{userID}
|
||||
</span>
|
||||
) : null}
|
||||
(ID: {''}
|
||||
{removed ? (
|
||||
_.lowerCase(t('removed'))
|
||||
) : deleted ? (
|
||||
_.lowerCase(t('deleted'))
|
||||
) : (
|
||||
<Tooltip
|
||||
children={
|
||||
<span
|
||||
title={t('highlight_posts')}
|
||||
className={styles.userAddress}
|
||||
onClick={() => handleUserAddressClick(userID, postCid)}
|
||||
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
|
||||
>
|
||||
{userID}
|
||||
</span>
|
||||
}
|
||||
content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`}
|
||||
showTooltip={isInPostPageView || postReplyCount < 6}
|
||||
/>
|
||||
)}
|
||||
){' '}
|
||||
</>
|
||||
}
|
||||
content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`}
|
||||
showTooltip={isInPostPageView || postReplyCount < 6}
|
||||
/>
|
||||
)}
|
||||
){' '}
|
||||
{pinned && (
|
||||
<span className={styles.stickyIconWrapper}>
|
||||
<img src='assets/icons/sticky.gif' alt='' className={styles.stickyIcon} title={t('sticky')} />
|
||||
@@ -196,29 +173,28 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles }: PostProps) => {
|
||||
</div>
|
||||
)}
|
||||
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />{' '}
|
||||
{!(isDescription || isRules) &&
|
||||
(cid ? (
|
||||
<span className={styles.postNumLink}>
|
||||
<Link
|
||||
to={boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`}
|
||||
className={styles.linkToPost}
|
||||
title={t('link_to_post')}
|
||||
onClick={(e) => !cid && e.preventDefault()}
|
||||
>
|
||||
CID:
|
||||
</Link>
|
||||
<span className={styles.replyToPost} title={t('reply_to_post')} onMouseDown={onReplyModalClick}>
|
||||
{shortCid.slice(0, -4)}
|
||||
</span>
|
||||
{cid ? (
|
||||
<span className={styles.postNumLink}>
|
||||
<Link
|
||||
to={boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`}
|
||||
className={styles.linkToPost}
|
||||
title={t('link_to_post')}
|
||||
onClick={(e) => !cid && e.preventDefault()}
|
||||
>
|
||||
No.
|
||||
</Link>
|
||||
<span className={styles.replyToPost} title={t('reply_to_post')} onMouseDown={onReplyModalClick}>
|
||||
{post?.number || '?'}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span>CID:</span>
|
||||
<span className={styles.pendingCid}>
|
||||
{state === 'failed' || stateString === 'Failed' ? _.capitalize(t('failed')) : state === 'pending' ? _.capitalize(t('pending')) : ''}
|
||||
</span>
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span>CID:</span>
|
||||
<span className={styles.pendingCid}>
|
||||
{state === 'failed' || stateString === 'Failed' ? _.capitalize(t('failed')) : state === 'pending' ? _.capitalize(t('pending')) : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -229,7 +205,6 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles }: PostProps) => {
|
||||
|
||||
const PostMediaContent = ({ post, link }: { post: any; link: string }) => {
|
||||
const [showThumbnail, setShowThumbnail] = useState(true);
|
||||
const { isDescription, isRules } = post || {}; // custom properties, not from api
|
||||
const { thumbnailUrl, linkWidth, linkHeight, spoiler, deleted, removed, parentCid } = post || {};
|
||||
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
||||
@@ -239,14 +214,11 @@ const PostMediaContent = ({ post, link }: { post: any; link: string }) => {
|
||||
<CommentMedia
|
||||
commentMediaInfo={commentMediaInfo}
|
||||
deleted={deleted}
|
||||
isDescription={isDescription}
|
||||
isRules={isRules}
|
||||
removed={removed}
|
||||
linkHeight={linkHeight}
|
||||
linkWidth={linkWidth}
|
||||
showThumbnail={showThumbnail}
|
||||
setShowThumbnail={setShowThumbnail}
|
||||
isOutOfFeed={isDescription || isRules}
|
||||
parentCid={parentCid}
|
||||
spoiler={spoiler}
|
||||
/>
|
||||
@@ -310,10 +282,8 @@ const Reply = ({ postReplyCount, reply, roles }: PostProps) => {
|
||||
const PostMobile = ({ post, roles, showAllReplies, showReplies = true }: PostProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { author, cid, pinned, postCid, replyCount, state, subplebbitAddress } = post || {};
|
||||
const { isDescription, isRules } = post || {}; // custom properties, not from api
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const isInPendingPostView = isPendingPostView(location.pathname, params);
|
||||
const isInPostView = isPostPageView(location.pathname, params);
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
@@ -326,17 +296,6 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true }: PostPro
|
||||
|
||||
const stateString = useStateString(post) || t('loading_post');
|
||||
|
||||
const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
|
||||
const showRules = isDescription && subplebbit?.rules && subplebbit?.rules.length > 0;
|
||||
const subplebbitRulesReply = {
|
||||
isRules: true,
|
||||
subplebbitAddress,
|
||||
timestamp: subplebbit?.createdAt,
|
||||
author: { displayName: _.capitalize(t('anonymous')) },
|
||||
content: `${subplebbit?.rules?.map((rule: string, index: number) => `${index + 1}. ${rule}`).join('\n')}`,
|
||||
replyCount: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{hidden && !isInPostPageView ? (
|
||||
@@ -373,16 +332,7 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true }: PostPro
|
||||
{replyCount > 0 && `${replyCount} Replies`}
|
||||
{linksCount > 0 && ` / ${linksCount} Links`}
|
||||
</span>
|
||||
<Link
|
||||
to={
|
||||
isInAllView && isDescription
|
||||
? '/all/description'
|
||||
: boardPath
|
||||
? `/${boardPath}/${isDescription ? 'description' : isRules ? 'rules' : `thread/${cid}`}`
|
||||
: `/${isDescription ? 'description' : isRules ? 'rules' : `thread/${cid}`}`
|
||||
}
|
||||
className='button'
|
||||
>
|
||||
<Link to={boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`} className='button'>
|
||||
{t('view_thread')}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -400,20 +350,8 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true }: PostPro
|
||||
<Reply postReplyCount={replyCount} reply={reply} roles={roles} />
|
||||
</div>
|
||||
))}
|
||||
{showRules && (
|
||||
<div className={styles.replyContainer}>
|
||||
<Reply reply={subplebbitRulesReply} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isInPendingPostView &&
|
||||
(!isDescription || (isDescription && !subplebbit?.updatedAt)) &&
|
||||
!isRules &&
|
||||
stateString &&
|
||||
stateString !== 'Failed' &&
|
||||
state !== 'succeeded' &&
|
||||
isInPostPageView &&
|
||||
!(!showReplies && !showAllReplies) ? (
|
||||
{!isInPendingPostView && stateString && stateString !== 'Failed' && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? (
|
||||
<div className={styles.stateString}>
|
||||
<LoadingEllipsis string={stateString} />
|
||||
</div>
|
||||
|
||||
@@ -178,7 +178,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, postCid, scrollY, s
|
||||
}
|
||||
}, []);
|
||||
|
||||
const contentPrefix = `>>${parentCid && Plebbit.getShortCid(parentCid)}\n`;
|
||||
const contentPrefix = `>>${parentCid && Plebbit.getShortCid({ cid: parentCid })}\n`;
|
||||
|
||||
// enable spellcheck after the prefix is set
|
||||
useEffect(() => {
|
||||
@@ -259,7 +259,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, postCid, scrollY, s
|
||||
}}
|
||||
>
|
||||
<div className={`replyModalHandle ${styles.title}`} {...(!isMobile ? bind() : {})}>
|
||||
{t('reply_to_cid', { cid: `CID:${parentCid && Plebbit.getShortCid(parentCid)}`, interpolation: { escapeValue: false } })}
|
||||
{t('reply_to_cid', { cid: `CID:${parentCid && Plebbit.getShortCid({ cid: parentCid })}`, interpolation: { escapeValue: false } })}
|
||||
<button
|
||||
className={styles.closeIcon}
|
||||
onClick={(e) => {
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ const BlockedAddressesSetting = () => {
|
||||
<ul className={styles.blockedAddresses}>
|
||||
{addressList.map((address: string) => (
|
||||
<li key={address} className={styles.blockedAddress}>
|
||||
{address && Plebbit.getShortAddress(address)} <UnblockButton address={address} />
|
||||
{address && Plebbit.getShortAddress({ address })} <UnblockButton address={address} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -127,7 +127,7 @@ const InterfaceLanguage = () => {
|
||||
const InterfaceSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const { hideAvatars, setHideAvatars } = useAvatarVisibilityStore();
|
||||
const { hideGoreBoards, setHideGoreBoards, hideAdultBoards, setHideAdultBoards, hideThreadsWithoutImages, setHideThreadsWithoutImages } = useInterfaceSettingsStore();
|
||||
const { hideThreadsWithoutImages, setHideThreadsWithoutImages } = useInterfaceSettingsStore();
|
||||
const { setShowTextOnlyThreads } = useCatalogFiltersStore();
|
||||
const { fitExpandedImagesToScreen, setFitExpandedImagesToScreen } = useExpandedMediaStore();
|
||||
|
||||
@@ -163,18 +163,6 @@ const InterfaceSettings = () => {
|
||||
</label>
|
||||
<div className={styles.settingTip}>{_.capitalize(t('threads_without_images_tip'))}</div>
|
||||
</div>
|
||||
<div className={styles.setting}>
|
||||
<label>
|
||||
<input type='checkbox' checked={hideGoreBoards} onChange={(e) => setHideGoreBoards(e.target.checked)} /> {_.capitalize(t('hide_gore_boards'))}
|
||||
</label>
|
||||
<div className={styles.settingTip}>{_.capitalize(t('hide_gore_boards_tip'))}</div>
|
||||
</div>
|
||||
<div className={styles.setting}>
|
||||
<label>
|
||||
<input type='checkbox' checked={hideAdultBoards} onChange={(e) => setHideAdultBoards(e.target.checked)} /> {_.capitalize(t('hide_adult_boards'))}
|
||||
</label>
|
||||
<div className={styles.settingTip}>{_.capitalize(t('hide_adult_boards_tip'))}</div>
|
||||
</div>
|
||||
<div className={styles.setting}>
|
||||
<label>
|
||||
<input type='checkbox' checked={fitExpandedImagesToScreen} onChange={(e) => setFitExpandedImagesToScreen(e.target.checked)} />
|
||||
|
||||
@@ -57,7 +57,7 @@ const SubscriptionsSetting = () => {
|
||||
<ul className={styles.subscriptions}>
|
||||
{subscriptions?.map((address: string) => (
|
||||
<li key={address} className={styles.subscription}>
|
||||
{address && Plebbit.getShortAddress(address)} <SubscriptionButton address={address} />
|
||||
{address && Plebbit.getShortAddress({ address })} <SubscriptionButton address={address} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './subplebbit-description';
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Post } from '../../views/post';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isAllView } from '../../lib/utils/view-utils';
|
||||
import { useMultisubMetadata } from '../../hooks/use-default-subplebbits';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import _ from 'lodash';
|
||||
|
||||
interface DescriptionPostProps {
|
||||
avatarUrl?: string;
|
||||
createdAt: number;
|
||||
description: string;
|
||||
replyCount: number;
|
||||
shortAddress: string;
|
||||
subplebbitAddress: string | undefined;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const SubplebbitDescription = ({ avatarUrl, createdAt, description, replyCount, shortAddress, subplebbitAddress, title }: DescriptionPostProps) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const multisubMetadata = useMultisubMetadata();
|
||||
const descriptionSubplebbitAddress = isInAllView ? 'all' : subplebbitAddress;
|
||||
|
||||
const post = {
|
||||
isDescription: true,
|
||||
subplebbitAddress: descriptionSubplebbitAddress,
|
||||
timestamp: isInAllView ? multisubMetadata?.createdAt : createdAt,
|
||||
author: { displayName: _.capitalize(t('anonymous')) },
|
||||
content: isInAllView ? multisubMetadata?.description : description,
|
||||
link: avatarUrl,
|
||||
replyCount,
|
||||
title: t('welcome_to_board', {
|
||||
board: isInAllView ? multisubMetadata?.title : title || `p/${shortAddress}`,
|
||||
interpolation: { escapeValue: false },
|
||||
}),
|
||||
pinned: true,
|
||||
locked: true,
|
||||
};
|
||||
|
||||
return <Post post={post} />;
|
||||
};
|
||||
|
||||
export default SubplebbitDescription;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './subplebbit-rules';
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Post } from '../../views/post';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import _ from 'lodash';
|
||||
|
||||
interface RulesPostProps {
|
||||
subplebbitAddress: string | undefined;
|
||||
createdAt: number;
|
||||
rules: string[];
|
||||
}
|
||||
|
||||
const SubplebbitRules = ({ subplebbitAddress, createdAt, rules }: RulesPostProps) => {
|
||||
const { t } = useTranslation();
|
||||
const content = rules?.map((rule, index) => `${index + 1}. ${rule}`).join('\n');
|
||||
const post = {
|
||||
isRules: true,
|
||||
subplebbitAddress,
|
||||
timestamp: createdAt,
|
||||
author: { displayName: _.capitalize(t('anonymous')) },
|
||||
content,
|
||||
replyCount: 0,
|
||||
title: _.capitalize(t('rules')),
|
||||
pinned: true,
|
||||
locked: true,
|
||||
};
|
||||
|
||||
return <Post post={post} />;
|
||||
};
|
||||
|
||||
export default SubplebbitRules;
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useAccountComment, useSubplebbitStats } from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages';
|
||||
import useSubplebbitStatsVisibilityStore from '../../stores/use-subplebbit-stats-visibility-store';
|
||||
import { isDescriptionView, isRulesView } from '../../lib/utils/view-utils';
|
||||
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
||||
import styles from './subplebbit-stats.module.css';
|
||||
|
||||
@@ -23,13 +22,9 @@ const SubplebbitStats = () => {
|
||||
const { hiddenStats, toggleVisibility } = useSubplebbitStatsVisibilityStore();
|
||||
const isHidden = hiddenStats[address];
|
||||
|
||||
const location = useLocation();
|
||||
const isInDescriptionView = isDescriptionView(location.pathname, params);
|
||||
const isInRulesView = isRulesView(location.pathname, params);
|
||||
|
||||
const comment = useSubplebbitsPagesStore((state) => state.comments[params?.commentCid as string]);
|
||||
const { deleted, locked, removed } = comment || {};
|
||||
const hideStats = deleted || locked || removed || isInDescriptionView || isInRulesView;
|
||||
const hideStats = deleted || locked || removed;
|
||||
|
||||
const unixToMMDDYYYY = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
|
||||
@@ -166,7 +166,7 @@ const TopBarDesktop = () => {
|
||||
// Render a subscription link
|
||||
const renderSubscription = (address: string, index: number, total: number) => {
|
||||
const boardPath = getBoardPath(address, defaultSubplebbits);
|
||||
const displayText = address.endsWith('.eth') || address.endsWith('.sol') ? address : Plebbit.getShortAddress(address);
|
||||
const displayText = address.endsWith('.eth') || address.endsWith('.sol') ? address : Plebbit.getShortAddress({ address });
|
||||
|
||||
return (
|
||||
<span key={address}>
|
||||
|
||||
@@ -33,7 +33,7 @@ export const BOARD_CODE_TO_DIRECTORY: Record<string, string> = {
|
||||
i: 'Oekaki',
|
||||
ic: 'Artwork/Critique',
|
||||
r9k: 'ROBOT9001',
|
||||
s4s: 'Shit 4chan Says',
|
||||
s5s: 'Shit 5chan Says',
|
||||
vip: 'Very Important Posts',
|
||||
cm: 'Cute/Male',
|
||||
hm: 'Handsome Men',
|
||||
@@ -90,8 +90,8 @@ export const BOARD_CODE_GROUPS: string[][] = [
|
||||
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'gif', 'h', 'hr', 'k', 'm', 'o', 'p', 'r', 's', 't', 'u', 'v', 'vg', 'vm', 'vmg', 'vr', 'vrpg', 'vst', 'w', 'wg'],
|
||||
// Group 2: [i / ic]
|
||||
['i', 'ic'],
|
||||
// Group 3: [r9k / s4s / vip]
|
||||
['r9k', 's4s', 'vip'],
|
||||
// Group 3: [r9k / s5s / vip]
|
||||
['r9k', 's5s', 'vip'],
|
||||
// Group 4: [cm / hm / lgbt / y]
|
||||
['cm', 'hm', 'lgbt', 'y'],
|
||||
// Group 5: [3 / aco / adv / an / bant / biz / cgl / ck / co / diy / fa / fit / gd / hc / his / int / jp / lit / mlp / mu / n / news / out / po / pol / pw / qst / sci / soc / sp / tg / toy / trv / tv / vp / vt / wsg / wsr / x / xs]
|
||||
|
||||
@@ -8,7 +8,7 @@ interface AuthorPrivilegesProps {
|
||||
postCid?: string;
|
||||
}
|
||||
|
||||
const useAuthorPrivileges = ({ commentAuthorAddress, subplebbitAddress, postCid }: AuthorPrivilegesProps) => {
|
||||
const useAuthorPrivileges = ({ commentAuthorAddress, subplebbitAddress }: AuthorPrivilegesProps) => {
|
||||
const account = useAccount();
|
||||
const accountAuthorAddress = account?.author?.address;
|
||||
const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
|
||||
|
||||
@@ -1,34 +1,17 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAccountComments, Subplebbit } from '@plebbit/plebbit-react-hooks';
|
||||
import useInterfaceSettingsStore from '../stores/use-interface-settings-store';
|
||||
import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils';
|
||||
import { isAllView } from '../lib/utils/view-utils';
|
||||
import { useMultisubMetadata } from './use-default-subplebbits';
|
||||
import _ from 'lodash';
|
||||
import useCatalogFiltersStore from '../stores/use-catalog-filters-store';
|
||||
|
||||
const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolean, subplebbit: Subplebbit) => {
|
||||
const { t } = useTranslation();
|
||||
const { address, createdAt, description, rules, shortAddress, suggested, title } = subplebbit || {};
|
||||
const { avatarUrl } = suggested || {};
|
||||
const { address } = subplebbit || {};
|
||||
const { hideThreadsWithoutImages } = useInterfaceSettingsStore();
|
||||
|
||||
const location = useLocation();
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const multisub = useMultisubMetadata();
|
||||
|
||||
const { accountComments } = useAccountComments();
|
||||
const { searchText } = useCatalogFiltersStore();
|
||||
|
||||
const feedWithFakePostsOnTop = useMemo(() => {
|
||||
if (!isFeedLoaded) {
|
||||
return []; // prevent rules and description from appearing while feed is loading
|
||||
}
|
||||
|
||||
if (!description && !rules && !isInAllView) {
|
||||
return feed;
|
||||
return []; // prevent temporary/mock posts from appearing while the actual feed is loading
|
||||
}
|
||||
|
||||
const _feed = [...feed];
|
||||
@@ -65,53 +48,8 @@ const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolea
|
||||
);
|
||||
}
|
||||
|
||||
// add subplebbit description and rules as fake posts at the top of the feed
|
||||
if (description && description.length > 0 && !searchText) {
|
||||
_feed.unshift({
|
||||
isDescription: true,
|
||||
subplebbitAddress: address,
|
||||
timestamp: createdAt,
|
||||
author: { displayName: `## ${t('board_mods')}` },
|
||||
content: isInAllView ? multisub?.description : description,
|
||||
link: avatarUrl,
|
||||
title: t('welcome_to_board', { board: isInAllView ? multisub?.title : title || `p/${shortAddress}`, interpolation: { escapeValue: false } }),
|
||||
pinned: true,
|
||||
locked: true,
|
||||
});
|
||||
}
|
||||
|
||||
// rules are shown in description thread if both are set
|
||||
if (rules && rules.length > 0 && !description && !searchText) {
|
||||
_feed.unshift({
|
||||
isRules: true,
|
||||
subplebbitAddress: address,
|
||||
timestamp: createdAt,
|
||||
author: { displayName: `## ${t('board_mods')}` },
|
||||
content: rules.map((rule: string, index: number) => `${index + 1}. ${rule}`).join('\n'),
|
||||
title: _.capitalize(t('rules')),
|
||||
pinned: true,
|
||||
locked: true,
|
||||
});
|
||||
}
|
||||
|
||||
return _feed;
|
||||
}, [
|
||||
accountComments,
|
||||
feed,
|
||||
description,
|
||||
rules,
|
||||
address,
|
||||
isFeedLoaded,
|
||||
createdAt,
|
||||
title,
|
||||
shortAddress,
|
||||
avatarUrl,
|
||||
t,
|
||||
isInAllView,
|
||||
multisub,
|
||||
hideThreadsWithoutImages,
|
||||
searchText,
|
||||
]);
|
||||
}, [accountComments, feed, address, isFeedLoaded, hideThreadsWithoutImages]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const rows = [];
|
||||
|
||||
@@ -53,11 +53,6 @@ const useReplies = (comment: Comment) => {
|
||||
});
|
||||
}, [flattenedReplies, accountRepliesNotYetPublished]);
|
||||
|
||||
// if the comment is a fake comment for description or rules, return an empty array
|
||||
if (comment?.isDescription || comment?.isRules) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return repliesAndNotYetPublishedReplies;
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const useReplyModal = () => {
|
||||
|
||||
const getSelectedText = () => {
|
||||
let text = document.getSelection()?.toString();
|
||||
text && setSelectedText(`>${text}\n`);
|
||||
if (text) setSelectedText(`>${text}\n`);
|
||||
};
|
||||
|
||||
const openReplyModal = (parentCid: string, postCid: string, subplebbitAddress: string) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ const getClientHost = (clientUrl: string): string => {
|
||||
if (!clientHosts[clientUrl]) {
|
||||
try {
|
||||
clientHosts[clientUrl] = new URL(clientUrl).hostname || clientUrl;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
clientHosts[clientUrl] = clientUrl;
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export const useFeedStateString = (subplebbitAddresses?: string[]): string | und
|
||||
}
|
||||
|
||||
// find all client urls
|
||||
const clientHosts = new Set([...pagesStatesClientHosts]);
|
||||
const clientHosts = new Set(pagesStatesClientHosts);
|
||||
states['fetching-ipns']?.clientUrls.forEach((clientUrl) => clientHosts.add(getClientHost(clientUrl)));
|
||||
states['fetching-ipfs']?.clientUrls.forEach((clientUrl) => clientHosts.add(getClientHost(clientUrl)));
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ const useTimeFilter = () => {
|
||||
} else if (timeFilterName) {
|
||||
try {
|
||||
timeFilterSeconds = convertTimeStringToSeconds(timeFilterName);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
console.error(`Invalid time filter format: ${timeFilterName}`);
|
||||
timeFilterSeconds = undefined;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ export type PostMenuProps = {
|
||||
postCid?: string;
|
||||
parentCid?: string;
|
||||
subplebbitAddress?: string;
|
||||
isDescription?: boolean;
|
||||
isRules?: boolean;
|
||||
authorAddress?: string;
|
||||
link?: string;
|
||||
linkWidth?: number;
|
||||
@@ -21,8 +19,6 @@ export const selectPostMenuProps = (post?: Comment): PostMenuProps => ({
|
||||
postCid: post?.postCid,
|
||||
parentCid: post?.parentCid,
|
||||
subplebbitAddress: post?.subplebbitAddress,
|
||||
isDescription: post?.isDescription,
|
||||
isRules: post?.isRules,
|
||||
authorAddress: post?.author?.address,
|
||||
link: post?.link,
|
||||
linkWidth: post?.linkWidth,
|
||||
|
||||
@@ -105,8 +105,6 @@ export const isFeedRoute = (pathname: string): boolean => {
|
||||
const normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
|
||||
if (normalizedPath.includes('/thread/')) return false;
|
||||
if (normalizedPath.endsWith('/description')) return false;
|
||||
if (normalizedPath.endsWith('/rules')) return false;
|
||||
if (normalizedPath.startsWith('/pending/')) return false;
|
||||
|
||||
const pathWithoutSettings = normalizedPath.replace(/\/settings$/, '');
|
||||
@@ -130,8 +128,6 @@ export const isPostRoute = (pathname: string): boolean => {
|
||||
const normalizedPath = pathname.replace(/\/settings$/, '');
|
||||
|
||||
if (normalizedPath.includes('/thread/')) return true;
|
||||
if (normalizedPath.endsWith('/description')) return true;
|
||||
if (normalizedPath.endsWith('/rules')) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -150,10 +146,6 @@ export const getFeedCacheKey = (pathname: string): string | null => {
|
||||
return parts[0] || null;
|
||||
}
|
||||
|
||||
if (normalizedPath.endsWith('/description') || normalizedPath.endsWith('/rules')) {
|
||||
return normalizedPath.replace(/\/(description|rules)$/, '');
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('/pending/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ export const isValidURL = (url: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export type ShareLinkType = 'thread' | 'description' | 'rules';
|
||||
export type ShareLinkType = 'thread' | 'catalog';
|
||||
|
||||
// Copies a share link to clipboard for a board, thread, description, or rules page
|
||||
export function copyShareLinkToClipboard(boardIdentifier: string, linkType: 'thread', cid: string): Promise<void>;
|
||||
export function copyShareLinkToClipboard(boardIdentifier: string, linkType: Exclude<ShareLinkType, 'thread'>, cid?: undefined): Promise<void>;
|
||||
export function copyShareLinkToClipboard(boardIdentifier: string, linkType: Exclude<ShareLinkType, 'thread'>): Promise<void>;
|
||||
export async function copyShareLinkToClipboard(boardIdentifier: string, linkType: ShareLinkType, cid?: string): Promise<void> {
|
||||
if (linkType === 'thread') {
|
||||
if (!cid) {
|
||||
@@ -71,12 +71,10 @@ export const is5chanLink = (url: string): boolean => {
|
||||
// - /{boardIdentifier} (directory code or address)
|
||||
// - /{boardIdentifier}/thread/{commentCid}
|
||||
// - /{boardIdentifier}/catalog
|
||||
// - /{boardIdentifier}/description
|
||||
// - /{boardIdentifier}/rules
|
||||
// - /all, /subs, /mod, /pending/{index}
|
||||
return (
|
||||
/^\/p\/[^/]+(\/c\/[^/]+)?$/.test(routePath) ||
|
||||
/^\/[^/]+(\/thread\/[^/]+|\/catalog|\/description|\/rules)?$/.test(routePath) ||
|
||||
/^\/[^/]+(\/thread\/[^/]+|\/catalog)?$/.test(routePath) ||
|
||||
/^\/(all|subscriptions|mod)(\/catalog|\/thread\/[^/]+)?(\/[^/]+)?$/.test(routePath) ||
|
||||
/^\/pending\/[^/]+$/.test(routePath)
|
||||
);
|
||||
|
||||
@@ -51,12 +51,6 @@ export const isCatalogView = (pathname: string, params: ParamsType): boolean =>
|
||||
);
|
||||
};
|
||||
|
||||
export const isDescriptionView = (pathname: string, params: ParamsType): boolean => {
|
||||
const decodedPathname = decodeURIComponent(pathname);
|
||||
const identifier = params.boardIdentifier || params.subplebbitAddress;
|
||||
return pathname === '/all/description' ? true : identifier ? decodedPathname.startsWith(`/${identifier}/description`) : false;
|
||||
};
|
||||
|
||||
export const isHomeView = (pathname: string): boolean => {
|
||||
return pathname === '/';
|
||||
};
|
||||
@@ -70,29 +64,17 @@ export const isPendingPostView = (pathname: string, params: ParamsType): boolean
|
||||
};
|
||||
|
||||
export const isPostPageView = (pathname: string, params: ParamsType): boolean => {
|
||||
if (isDescriptionView(pathname, params) || isRulesView(pathname, params)) {
|
||||
return true;
|
||||
}
|
||||
const decodedPathname = decodeURIComponent(pathname);
|
||||
const identifier = params.boardIdentifier || params.subplebbitAddress;
|
||||
return identifier && params.commentCid ? decodedPathname.startsWith(`/${identifier}/thread/${params.commentCid}`) : false;
|
||||
};
|
||||
|
||||
export const isRulesView = (pathname: string, params: ParamsType): boolean => {
|
||||
const decodedPathname = decodeURIComponent(pathname);
|
||||
const identifier = params.boardIdentifier || params.subplebbitAddress;
|
||||
return identifier ? decodedPathname.startsWith(`/${identifier}/rules`) : false;
|
||||
};
|
||||
|
||||
export const isSettingsView = (pathname: string, params: ParamsType): boolean => {
|
||||
const { accountCommentIndex, boardIdentifier, commentCid, subplebbitAddress } = params;
|
||||
const identifier = boardIdentifier || subplebbitAddress;
|
||||
const decodedPathname = decodeURIComponent(pathname);
|
||||
return (
|
||||
(identifier && commentCid && decodedPathname === `/${identifier}/thread/${commentCid}/settings`) ||
|
||||
(identifier && decodedPathname === `/${identifier}/description/settings`) ||
|
||||
(identifier && decodedPathname === `/${identifier}/rules/settings`) ||
|
||||
decodedPathname === `/pending/${accountCommentIndex}/settings`
|
||||
(identifier && commentCid && decodedPathname === `/${identifier}/thread/${commentCid}/settings`) || decodedPathname === `/pending/${accountCommentIndex}/settings`
|
||||
);
|
||||
};
|
||||
|
||||
@@ -115,11 +97,9 @@ export const isNotFoundView = (pathname: string, params: ParamsType): boolean =>
|
||||
!isAllView(pathname) &&
|
||||
!isBoardView(pathname, params) &&
|
||||
!isCatalogView(pathname, params) &&
|
||||
!isDescriptionView(pathname, params) &&
|
||||
!isHomeView(pathname) &&
|
||||
!isPendingPostView(pathname, params) &&
|
||||
!isPostPageView(pathname, params) &&
|
||||
!isRulesView(pathname, params) &&
|
||||
!isSettingsView(pathname, params) &&
|
||||
!isSubscriptionsView(pathname, params)
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ interface DisclaimerModalState {
|
||||
const hasAcceptedDisclaimer = (): boolean => {
|
||||
try {
|
||||
return localStorage.getItem(DISCLAIMER_ACCEPTED_KEY) === 'true';
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,10 +2,6 @@ import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface InterfaceSettingsStore {
|
||||
hideGoreBoards: boolean;
|
||||
setHideGoreBoards: (value: boolean) => void;
|
||||
hideAdultBoards: boolean;
|
||||
setHideAdultBoards: (value: boolean) => void;
|
||||
hideThreadsWithoutImages: boolean;
|
||||
setHideThreadsWithoutImages: (value: boolean) => void;
|
||||
}
|
||||
@@ -13,10 +9,6 @@ interface InterfaceSettingsStore {
|
||||
const useInterfaceSettingsStore = create(
|
||||
persist<InterfaceSettingsStore>(
|
||||
(set) => ({
|
||||
hideGoreBoards: true,
|
||||
setHideGoreBoards: (value: boolean) => set({ hideGoreBoards: value }),
|
||||
hideAdultBoards: true,
|
||||
setHideAdultBoards: (value: boolean) => set({ hideAdultBoards: value }),
|
||||
hideThreadsWithoutImages: true,
|
||||
setHideThreadsWithoutImages: (value: boolean) => set({ hideThreadsWithoutImages: value }),
|
||||
}),
|
||||
|
||||
@@ -40,7 +40,7 @@ const saveToLocalStorage = (key: string, set: Set<string>) => {
|
||||
}
|
||||
};
|
||||
|
||||
const useTopbarVisibilityStore = create<TopbarVisibilityState>((set, get) => {
|
||||
const useTopbarVisibilityStore = create<TopbarVisibilityState>((set, _get) => {
|
||||
// Initialize with all directories visible by default
|
||||
const allBoardCodes = getAllBoardCodes();
|
||||
const defaultVisibleDirectories = new Set(allBoardCodes);
|
||||
|
||||
@@ -16,8 +16,6 @@ import useSortingStore from '../../stores/use-sorting-store';
|
||||
import { getSubplebbitAddress } from '../../lib/utils/route-utils';
|
||||
import ErrorDisplay from '../../components/error-display/error-display';
|
||||
import LoadingEllipsis from '../../components/loading-ellipsis';
|
||||
import SubplebbitDescription from '../../components/subplebbit-description';
|
||||
import SubplebbitRules from '../../components/subplebbit-rules';
|
||||
import { Post } from '../post';
|
||||
|
||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||
@@ -145,7 +143,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
||||
}, [filteredComments, reset]);
|
||||
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||
const { createdAt, description, error, rules, shortAddress, state, suggested } = subplebbit || {};
|
||||
const { error, shortAddress, state } = subplebbit || {};
|
||||
const title = isInAllView ? t('all') : isInSubscriptionsView ? t('subscriptions') : isInModView ? t('mod') : subplebbit?.title;
|
||||
|
||||
const { blocked, unblock } = useBlock({ address: subplebbitAddress });
|
||||
@@ -336,23 +334,11 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
||||
<>
|
||||
{shouldShowSnow() && <hr />}
|
||||
<div className={`${styles.content} ${shouldShowSnow() ? styles.garland : ''}`}>
|
||||
{((description && description.length > 0) || isInAllView) && (
|
||||
<SubplebbitDescription
|
||||
avatarUrl={suggested?.avatarUrl}
|
||||
subplebbitAddress={subplebbitAddress}
|
||||
createdAt={createdAt}
|
||||
description={description}
|
||||
replyCount={isInAllView ? 0 : rules?.length > 0 ? 1 : 0}
|
||||
shortAddress={shortAddress}
|
||||
title={title}
|
||||
/>
|
||||
)}
|
||||
{shouldShowErrorToUser && (
|
||||
<div className={styles.error}>
|
||||
<ErrorDisplay error={error} />
|
||||
</div>
|
||||
)}
|
||||
{rules && !description && rules.length > 0 && <SubplebbitRules subplebbitAddress={subplebbitAddress} createdAt={createdAt} rules={rules} />}
|
||||
<Virtuoso
|
||||
increaseViewportBy={{ bottom: 1200, top: 1200 }}
|
||||
totalCount={combinedFeed.length}
|
||||
|
||||
@@ -12,7 +12,6 @@ import useTimeFilter, { timeFilterNameToSeconds } from '../../hooks/use-time-fil
|
||||
import useWindowWidth from '../../hooks/use-window-width';
|
||||
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
|
||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
import useInterfaceSettingsStore from '../../stores/use-interface-settings-store';
|
||||
import useSortingStore from '../../stores/use-sorting-store';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
import { getSubplebbitAddress } from '../../lib/utils/route-utils';
|
||||
@@ -149,23 +148,13 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
}, [boardIdentifierProp, defaultSubplebbits, resolvedAddressFromUrl]);
|
||||
|
||||
const boardPath = useBoardPath(subplebbitAddress);
|
||||
const { hideAdultBoards } = useInterfaceSettingsStore();
|
||||
const { showTextOnlyThreads, filterItems, searchText, clearMatchedFilters } = useCatalogFiltersStore();
|
||||
|
||||
const account = useAccount();
|
||||
const subscriptions = account?.subscriptions;
|
||||
|
||||
const subplebbitAddresses = useMemo(() => {
|
||||
const filteredDefaultSubplebbits = defaultSubplebbits
|
||||
.filter((subplebbit) => {
|
||||
// Hide NSFW boards if hideAdultBoards is enabled (treating NSFW as adult content)
|
||||
if (subplebbit.nsfw && hideAdultBoards) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((subplebbit) => subplebbit.address)
|
||||
.filter(Boolean); // Filter out any undefined/null values
|
||||
const filteredDefaultSubplebbits = defaultSubplebbits.map((subplebbit) => subplebbit.address).filter(Boolean); // Filter out any undefined/null values
|
||||
|
||||
if (isInAllView) {
|
||||
return filteredDefaultSubplebbits;
|
||||
@@ -175,7 +164,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
}
|
||||
// Only include subplebbitAddress if it's defined
|
||||
return subplebbitAddress ? [subplebbitAddress] : [];
|
||||
}, [isInAllView, isInSubscriptionsView, subplebbitAddress, defaultSubplebbits, subscriptions, hideAdultBoards]);
|
||||
}, [isInAllView, isInSubscriptionsView, subplebbitAddress, defaultSubplebbits, subscriptions]);
|
||||
|
||||
const { imageSize } = useCatalogStyleStore();
|
||||
const columnWidth = imageSize === 'Large' ? 270 : 180;
|
||||
|
||||
@@ -494,7 +494,7 @@ const BoardsList = ({ multisub }: { multisub: MultisubSubplebbit[] }) => {
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Shit 4chan Says
|
||||
Shit 5chan Says
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
+3
-21
@@ -3,14 +3,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Comment, Role, useComment, useEditedComment, useSubplebbit } from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { isAllView, isDescriptionView, isRulesView } from '../../lib/utils/view-utils';
|
||||
import { isAllView } from '../../lib/utils/view-utils';
|
||||
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import ErrorDisplay from '../../components/error-display/error-display';
|
||||
import PostDesktop from '../../components/post-desktop';
|
||||
import PostMobile from '../../components/post-mobile';
|
||||
import SubplebbitDescription from '../../components/subplebbit-description';
|
||||
import SubplebbitRules from '../../components/subplebbit-rules';
|
||||
import styles from './post.module.css';
|
||||
|
||||
export interface PostProps {
|
||||
@@ -57,12 +55,10 @@ const PostPage = () => {
|
||||
const { commentCid } = params;
|
||||
const subplebbitAddress = useResolvedSubplebbitAddress();
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const isInDescriptionView = isDescriptionView(location.pathname, params);
|
||||
const isInRulesView = isRulesView(location.pathname, params);
|
||||
|
||||
const comment = useComment({ commentCid });
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||
const { createdAt, description, rules, shortAddress, suggested, title } = subplebbit || {};
|
||||
const { shortAddress, title } = subplebbit || {};
|
||||
|
||||
// if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread
|
||||
const postComment = useComment({ commentCid: comment?.postCid });
|
||||
@@ -98,21 +94,7 @@ const PostPage = () => {
|
||||
<ErrorDisplay error={error} />
|
||||
</div>
|
||||
)}
|
||||
{isInDescriptionView ? (
|
||||
<SubplebbitDescription
|
||||
avatarUrl={suggested?.avatarUrl}
|
||||
createdAt={createdAt}
|
||||
description={description}
|
||||
replyCount={location.pathname.startsWith('/all/') ? 0 : rules?.length > 0 ? 1 : 0}
|
||||
subplebbitAddress={subplebbitAddress}
|
||||
shortAddress={shortAddress}
|
||||
title={title}
|
||||
/>
|
||||
) : isInRulesView ? (
|
||||
<SubplebbitRules createdAt={createdAt} rules={rules} subplebbitAddress={subplebbitAddress} />
|
||||
) : (
|
||||
<Post post={post} showAllReplies={true} />
|
||||
)}
|
||||
<Post post={post} showAllReplies={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+9
-25
@@ -2,10 +2,8 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
||||
import eslint from 'vite-plugin-eslint';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import reactScan from '@react-scan/vite-plugin-react-scan';
|
||||
import { reactGrab } from 'react-grab/plugins/vite';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
@@ -26,16 +24,6 @@ export default defineConfig({
|
||||
showToolbar: true,
|
||||
playSound: true,
|
||||
}),
|
||||
// Only include React Grab in development mode - never in production builds
|
||||
(isDevelopment || (!isProduction && process.env.NODE_ENV !== 'production')) && reactGrab(),
|
||||
!isProduction && eslint({
|
||||
lintOnStart: true,
|
||||
overrideConfigFile: './.eslintrc.cjs',
|
||||
failOnError: false,
|
||||
failOnWarning: false,
|
||||
cache: true,
|
||||
include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.js', 'src/**/*.jsx'],
|
||||
}),
|
||||
nodePolyfills({
|
||||
globals: {
|
||||
Buffer: true,
|
||||
@@ -61,7 +49,7 @@ export default defineConfig({
|
||||
manifest: {
|
||||
name: '5chan',
|
||||
short_name: '5chan',
|
||||
description: 'A GUI for plebbit similar to 4chan',
|
||||
description: 'A serverless, adminless, decentralized 4chan alternative',
|
||||
theme_color: '#ffffff',
|
||||
background_color: '#ffffee',
|
||||
display: 'standalone',
|
||||
@@ -164,6 +152,8 @@ export default defineConfig({
|
||||
'stream': 'stream-browserify',
|
||||
'crypto': 'crypto-browserify',
|
||||
'buffer': 'buffer',
|
||||
'util/': 'util',
|
||||
'util': 'util',
|
||||
},
|
||||
},
|
||||
server: {
|
||||
@@ -180,37 +170,31 @@ export default defineConfig({
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: process.env.GENERATE_SOURCEMAP === 'true',
|
||||
target: process.env.ELECTRON ? 'electron-renderer' : 'modules',
|
||||
target: process.env.ELECTRON ? 'electron-renderer' : 'esnext',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom', 'react-router-dom', 'react-i18next', 'i18next', 'i18next-browser-languagedetector', 'i18next-http-backend']
|
||||
manualChunks(id) {
|
||||
if (/[\\/]node_modules[\\/](react|react-dom|react-router-dom|react-i18next|i18next|i18next-browser-languagedetector|i18next-http-backend)[\\/]/.test(id)) {
|
||||
return 'vendor';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
base: process.env.PUBLIC_URL || '/',
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
target: 'es2020',
|
||||
define: {
|
||||
global: 'globalThis',
|
||||
},
|
||||
},
|
||||
include: [
|
||||
'ethers',
|
||||
'assert',
|
||||
'buffer',
|
||||
'process',
|
||||
'util',
|
||||
'stream-browserify',
|
||||
'isomorphic-fetch',
|
||||
'workbox-core',
|
||||
'workbox-precaching'
|
||||
],
|
||||
},
|
||||
esbuild: {
|
||||
target: 'es2020'
|
||||
},
|
||||
define: {
|
||||
'process.env.VITE_COMMIT_REF': JSON.stringify(process.env.COMMIT_REF),
|
||||
'global': 'globalThis',
|
||||
|
||||
Reference in New Issue
Block a user