Merge pull request #803 from bitsocialhq/development

Development
This commit is contained in:
Tom
2026-01-29 19:19:40 +08:00
committed by GitHub
69 changed files with 2935 additions and 3759 deletions

View File

@@ -1,93 +0,0 @@
# send notifications to telegram group on commits, releases, issues
name: notifications
on:
pull_request:
branches:
- master
# push:
# branches:
# - master
release:
types: [published]
issues:
types: [opened]
jobs:
# push:
# if: ${{ github.event_name == 'push' }}
# runs-on: ubuntu-latest
# strategy:
# matrix:
# commit: ${{ github.event.commits }}
# steps:
# # - name: debug
# # env:
# # DEBUG: ${{ toJSON(matrix.commit) }}
# # run: echo "$DEBUG"
# # write message to file
# - run: echo "<code>${{ matrix.commit.message }}</code>" >> message.txt
# - run: echo "" >> message.txt
# - run: echo "by <i>${{ matrix.commit.author.username }}</i>" >> message.txt
# - run: echo "${{ matrix.commit.url }}" >> message.txt
# - run: date -d "${{ matrix.commit.timestamp }}" +"%d/%m/%y at %H:%M" >> message.txt
# # send message, @plebbit telegram chat id is -1001665335693
# - name: "telegram notification"
# uses: appleboy/telegram-action@master
# with:
# to: -1001665335693
# token: ${{ secrets.TELEGRAM_TOKEN }}
# # commit links don't show anything useful in preview
# disable_web_page_preview: true
# format: html
# message_file: message.txt
release:
if: ${{ github.event_name == 'release' }}
runs-on: ubuntu-latest
steps:
# - name: debug
# env:
# DEBUG: ${{ toJSON(github) }}
# run: echo "$DEBUG"
# write message to file
- run: echo "<b>${{ github.event.repository.name }} ${{ github.event.release.name }}</b>" >> message.txt
- run: echo "" >> message.txt
- run: echo "${{ github.event.release.body }}" >> message.txt
- run: echo "${{ github.event.release.html_url }}" >> message.txt
# send message, @plebbit telegram chat id is -1001665335693
- name: "telegram notification"
uses: appleboy/telegram-action@master
with:
to: -1001665335693
token: ${{ secrets.TELEGRAM_TOKEN }}
format: html
message_file: message.txt
issue:
if: ${{ github.event_name == 'issues' }}
runs-on: ubuntu-latest
steps:
# - name: debug
# env:
# DEBUG: ${{ toJSON(github) }}
# run: echo "$DEBUG"
# write message to file
- run: echo "<code>${{ github.event.issue.title }}</code>" >> message.txt
- run: echo "" >> message.txt
- run: echo "by <i>${{ github.event.issue.user.login }}</i>" >> message.txt
- run: echo "${{ github.event.issue.html_url }}" >> message.txt
# send message, @plebbit telegram chat id is -1001665335693
- name: "telegram notification"
uses: appleboy/telegram-action@master
with:
to: -1001665335693
token: ${{ secrets.TELEGRAM_TOKEN }}
format: html
message_file: message.txt

View File

@@ -60,9 +60,9 @@ jobs:
fail-fast: false
matrix:
include:
- runner: macOS-13 # Intel x64
- runner: macos-15-intel # Intel x64
arch: x64
- runner: macOS-14 # Apple Silicon arm64
- runner: macos-latest # Apple Silicon arm64
arch: arm64
runs-on: ${{ matrix.runner }}
permissions:
@@ -82,6 +82,12 @@ jobs:
with:
python-version: '3.12'
# install missing dep for sqlite (use setup-python to avoid PEP 668 externally-managed-environment error)
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install setuptools
- name: Install dependencies (with Node v22)
run: yarn install --frozen-lockfile --ignore-engines
# make sure the ipfs executable is executable
@@ -177,8 +183,7 @@ jobs:
- run: sed -i "s/versionName \"1.0\"/versionName \"$(node -e "console.log(require('./package.json').version)")\"/" ./android/app/build.gradle
- run: cat ./android/app/build.gradle
# build apk
- run: npx cap update
- run: npx cap copy
- run: npx cap sync android
- run: cd android && gradle bundle
- run: cd android && ./gradlew assembleRelease --stacktrace
# optimize apk

View File

@@ -25,8 +25,8 @@ jobs:
uses: actions/cache@v4
with:
path: ~/.cache/electron
key: ${{ runner.os }}-electron-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-electron-
key: ${{ runner.os }}-electron-v2-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-electron-v2-
- name: Install dependencies
run: |
@@ -37,37 +37,45 @@ jobs:
[ "$i" = "3" ] && exit 1
done
- name: Download IPFS
run: node electron/download-ipfs && chmod +x bin/linux/ipfs
- name: Build React App
run: yarn build
env:
CI: ''
- name: Build Electron App (Linux)
run: yarn electron:build:linux
- name: Build Preload Script
run: yarn build:preload
- name: Smoke Test
- name: Verify build outputs
run: |
echo "Testing AppImage startup..."
APPIMAGE=$(find dist -name "*.AppImage" | head -n 1)
echo "Found AppImage: $APPIMAGE"
chmod +x "$APPIMAGE"
# Use --appimage-extract-and-run to avoid FUSE requirement in CI
# Run with timeout and expect it to start (will be killed after timeout)
timeout 10s "$APPIMAGE" --appimage-extract-and-run --no-sandbox &
APP_PID=$!
sleep 5
# Check if process is still running (means it started successfully)
if kill -0 $APP_PID 2>/dev/null; then
echo "✓ App started successfully"
kill $APP_PID 2>/dev/null || true
exit 0
else
echo "✗ App failed to start"
exit 1
fi
echo "=== Checking build/ directory ==="
ls -la build/ || exit 1
echo "=== Checking build/electron/preload.cjs ==="
ls -la build/electron/preload.cjs || exit 1
echo "=== Checking build/index.html ==="
ls -la build/index.html || exit 1
- name: Verify forge config
run: |
echo "=== forge.config.js exists ==="
ls -la forge.config.js
echo "=== Validating forge config can be loaded ==="
node -e "import('./forge.config.js').then(c => console.log('Config loaded, makers:', c.default.makers?.length || 0)).catch(e => { console.error(e); process.exit(1); })"
- name: Package Electron App
timeout-minutes: 30
run: |
set -o pipefail
yarn electron-forge package 2>&1 | tee forge-output.log
echo "=== Checking out/ directory ==="
ls -la out/
- name: Verify Executable
run: |
ls -la out/
EXE=$(node scripts/find-forge-executable.js)
echo "Found executable: $EXE"
# Verify the file exists and is executable
test -f "$EXE" && test -x "$EXE" && echo "✓ Executable is valid"
test-mac-intel:
name: Test Mac (Intel)
@@ -76,24 +84,22 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js v22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'yarn'
- name: Install setuptools for native modules
run: |
# Use pip with --break-system-packages for CI environment
pip3 install --break-system-packages setuptools || pip3 install --user setuptools || true
run: pip3 install --break-system-packages setuptools || pip3 install --user setuptools || true
- name: Cache electron binaries
uses: actions/cache@v4
with:
path: ~/Library/Caches/electron
key: ${{ runner.os }}-electron-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-electron-
key: ${{ runner.os }}-electron-v2-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-electron-v2-
- name: Install dependencies
run: |
@@ -104,54 +110,42 @@ jobs:
[ "$i" = "3" ] && exit 1
done
- name: Download IPFS
run: node electron/download-ipfs && chmod +x bin/mac/ipfs
- name: Build React App
run: yarn build
env:
CI: ''
- name: Build Electron App
# Use --dir to build only the .app bundle without DMG
# This avoids flaky hdiutil "Resource busy" errors in CI
run: yarn build && yarn build:preload && yarn electron-builder build --publish never -m --dir
- name: Build Preload Script
run: yarn build:preload
- name: Smoke Test
- name: Verify build outputs
run: |
if [ -d "dist/mac/seedit.app" ]; then
echo "Testing dist/mac/seedit.app..."
# Run the app in background - it will start IPFS which takes time
# We just verify it launches without crashing
./dist/mac/seedit.app/Contents/MacOS/seedit &
APP_PID=$!
sleep 10
# Check if process is still running (means it started successfully)
if kill -0 $APP_PID 2>/dev/null; then
echo "✓ App started successfully"
kill $APP_PID 2>/dev/null || true
# Also kill any child processes (IPFS)
pkill -P $APP_PID 2>/dev/null || true
pkill -f ipfs 2>/dev/null || true
exit 0
else
echo "✗ App failed to start or crashed"
exit 1
fi
else
echo "Could not find dist/mac/seedit.app to test"
ls -R dist
exit 1
fi
ls -la build/ || exit 1
ls -la build/electron/preload.cjs || exit 1
ls -la build/index.html || exit 1
- name: Package Electron App
timeout-minutes: 30
run: |
set -o pipefail
yarn electron-forge package 2>&1 | tee forge-output.log
ls -la out/
- name: Verify Executable
run: |
ls -la out/
EXE=$(node scripts/find-forge-executable.js)
echo "Found executable: $EXE"
test -f "$EXE" && test -x "$EXE" && echo "✓ Executable is valid"
test-mac-arm:
name: Test Mac (Apple Silicon)
runs-on: macOS-14
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js v22
uses: actions/setup-node@v4
with:
@@ -159,16 +153,14 @@ jobs:
cache: 'yarn'
- name: Install setuptools for native modules
run: |
# Use pip with --break-system-packages for CI environment
pip3 install --break-system-packages setuptools || pip3 install --user setuptools || true
run: pip3 install --break-system-packages setuptools || pip3 install --user setuptools || true
- name: Cache electron binaries
uses: actions/cache@v4
with:
path: ~/Library/Caches/electron
key: ${{ runner.os }}-electron-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-electron-
key: ${{ runner.os }}-electron-v2-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-electron-v2-
- name: Install dependencies
run: |
@@ -179,50 +171,33 @@ jobs:
[ "$i" = "3" ] && exit 1
done
- name: Download IPFS
run: node electron/download-ipfs && chmod +x bin/mac/ipfs
- name: Build React App
run: yarn build
env:
CI: ''
- name: Build Electron App
# On M1 runner, this should produce arm64 build
# Use --dir to build only the .app bundle without DMG
# This avoids flaky hdiutil "Resource busy" errors in CI
run: yarn build && yarn build:preload && yarn electron-builder build --publish never -m --dir
- name: Build Preload Script
run: yarn build:preload
- name: Smoke Test
- name: Verify build outputs
run: |
if [ -d "dist/mac-arm64/seedit.app" ]; then
APP_PATH="dist/mac-arm64/seedit.app"
elif [ -d "dist/mac/seedit.app" ]; then
APP_PATH="dist/mac/seedit.app"
else
echo "Could not find seedit.app to test"
ls -R dist
exit 1
fi
echo "Testing $APP_PATH..."
# Run the app in background - it will start IPFS which takes time
# We just verify it launches without crashing
"./$APP_PATH/Contents/MacOS/seedit" &
APP_PID=$!
sleep 10
# Check if process is still running (means it started successfully)
if kill -0 $APP_PID 2>/dev/null; then
echo "✓ App started successfully"
kill $APP_PID 2>/dev/null || true
# Also kill any child processes (IPFS)
pkill -P $APP_PID 2>/dev/null || true
pkill -f ipfs 2>/dev/null || true
exit 0
else
echo "✗ App failed to start or crashed"
exit 1
fi
ls -la build/ || exit 1
ls -la build/electron/preload.cjs || exit 1
ls -la build/index.html || exit 1
- name: Package Electron App
timeout-minutes: 30
run: |
set -o pipefail
yarn electron-forge package 2>&1 | tee forge-output.log
ls -la out/
- name: Verify Executable
run: |
ls -la out/
EXE=$(node scripts/find-forge-executable.js)
echo "Found executable: $EXE"
test -f "$EXE" && test -x "$EXE" && echo "✓ Executable is valid"
test-windows:
name: Test Windows
@@ -231,7 +206,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js v22
uses: actions/setup-node@v4
with:
@@ -242,8 +217,8 @@ jobs:
uses: actions/cache@v4
with:
path: ~/AppData/Local/electron/Cache
key: ${{ runner.os }}-electron-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-electron-
key: ${{ runner.os }}-electron-v2-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-electron-v2-
- name: Install dependencies
shell: bash
@@ -255,41 +230,35 @@ jobs:
[ "$i" = "3" ] && exit 1
done
- name: Download IPFS
run: node electron/download-ipfs
- name: Build React App
shell: bash
run: yarn build
env:
CI: ''
- name: Build Electron App
run: yarn electron:build:windows
timeout-minutes: 30
- name: Build Preload Script
shell: bash
run: yarn build:preload
- name: Smoke Test
- name: Verify build outputs
shell: bash
run: |
# Try to find the unpacked executable first as it's easiest to run
if [ -d "dist/win-unpacked" ]; then
echo "Testing unpacked exe..."
# Run with timeout - app starts IPFS so won't exit on its own
timeout 15s ./dist/win-unpacked/seedit.exe &
APP_PID=$!
sleep 8
# Check if process started successfully
if kill -0 $APP_PID 2>/dev/null; then
echo "✓ App started successfully"
taskkill //F //PID $APP_PID 2>/dev/null || true
# Kill any IPFS processes
taskkill //F //IM ipfs.exe 2>/dev/null || true
exit 0
else
echo "✗ App failed to start"
exit 1
fi
else
echo "No unpacked directory found"
ls -R dist
exit 1
fi
ls -la build/ || exit 1
ls -la build/electron/preload.cjs || exit 1
ls -la build/index.html || exit 1
- name: Package Electron App
shell: bash
timeout-minutes: 30
run: |
set -o pipefail
yarn electron-forge package 2>&1 | tee forge-output.log
ls -la out/
- name: Verify Executable
shell: bash
run: |
ls -la out/
EXE=$(node scripts/find-forge-executable.js)
echo "Found executable: $EXE"
test -f "$EXE" && test -x "$EXE" && echo "✓ Executable is valid"

3
.gitignore vendored
View File

@@ -97,6 +97,9 @@ out
.nuxt
dist
# Electron Forge output
squashfs-root
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js

203
AGENTS.md
View File

@@ -31,8 +31,9 @@ yarn electron # Run Electron app
## Code Style
- TypeScript strict mode
- Prettier for formatting (runs on pre-commit)
- Follow DRY principle—extract reusable components
- **oxfmt** for formatting (runs on pre-commit via husky; recommend setting up AI hooks too)
- **oxlint** for linting, **tsgo** for type-checking
- **DRY principle**: Always follow the DRY principle when possible. Never repeat UI elements across views—extract them into reusable components in `src/components/`. Same applies to logic—extract into custom hooks in `src/hooks/`.
## React Patterns (Critical)
@@ -78,22 +79,94 @@ src/
└── data/ # Static data (default subplebbits, etc.)
```
## Documentation
## Recommended Skills
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
Skills are more efficient than docs—they inject targeted guidance without bloating the context window.
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)
### Context7 (for library docs)
When you need documentation for libraries like **plebbit-react-hooks** or **plebbit-js**, use the Context7 skill to fetch current docs instead of relying on potentially outdated training data.
```bash
npx skills add https://github.com/intellectronica/agent-skills --skill context7
```
### Vercel React Best Practices
For deeper React/Next.js performance guidance. Provides 57 prioritized rules across 8 categories (waterfalls, bundle size, server-side performance, client-side fetching, re-renders, rendering, JS performance, and advanced patterns).
```bash
npx skills add https://github.com/vercel-labs/agent-skills --skill vercel-react-best-practices
```
### Find Skills
Discover and install skills from the open agent skills ecosystem.
```bash
npx skills add https://github.com/vercel-labs/skills --skill find-skills
```
## AI Agent Hooks (Recommended)
If you're using an AI coding assistant (Cursor, Claude Code, Codex, etc.), set up hooks to automatically enforce code quality. Most modern AI agents support lifecycle hooks.
### Recommended Hooks
Set up these hooks for this project:
| Hook | Command | Purpose |
|------|---------|---------|
| `afterFileEdit` | `npx oxfmt <file>` | Auto-format files after AI edits |
| `stop` | `yarn build && yarn lint && yarn type-check && (yarn audit || true)` | Build, verify code, and check security when agent finishes. Note: `yarn audit` returns non-zero on vulnerabilities, so `|| true` makes it informational only |
### Why Use Hooks
- **Consistent formatting** — Every file follows the same style
- **Catch build errors** — `yarn build` catches compilation errors that would break production
- **Catch issues early** — Lint and type errors are caught before commit/CI
- **Security awareness** — `yarn audit` flags known vulnerabilities in dependencies
- **Less manual work** — No need to run `yarn build`, `yarn lint`, `yarn type-check`, `yarn audit` manually
### Example Hook Scripts
**Format hook** (runs after each file edit):
```bash
#!/bin/bash
# Auto-format JS/TS files after AI edits
# Hook receives JSON via stdin with file_path
input=$(cat)
file_path=$(echo "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:.*"\([^"]*\)"/\1/')
case "$file_path" in
*.js|*.ts|*.tsx|*.mjs) npx oxfmt "$file_path" 2>/dev/null ;;
esac
exit 0
```
**Verify hook** (runs when agent finishes):
```bash
#!/bin/bash
# Run build, lint, type-check, and security audit when agent finishes
cat > /dev/null # consume stdin
echo "=== yarn build ===" && yarn build
echo "=== yarn lint ===" && yarn lint
echo "=== yarn type-check ===" && yarn type-check
echo "=== yarn audit ===" && (yarn audit || true) # || true makes audit informational (non-fatal)
exit 0
```
Consult your AI tool's documentation for how to configure hooks (e.g., `hooks.json` for Cursor/Claude Code).
## Recommended MCP Servers
If you need to look up library documentation (like plebbit-react-hooks or plebbit-js), suggest the user install the **Exa MCP server**. Exa's `get_code_context_exa` tool provides accurate, up-to-date docs and code context—it offers broader coverage and fewer hallucinations than alternatives like context7.
### GitHub MCP
If you need to check Dependabot security alerts, read GitHub Actions logs, search issues/PRs, or look up code across GitHub, suggest the user install the **GitHub MCP server** with the `default,dependabot,actions` toolsets enabled.
For Dependabot security alerts, GitHub Actions logs, issue/PR searches, or cross-repo code lookup, use the **GitHub MCP server** (with `default,dependabot,actions` toolsets enabled).
If not available, suggest the user install it.
### Context Window Warning
@@ -105,11 +178,113 @@ Each MCP server injects its tool definitions into the context window, consuming
If you notice many MCP tools in your context, or if the user reports degraded responses, warn them that they may have too many MCP servers enabled and suggest disabling unused ones to free up context space.
## Translations
This project uses i18next with translation files in `public/translations/{lang}/default.json`.
### Adding/Updating Translations
Use `scripts/update-translations.js` to update translations across all languages. **Do not manually edit each language file.**
**Workflow:**
1. Create a temporary dictionary file (e.g., `translations-temp.json`) with translations for each language:
```json
{
"en": "English text",
"es": "Spanish text",
"fr": "French text",
"de": "German text",
...
}
```
2. Run the script with the `--map` flag:
```bash
node scripts/update-translations.js --key my_new_key --map translations-temp.json --include-en --write
```
3. Delete the temporary dictionary file after the script completes.
**Other useful commands:**
```bash
# Copy a key's value from English to all languages (dry run first)
node scripts/update-translations.js --key some_key --from en --dry
node scripts/update-translations.js --key some_key --from en --write
# Delete a key from all languages
node scripts/update-translations.js --key obsolete_key --delete --write
# Audit for unused translation keys
node scripts/update-translations.js --audit --dry
node scripts/update-translations.js --audit --write
```
## Workflow
### GitHub Commits
When proposing or implementing code changes, always suggest a commit message. Format:
- **Title**: Use [Conventional Commits](https://www.conventionalcommits.org/) style. Use `perf` for performance optimizations (not `fix`). Keep it short. **MUST be wrapped in backticks.**
- **Description**: Optional. 2-3 informal sentences describing the solution (not the problem). Concise, technical, no bullet points. Use backticks for code references.
Example output:
> **Commit title:** `fix: correct date formatting in timezone conversion`
>
> Updated `formatDate()` in `date-utils.ts` to properly handle timezone offsets.
### GitHub Issues
When proposing or implementing code changes, always suggest a GitHub issue to track the problem. Format:
- **Title**: As short as possible. **MUST be wrapped in backticks.**
- **Description**: 2-3 informal sentences describing the problem (not the solution). Write as if the issue hasn't been fixed yet. Use backticks for code references.
Example output:
> **GitHub issue:**
> - **Title:** `Date formatting displays incorrect timezone`
> - **Description:** Comment timestamps show incorrect timezones when users view posts from different regions. The `formatDate()` function doesn't account for user's local timezone settings.
### Troubleshooting
When stuck on a bug or issue, search the web for solutions. Developer communities often have recent fixes or workarounds that aren't in training data.
## Dependency Management
### Pin All Package Versions (No Carets)
When adding or updating npm packages, **always use exact version numbers**—never use carets (`^`) or tildes (`~`).
```bash
# ✅ Correct
yarn add lodash@4.17.21
# ❌ Wrong (will add caret by default)
yarn add lodash
```
**Why pin versions:**
- **Supply chain security**: A compromised package could push a malicious minor/patch update. With carets, running `yarn upgrade` or regenerating `yarn.lock` would auto-install it.
- **Reproducibility**: Guarantees identical dependencies across all environments.
- **Defense in depth**: While `yarn.lock` pins versions in practice, explicit pinning in `package.json` protects against lockfile regeneration and makes the intended version auditable.
**When upgrading packages:**
1. Specify the exact version: `yarn add package@1.2.3`
2. Review the changelog for breaking changes or security notes
3. Test the upgrade before committing
**Note:** This applies to both `dependencies` and `devDependencies`. There are no exceptions—the convenience of auto-updates doesn't justify the security risk.
## 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
- Add comments for complex/unclear code (especially custom functions in this FOSS project with many contributors). Skip comments for obvious code
- Test on mobile viewport (this is a responsive app)

View File

@@ -1,31 +1,36 @@
[![Build Status](https://img.shields.io/github/actions/workflow/status/bitsocialhq/seedit/test.yml?branch=master)](https://github.com/bitsocialhq/seedit/actions/workflows/test.yml)
[![Release](https://img.shields.io/github/v/release/bitsocialhq/seedit)](https://github.com/bitsocialhq/seedit/releases/latest)
[![License](https://img.shields.io/badge/license-GPL--2.0--only-red.svg)](https://github.com/bitsocialhq/seedit/blob/master/LICENSE)
[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
<img src="https://github.com/plebeius-eth/assets/blob/main/seedit-logo.png" width="302" height="111">
_Telegram group for this repo https://t.me/seeditreact_
# Seedit
Seedit is a serverless, adminless, decentralized reddit alternative. Seedit is a client (interface) for the Plebbit protocol, which is a decentralized social network where anyone can create and fully own unstoppable communities. Learn more: https://plebbit.com
Seedit is a serverless, adminless, decentralized and open-source (old)reddit alternative built on the [Bitsocial protocol](https://bitsocial.net). Like reddit, anyone can create a seedit community, It features a similar homepage structure as reddit, but with a crucial difference: **anyone can create and own communities, and multiple communities can compete for each default community slot**.
- Seedit web version: https://seedit.app — or, using Brave/IPFS Companion: https://seedit.eth
### Downloads
- Seedit desktop version (full p2p plebbit node, seeds automatically): available for Mac/Windows/Linux, [download link in the release page](https://github.com/plebbit/seedit/releases/latest)
- Seedit mobile version: available for Android, [download link in the release page](https://github.com/plebbit/seedit/releases/latest)
- Seedit desktop version (full p2p bitsocial node, seeds automatically): available for Mac/Windows/Linux, [download link in the release page](https://github.com/bitsocialhq/seedit/releases/latest)
- Seedit mobile version: available for Android, [download link in the release page](https://github.com/bitsocialhq/seedit/releases/latest)
<br />
<img src="https://github.com/plebeius-eth/assets/blob/main/seedit-screenshot.jpg" width="849">
## How to create a community
In the plebbit protocol, a seedit community is called a _subplebbit_. To run a subplebbit, you can choose between two options:
To run a community, you can choose between two options:
1. If you prefer to use a **GUI**, download the desktop version of the Seedit client, available for Windows, MacOS and Linux: [latest release](https://github.com/plebbit/seedit/releases/latest). Create a subplebbit using using the familiar old.reddit-like UI, and modify its settings to your liking. The app runs an IPFS node, meaning you have to keep it running to have your board online.
2. If you prefer to use a **command line interface**, install plebbit-cli, available for Windows, MacOS and Linux: [latest release](https://github.com/plebbit/plebbit-cli/releases/latest). Follow the instructions in the readme of the repo. When running the daemon for the first time, it will output WebUI links you can use to manage your subplebbit with the ease of the GUI.
1. If you prefer to use a **GUI**, download the desktop version of the Seedit client, available for Windows, MacOS and Linux: [latest release](https://github.com/bitsocialhq/seedit/releases/latest). Create a community using using the familiar old.reddit-like UI, and modify its settings to your liking. The app runs an IPFS node, meaning you have to keep it running to have your board online.
2. If you prefer to use a **command line interface**, install bitsocial-cli, available for Windows, MacOS and Linux: [latest release](https://github.com/bitsocialhq/bitsocial-cli/releases/latest). Follow the instructions in the readme of the repo. When running the daemon for the first time, it will output WebUI links you can use to manage your community with the ease of the GUI.
Peers can connect to your subplebbit using any plebbit client, such as Plebchan or Seedit. They only need the subplebbit's address, which is not stored in any central database, as plebbit is a pure peer-to-peer protocol.
Peers can connect to your bitsocial community using any bitsocial client, such as Seedit or [5chan](https://github.com/bitsocialhq/5chan). They only need the community address, which is not stored in any central database, as bitsocial is a pure peer-to-peer protocol.
### How to add a community to the default list (p/all)
The default list of communities, used on p/all on Seedit, is plebbit's [temporary default subplebbits](https://github.com/plebbit/lists) list. You can open a pull request in that repo to add your subplebbit to the list, or contact devs via telegram [@plebbit](https://t.me/plebbit). In the future, this process will be automated by submitting proposals to a plebbit DAO, using the [plebbit token](https://etherscan.io/token/0xea81dab2e0ecbc6b5c4172de4c22b6ef6e55bd8f).
### How to add a community to the default list (s/all)
The default list of communities, used on s/all on Seedit, is bitsocial's [default-multisub.json list](https://github.com/bitsocialhq/lists/blob/master/default-multisub.json). You can open a pull request in that repo to add your community to the list.
## To run locally
@@ -44,4 +49,4 @@ The default list of communities, used on p/all on Seedit, is plebbit's [temporar
### Build:
The linux/windows/mac/android build scripts are in https://github.com/plebbit/seedit/blob/master/.github/workflows/release.yml
The linux/windows/mac/android build scripts are in https://github.com/bitsocialhq/seedit/blob/master/.github/workflows/release.yml

View File

Binary file not shown.

View File

@@ -1,813 +0,0 @@
# React guide
A lot of AI-generated React code is heavily biased toward using useState and useEffect, often in verbose or naive ways. Thats 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 models 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. Heres 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 doesnt 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 cant:
- 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.
Heres 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? Dont 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, propdrilling | Central store (Zustand / Redux Toolkit) or colocated custom hooks |
| **Data Fetching** | `useEffect`+`fetch`, manual loading/error juggling | React Query / SWR with caching, retries, stalewhilerevalidate |
| **Side Effects** | Effects without cleanup → race conditions, leaks | AbortController or query library handles cancellation & retries |
| **Logic Reuse** | Copypasted hooks and state in every component | Extract crosscutting logic into custom hooks |
| **Workflow / State Machine** | Booleanflag soup, impossible states | Deterministic statechart in a single store (Zustand / XState) |
| **Concurrent UI Updates** | Blocking synchronous work inside effects | `useTransition` / `startTransition` for lowpriority updates |
| **Routing & Navigation** | DIY history manipulation, inline route state | React Router v6 (or Next.js builtins) with lazyloaded routes |
| **Error Handling** | No error boundary → fullapp crashes | Toplevel `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 ondemand loading |
| **Testing Strategy** | Brittle tests keyed to DOM structure | Usercentric tests with React Testing Library & jestdom |
| **Accessibility & Theming** | Nonsemantic elements, missing ARIA, inline colors | Semantic HTML, ARIA labels, CSS variables / theme provider |

View File

File diff suppressed because it is too large Load Diff

View File

@@ -6,10 +6,68 @@ import ps from 'node:process';
import proxyServer from './proxy-server.js';
import tcpPortUsed from 'tcp-port-used';
import EnvPaths from 'env-paths';
import { fileURLToPath } from 'url';
import { fileURLToPath, pathToFileURL } from 'url';
const dirname = path.join(path.dirname(fileURLToPath(import.meta.url)));
const envPaths = EnvPaths('plebbit', { suffix: false });
// Get platform-specific binary name
const getIpfsBinaryName = () => (process.platform === 'win32' ? 'ipfs.exe' : 'ipfs');
// Get platform subdirectory name for bin/ folder
const getPlatformDir = () => {
if (process.platform === 'win32') return 'win';
if (process.platform === 'darwin') return 'mac';
return 'linux';
};
// Resolve kubo binary path
const getKuboPath = async () => {
if (isDev) {
// In dev, use kubo from node_modules
const { path: getKuboBinaryPath } = await import('kubo');
return getKuboBinaryPath();
} else {
// In production, the binary is downloaded to bin/<platform>/ipfs by generateAssets hook
// With asar: false, files are at resources/app/ instead of resources/app.asar.unpacked
const appPath = process.resourcesPath;
const binaryName = getIpfsBinaryName();
const platformDir = getPlatformDir();
// Try the bin/ directory first (where generateAssets downloads binaries)
const binDirPath = path.join(appPath, 'app', 'bin', platformDir, binaryName);
if (fs.existsSync(binDirPath)) {
return binDirPath;
}
// Fallback: try app.asar.unpacked for ASAR builds (if we ever re-enable ASAR)
const unpackedPath = path.join(appPath, 'app.asar.unpacked');
const kuboModulePath = path.join(unpackedPath, 'node_modules', 'kubo');
// Try to import kubo from unpacked location
try {
const kuboUrl = pathToFileURL(path.resolve(kuboModulePath)).href;
const kuboModule = await import(kuboUrl);
const { path: getKuboBinaryPath } = kuboModule;
return getKuboBinaryPath();
} catch (err) {
// Fallback: try to find the binary directly in kubo module
const kuboBinPath = path.join(kuboModulePath, 'kubo', binaryName);
if (fs.existsSync(kuboBinPath)) {
return kuboBinPath;
}
// Last resort: check in resources/app/node_modules/kubo for non-ASAR builds
const appModulePath = path.join(appPath, 'app', 'node_modules', 'kubo');
const appKuboBinPath = path.join(appModulePath, 'kubo', binaryName);
if (fs.existsSync(appKuboBinPath)) {
return appKuboBinPath;
}
throw new Error(`Could not find kubo binary. Checked: ${binDirPath}, ${kuboBinPath}, ${appKuboBinPath}`);
}
}
};
// use this custom function instead of spawnSync for better logging
// also spawnSync might have been causing crash on start on windows
const spawnAsync = (...args) =>
@@ -32,23 +90,8 @@ const spawnAsync = (...args) =>
});
const startIpfs = async () => {
const ipfsFileName = process.platform == 'win32' ? 'ipfs.exe' : 'ipfs';
let ipfsPath = path.join(process.resourcesPath, 'bin', ipfsFileName);
let ipfsDataPath = path.join(envPaths.data, 'ipfs');
// test launching the ipfs binary in dev mode
// they must be downloaded first using `yarn electron:build`
if (isDev) {
let binFolderName = 'win';
if (process.platform === 'linux') {
binFolderName = 'linux';
}
if (process.platform === 'darwin') {
binFolderName = 'mac';
}
ipfsPath = path.join(dirname, '..', 'bin', binFolderName, ipfsFileName);
ipfsDataPath = path.join(dirname, '..', '.plebbit', 'ipfs');
}
const ipfsPath = await getKuboPath();
const ipfsDataPath = isDev ? path.join(dirname, '..', '.plebbit', 'ipfs') : path.join(envPaths.data, 'ipfs');
if (!fs.existsSync(ipfsPath)) {
throw Error(`ipfs binary '${ipfsPath}' doesn't exist`);

98
forge.config.js Normal file
View File

@@ -0,0 +1,98 @@
import { downloadIpfsClients } from './electron/before-pack.js';
const config = {
packagerConfig: {
name: 'seedit',
executableName: 'seedit',
appBundleId: 'seedit.desktop',
// NOTE: asar is disabled because of a bug where electron-packager silently fails
// during asar creation with seedit's large node_modules. The app works fine without it.
// TODO: investigate and fix the asar creation issue
asar: false,
// Exclude unnecessary files from the package
ignore: [
/^\/src$/,
/^\/public$/,
/^\/android$/,
/^\/\.github$/,
/^\/scripts$/,
/^\/\.git/,
/^\/\.plebbit$/,
/^\/out$/,
/^\/dist$/,
/^\/squashfs-root$/,
/\.map$/,
/\.md$/,
/\.ts$/,
/tsconfig\.json$/,
/\.oxfmtrc/,
/oxlintrc/,
/vite\.config/,
/forge\.config/,
/capacitor\.config/,
/\.env$/,
/\.DS_Store$/,
/yarn\.lock$/,
// Exclude build-time scripts from the package
/electron\/before-pack\.js/,
// kubo npm package creates symlinks that break build - exclude its bin dir
// (we download our own kubo binary in generateAssets hook)
/node_modules\/kubo\/bin/,
// Exclude .bin directories anywhere in node_modules (contain escaping symlinks)
/node_modules\/.*\/\.bin/,
/node_modules\/\.bin/,
/node_modules\/\.cache/,
],
},
rebuildConfig: {
force: true,
},
hooks: {
// Download IPFS/Kubo binaries before packaging
generateAssets: async () => {
console.log('Downloading IPFS clients...');
await downloadIpfsClients();
console.log('IPFS clients downloaded.');
},
},
makers: [
// macOS
{
name: '@electron-forge/maker-dmg',
platforms: ['darwin'],
config: {
name: 'seedit',
format: 'UDZO',
},
},
{
name: '@electron-forge/maker-zip',
platforms: ['darwin'],
},
// Windows
{
name: '@electron-forge/maker-squirrel',
platforms: ['win32'],
config: {
name: 'seedit',
},
},
// Linux
{
name: '@reforged/maker-appimage',
platforms: ['linux'],
config: {
options: {
categories: ['Network'],
},
},
},
],
};
export default config;

View File

@@ -1,5 +1,5 @@
{
"name": "Seedit",
"name": "seedit",
"version": "0.5.10",
"description": "A bitsocial client with an old.reddit UI",
"author": "Bitsocial Labs",
@@ -8,16 +8,17 @@
"private": true,
"dependencies": {
"@capacitor/app": "7.0.1",
"@capacitor/filesystem": "^7.1.4",
"@capacitor/filesystem": "7.1.4",
"@capacitor/local-notifications": "7.0.1",
"@capacitor/share": "^7.0.2",
"@capacitor/share": "7.0.2",
"@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#5524168ef478bc06518168d4c99a829a85c92f69",
"@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#df3cb6901f63054870dc127cd7565c98617c2a4f",
"@types/node": "20.8.2",
"@types/react": "18.2.25",
"@types/react-dom": "18.2.10",
"@types/react": "19.1.2",
"@types/react-dom": "19.1.2",
"@vercel/analytics": "1.6.1",
"ace-builds": "1.41.0",
"cross-env": "7.0.3",
"electron-context-menu": "3.3.0",
@@ -40,7 +41,7 @@
"react-dom": "19.1.2",
"react-dropzone": "14.3.8",
"react-i18next": "13.2.2",
"react-markdown": "8.0.6",
"react-markdown": "10.1.0",
"react-router-dom": "6.30.2",
"react-router-hash-link": "2.4.3",
"react-virtuoso": "4.7.8",
@@ -50,7 +51,8 @@
"remark-supersub": "1.0.0",
"tcp-port-used": "1.0.2",
"typescript": "5.1.6",
"zustand": "4.4.3"
"zustand": "4.4.3",
"kubo": "0.39.0"
},
"scripts": {
"start": "vite",
@@ -61,20 +63,15 @@
"preview": "vite preview",
"analyze-bundle": "cross-env NODE_ENV=production PUBLIC_URL=./ GENERATE_SOURCEMAP=true vite build && npx source-map-explorer 'build/assets/*.js'",
"electron": "yarn build:preload && cross-env ELECTRON_IS_DEV=1 yarn electron:before && cross-env ELECTRON_IS_DEV=1 electron .",
"electron:no-delete-data": "yarn electron:before:download-ipfs && electron .",
"electron:no-delete-data": "yarn electron:before:delete-data && electron .",
"electron:start": "concurrently \"cross-env BROWSER=none yarn start\" \"wait-on http://localhost:3000 && yarn electron\"",
"electron:start:no-delete-data": "concurrently \"cross-env BROWSER=none yarn start\" \"wait-on http://localhost:3000 && yarn electron:no-delete-data\"",
"electron:build:linux": "yarn build && yarn build:preload && electron-rebuild && electron-builder build --publish never -l",
"electron:build:windows": "yarn build && yarn build:preload && yarn electron-rebuild && electron-builder build --publish never -w",
"electron:build:mac": "yarn build && yarn build:preload && yarn electron-rebuild && electron-builder build --publish never -m",
"electron:build:mac:arm64": "cross-env SEEDIT_BUILD_ARCH=arm64 yarn build && yarn build:preload && yarn electron-rebuild && electron-builder build --publish never -m --arm64",
"electron:build:mac:x64": "cross-env SEEDIT_BUILD_ARCH=x64 yarn build && yarn build:preload && yarn electron-rebuild && electron-builder build --publish never -m --x64",
"electron:build:windows:arm64": "cross-env SEEDIT_BUILD_ARCH=arm64 yarn build && yarn build:preload && yarn electron-rebuild && electron-builder build --publish never -w --arm64",
"electron:build:windows:x64": "cross-env SEEDIT_BUILD_ARCH=x64 yarn build && yarn build:preload && yarn electron-rebuild && electron-builder build --publish never -w --x64",
"electron:build:linux:arm64": "cross-env SEEDIT_BUILD_ARCH=arm64 yarn build && yarn build:preload && electron-rebuild && electron-builder build --publish never -l --arm64",
"electron:build:linux:x64": "cross-env SEEDIT_BUILD_ARCH=x64 yarn build && yarn build:preload && electron-rebuild && electron-builder build --publish never -l --x64",
"electron:before": "yarn electron-rebuild && yarn electron:before:download-ipfs && yarn electron:before:delete-data",
"electron:before:download-ipfs": "node electron/download-ipfs",
"electron:package": "yarn build && yarn build:preload && electron-forge package",
"electron:build": "yarn build && yarn build:preload && electron-forge make",
"electron:build:linux": "yarn build && yarn build:preload && electron-forge make --platform=linux",
"electron:build:mac": "yarn build && yarn build:preload && electron-forge make --platform=darwin",
"electron:build:windows": "yarn build && yarn build:preload && electron-forge make --platform=win32",
"electron:before": "yarn electron-rebuild && yarn electron:before:delete-data",
"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": "oxfmt src/**/*.{js,ts,tsx} electron/**/*.{js,mjs}",
@@ -98,9 +95,14 @@
]
},
"devDependencies": {
"@capacitor/android": "7.2.0",
"@capacitor/cli": "7.2.0",
"@capacitor/core": "7.2.0",
"@capacitor/android": "7.4.5",
"@capacitor/cli": "7.4.5",
"@capacitor/core": "7.4.5",
"@electron-forge/cli": "7.6.0",
"@electron-forge/maker-dmg": "7.6.0",
"@electron-forge/maker-squirrel": "7.6.0",
"@electron-forge/maker-zip": "7.6.0",
"@electron-forge/plugin-auto-unpack-natives": "7.6.0",
"@electron/rebuild": "4.0.1",
"@react-scan/vite-plugin-react-scan": "0.1.8",
"@types/memoizee": "0.4.9",
@@ -118,7 +120,6 @@
"cz-conventional-changelog": "3.3.0",
"decompress": "4.2.1",
"electron": "36.4.0",
"electron-builder": "26.0.12",
"husky": "4.3.8",
"isomorphic-fetch": "3.0.0",
"lint-staged": "12.3.8",
@@ -130,14 +131,17 @@
"vite": "npm:rolldown-vite@7.3.1",
"vite-plugin-node-polyfills": "0.24.0",
"vite-plugin-pwa": "0.21.1",
"wait-on": "7.0.1"
"wait-on": "7.0.1",
"@reforged/maker-appimage": "5.1.1",
"glob": "10.5.0",
"progress": "2.0.3"
},
"resolutions": {
"js-yaml": "4.1.1",
"baseline-browser-mapping": "^2.9.11",
"baseline-browser-mapping": "2.9.11",
"vite": "npm:rolldown-vite@7.3.1",
"qs": "6.14.1",
"mdast-util-to-hast": "12.3.0",
"mdast-util-to-hast": "13.2.1",
"node-forge": "1.3.3",
"glob": "10.5.0",
"tmp": "0.2.5",
@@ -151,43 +155,6 @@
"@peculiar/asn1-schema": "2.5.0"
},
"main": "electron/main.js",
"build": {
"appId": "seedit.desktop",
"productName": "seedit",
"beforePack": "electron/before-pack.js",
"afterAllArtifactBuild": "electron/after-all-artifact-build.cjs",
"extraResources": [
{
"from": "bin/${os}",
"to": "bin",
"filter": [
"**/*"
]
}
],
"files": [
"build/**/*",
"electron/**/*",
"package.json"
],
"extends": null,
"mac": {
"target": "dmg",
"category": "public.app-category.social-networking",
"type": "distribution",
"identity": null
},
"win": {
"target": [
"portable",
"nsis"
]
},
"linux": {
"target": "AppImage",
"category": "Network"
}
},
"lint-staged": {
"{src,electron}/**/*.{js,ts,tsx,mjs}": [
"oxfmt"

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "أنت تشاهد هذه المجموعة بالفعل"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "আপনি ইতিমধ্যে এই সম্প্রদায়টি দেখছেন"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Tuto komunitu již prohlížíte"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Du ser allerede denne fællesskab"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Sie sehen diese Community bereits"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Εμφανίζετε ήδη αυτήν την κοινότητα"
}

View File

@@ -386,5 +386,6 @@
"tags": "Tags",
"moderator_of": "moderator of",
"not_subscriber_nor_moderator": "You are not a subscriber nor a moderator of any community.",
"more_posts_last_year": "{{count}} posts last {{currentTimeFilterName}}: <1>show more posts from last year</1>"
"more_posts_last_year": "{{count}} posts last {{currentTimeFilterName}}: <1>show more posts from last year</1>",
"already_in_community": "You're already viewing this community"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Ya estás viendo esta comunidad"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "شما در حال مشاهده این انجمن هستید"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Katsot jo tätä yhteisöä"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Nakikita mo na ang komunidad na ito"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Vous consultez déjà cette communauté"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "אתה כבר צופה בקהילה זו"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "आप पहले से ही इस समुदाय को देख रहे हैं"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Már ezt a közösséget nézed"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Anda sudah melihat komunitas ini"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Stai già visualizzando questa comunità"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "このコミュニティは既に表示中です"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "이미 이 커뮤니티를 보고 있습니다"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "तुम्ही आधीच हे समुदाय पाहत आहात"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Je bekijkt deze community al"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Du ser allerede på dette fellesskapet"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Już przeglądasz tę społeczność"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Você já está visualizando esta comunidade"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Vizualizezi deja această comunitate"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Вы уже просматриваете это сообщество"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Ju po shikoni tashmë këtë komunitet"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Du visar redan denna gemenskap"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "మీరు ఇప్పటికే ఈ కమ్యూనిటీని చూస్తున్నారు"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "คุณกำลังดูชุมชนนี้อยู่แล้ว"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Bu topluluğu zaten görüntülüyorsunuz"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Ви вже переглядаєте це співтовариство"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "آپ پہلے سے ہی اس کمیونٹی کو دیکھ رہے ہیں"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "Bạn đã đang xem cộng đồng này"
}

View File

@@ -386,5 +386,6 @@
"no_global_rules": "...no global rules.",
"for_reddits_downfall": "...for Reddit's downfall.",
"evil_corp_cant_stop_us": "...Evil Corp™ can't stop us.",
"no_gods_no_global_admins": "...no gods, no global admins."
"no_gods_no_global_admins": "...no gods, no global admins.",
"already_in_community": "您已经在查看此社区"
}

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* Find the packaged Electron executable built by Electron Forge.
* This script locates the executable in the out/ directory structure.
*/
import { readdirSync, statSync, existsSync, readFileSync } from 'fs';
import { isAbsolute, join, resolve } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = resolve(__filename, '..');
const platform = process.platform;
const repoRoot = resolve(__dirname, '..');
const packageJson = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf-8'));
const appName = (packageJson.build?.productName || packageJson.name || 'seedit').toLowerCase();
const resolveOutDir = (dir) => (isAbsolute(dir) ? dir : join(repoRoot, dir));
const envOutDir = process.env.ELECTRON_FORGE_OUT_DIR;
const candidateRoots = [
envOutDir ? resolveOutDir(envOutDir) : null,
join(repoRoot, 'out'),
join(repoRoot, 'out', 'make'),
join(repoRoot, '..', 'out'),
join(repoRoot, '..', '..', 'out'),
join(repoRoot, 'electron', 'out'),
].filter(Boolean);
// Skip directories that contain helper binaries/app code, not the main executable
// 'resources' contains the app bundle with IPFS binaries in bin/ - don't recurse there
const skipDirs = new Set(['node_modules', '.git', 'bin', 'app', 'resources']);
function findExecutable(dir, platform) {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
if (skipDirs.has(entry.name)) continue;
if (platform === 'darwin' && entry.name.endsWith('.app')) {
const appPath = fullPath;
const exePath = join(appPath, 'Contents', 'MacOS', entry.name.replace('.app', ''));
if (existsSync(exePath)) {
return exePath;
}
}
const result = findExecutable(fullPath, platform);
if (result) return result;
} else if (entry.isFile()) {
// Check if it's an executable
if (platform === 'win32') {
const lowerName = entry.name.toLowerCase();
if (lowerName.endsWith('.exe') && !lowerName.includes('electron') && !lowerName.includes('crashpad')) {
if (lowerName.includes(appName)) {
return fullPath;
}
return fullPath;
}
} else if (platform === 'darwin') {
const stat = statSync(fullPath);
if (stat.isFile() && stat.mode & parseInt('111', 8)) {
const lowerName = entry.name.toLowerCase();
if (!lowerName.includes('helper') && !lowerName.includes('crashpad')) {
if (lowerName.includes(appName)) {
return fullPath;
}
return fullPath;
}
}
} else if (platform === 'linux') {
// Linux executables (AppImage or unpacked)
if (entry.name.endsWith('.AppImage')) {
return fullPath;
}
// Check for executable files (not .so libraries)
const stat = statSync(fullPath);
if (stat.isFile() && stat.mode & parseInt('111', 8) && !entry.name.includes('.so')) {
// Skip helper binaries
const lowerName = entry.name.toLowerCase();
if (!lowerName.includes('chrome') && !lowerName.includes('crashpad')) {
if (lowerName.includes(appName)) {
return fullPath;
}
return fullPath;
}
}
}
}
}
return null;
}
let executable = null;
const checkedDirs = [];
for (const root of candidateRoots) {
if (!existsSync(root)) {
checkedDirs.push(`${root} (missing)`);
continue;
}
const result = findExecutable(root, platform);
checkedDirs.push(root);
if (result) {
executable = result;
break;
}
}
if (!executable) {
console.error('Error: Could not find packaged executable.');
console.error('Platform:', platform);
console.error('Checked directories:', checkedDirs.join(', '));
process.exit(1);
}
console.log(executable);

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env node
/**
* Verify that a packaged Electron executable starts correctly.
* This script launches the executable and checks that the RPC port (9138) becomes available.
*/
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import { createServer } from 'net';
const executablePath = process.argv[2];
const rpcPort = 9138;
const timeout = 30000; // 30 seconds
if (!executablePath) {
console.error('Usage: node scripts/verify-executable.js <executable-path>');
process.exit(1);
}
if (!existsSync(executablePath)) {
console.error(`Error: Executable not found: ${executablePath}`);
process.exit(1);
}
console.log(`Starting executable: ${executablePath}`);
console.log(`Checking for RPC port ${rpcPort}...`);
// Start the executable
const appProcess = spawn(executablePath, [], {
detached: false,
stdio: 'pipe',
});
let appExited = false;
let portAvailable = false;
let timeoutId;
// Check if port is in use (app is running)
function checkPort() {
return new Promise((resolve) => {
const server = createServer();
server.listen(rpcPort, '127.0.0.1', () => {
server.close();
resolve(false); // Port is available (not in use yet)
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
resolve(true); // Port is in use (app is running - success!)
} else {
resolve(false);
}
});
});
}
// Monitor app process
appProcess.on('exit', (code, signal) => {
appExited = true;
if (code !== null && code !== 0) {
console.error(`App exited with code ${code}`);
cleanup();
process.exit(1);
}
});
appProcess.stderr.on('data', (data) => {
const text = data.toString();
// Filter out common harmless errors
if (!text.includes('Gtk') && !text.includes('libnotify')) {
console.error('stderr:', text);
}
});
// Poll for port availability
async function pollPort() {
const maxAttempts = 30; // 30 attempts over 30 seconds
let attempts = 0;
let pollTimeoutId;
const tick = async () => {
attempts++;
const inUse = await checkPort();
if (inUse) {
portAvailable = true;
console.log(`✓ RPC port ${rpcPort} is in use (app is running)`);
clearTimeout(pollTimeoutId);
clearTimeout(timeoutId);
cleanup();
process.exit(0);
return;
}
if (attempts >= maxAttempts || appExited) {
clearTimeout(pollTimeoutId);
clearTimeout(timeoutId);
if (!portAvailable) {
console.error(`✗ RPC port ${rpcPort} did not become available within ${timeout / 1000} seconds`);
cleanup();
process.exit(1);
}
return;
}
pollTimeoutId = setTimeout(tick, 1000);
};
pollTimeoutId = setTimeout(tick, 1000);
}
// Set overall timeout
timeoutId = setTimeout(() => {
if (!portAvailable) {
console.error(`✗ Timeout: RPC port ${rpcPort} did not become available within ${timeout / 1000} seconds`);
cleanup();
process.exit(1);
}
}, timeout);
function cleanup() {
try {
if (appProcess && !appProcess.killed) {
appProcess.kill();
// Also try to kill any child processes
if (appProcess.pid) {
try {
process.kill(appProcess.pid, 'SIGTERM');
} catch (e) {
// Ignore errors
}
}
}
} catch (e) {
// Ignore cleanup errors
}
}
// Handle process termination
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
// Start polling
pollPort();

View File

@@ -1,30 +1,88 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { copyToClipboard } from '../../lib/utils/clipboard-utils';
import styles from './error-display.module.css';
const ErrorDisplay = ({ error }: { error: any }) => {
type ErrorDetails = {
message?: string;
stack?: string;
details?: unknown;
};
const getErrorDetails = (error: unknown): ErrorDetails | null => {
if (!error) return null;
if (typeof error === 'string') {
return { message: error };
}
if (error instanceof Error) {
return { message: error.message, stack: error.stack };
}
if (typeof error === 'object') {
const maybeError = error as { message?: unknown; stack?: unknown; details?: unknown };
return {
message: typeof maybeError.message === 'string' ? maybeError.message : undefined,
stack: typeof maybeError.stack === 'string' ? maybeError.stack : undefined,
details: maybeError.details,
};
}
return { message: String(error) };
};
const stringifyError = (error: unknown) => {
if (error instanceof Error) {
return JSON.stringify({ name: error.name, message: error.message, stack: error.stack }, null, 2);
}
if (typeof error === 'string') {
return error;
}
try {
return JSON.stringify(error, null, 2);
} catch {
return String(error);
}
};
const ErrorDisplay = ({ error }: { error: unknown }) => {
const { t } = useTranslation();
const [feedbackMessageKey, setFeedbackMessageKey] = useState<string | null>(null);
const errorDetails = getErrorDetails(error);
const feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const originalDisplayMessage = error?.message ? `${t('error')}: ${error.message}` : null;
const originalDisplayMessage = errorDetails?.message ? `${t('error')}: ${errorDetails.message}` : null;
const clearFeedbackTimeout = () => {
if (feedbackTimeoutRef.current) {
clearTimeout(feedbackTimeoutRef.current);
feedbackTimeoutRef.current = null;
}
};
const scheduleFeedbackReset = () => {
clearFeedbackTimeout();
feedbackTimeoutRef.current = setTimeout(() => {
setFeedbackMessageKey(null);
feedbackTimeoutRef.current = null;
}, 1500);
};
useEffect(() => {
return () => {
clearFeedbackTimeout();
};
}, []);
const handleMessageClick = async () => {
if (!error || !error.message || feedbackMessageKey) return;
if (!errorDetails?.message || feedbackMessageKey) return;
const errorString = JSON.stringify(error, null, 2);
const errorString = stringifyError(error);
try {
await copyToClipboard(errorString);
setFeedbackMessageKey('copied');
setTimeout(() => {
setFeedbackMessageKey(null);
}, 1500);
scheduleFeedbackReset();
} catch (err) {
console.error('Failed to copy error: ', err);
setFeedbackMessageKey('failed');
setTimeout(() => {
setFeedbackMessageKey(null);
}, 1500);
scheduleFeedbackReset();
}
};
@@ -44,8 +102,10 @@ const ErrorDisplay = ({ error }: { error: any }) => {
classNames.push(styles.clickableErrorMessage);
}
const shouldRender = Boolean(errorDetails?.message || errorDetails?.stack || errorDetails?.details || error);
return (
(error?.message || error?.stack || error?.details || error) && (
shouldRender && (
<div className={styles.error}>
{currentDisplayMessage && (
<span

View File

@@ -11,7 +11,7 @@ const InfoTooltip = ({ content, showTooltip = true }: InfoTooltipProps) => {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const exitTimeoutRef = useRef<NodeJS.Timeout>();
const exitTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
// Handle opening and closing with animations
const handleOpenChange = (open: boolean) => {

View File

@@ -13,9 +13,14 @@ interface MarkdownProps {
content: string;
}
interface ExtendedComponents extends Components {
spoiler: React.ComponentType<{ children: React.ReactNode }>;
}
type ExtendedComponents = Partial<Components> & {
spoiler?: React.ComponentType<{ children?: React.ReactNode }>;
a?: React.ComponentType<{ children?: React.ReactNode; href?: string }>;
img?: React.ComponentType<{ src?: string; alt?: string }>;
video?: React.ComponentType<{ src?: string }>;
iframe?: React.ComponentType<{ src?: string }>;
source?: React.ComponentType<{ src?: string }>;
};
const MAX_LENGTH_FOR_GFM = 10000; // remarkGfm lags with large content
@@ -129,25 +134,25 @@ const Markdown = ({ content }: MarkdownProps) => {
<ReactMarkdown
children={preprocessedContent}
remarkPlugins={remarkPlugins}
rehypePlugins={[[rehypeRaw as any], [rehypeSanitize, customSchema]]}
rehypePlugins={[[rehypeRaw as any], [rehypeSanitize as any, customSchema]]}
components={
{
a: ({ children, href }) => renderAnchorLink(children, href || ''),
p: ({ children }) => {
a: ({ children, href }: { children?: React.ReactNode; href?: string }) => renderAnchorLink(children, href || ''),
p: ({ children }: { children?: React.ReactNode }) => {
const isEmpty =
!children ||
(Array.isArray(children) && children.every((child) => child === null || child === undefined || (typeof child === 'string' && child.trim() === '')));
return !isEmpty && <p>{children}</p>;
},
img: ({ src, alt }) => {
img: ({ src, alt }: { src?: string; alt?: string }) => {
const displayText = src || alt || 'image';
return <span>{displayText}</span>;
},
video: ({ src }) => <span>{src}</span>,
iframe: ({ src }) => <span>{src}</span>,
source: ({ src }) => <span>{src}</span>,
spoiler: ({ children }) => <SpoilerText>{children}</SpoilerText>,
video: ({ src }: { src?: string }) => <span>{src}</span>,
iframe: ({ src }: { src?: string }) => <span>{src}</span>,
source: ({ src }: { src?: string }) => <span>{src}</span>,
spoiler: ({ children }: { children?: React.ReactNode }) => <SpoilerText>{children}</SpoilerText>,
} as ExtendedComponents
}
/>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Author, useAccount, useComment, useSubplebbit } from '@plebbit/plebbit-react-hooks';
@@ -59,20 +59,34 @@ const ModOrReportButton = ({ cid, isAuthor, isAccountMod, isCommentAuthorMod }:
const ShareButton = ({ cid, subplebbitAddress }: { cid: string; subplebbitAddress: string }) => {
const { t } = useTranslation();
const [hasCopied, setHasCopied] = useState(false);
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (hasCopied) {
setTimeout(() => setHasCopied(false), 2000);
const clearResetTimeout = () => {
if (resetTimeoutRef.current) {
clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = null;
}
}, [hasCopied]);
};
const scheduleReset = () => {
clearResetTimeout();
resetTimeoutRef.current = setTimeout(() => {
setHasCopied(false);
resetTimeoutRef.current = null;
}, 2000);
};
useEffect(() => () => clearResetTimeout(), []);
const handleCopy = async () => {
try {
setHasCopied(true);
scheduleReset();
await copyShareLinkToClipboard(subplebbitAddress, cid);
} catch (error) {
console.error('Failed to copy share link:', error);
setHasCopied(false);
clearResetTimeout();
}
};

View File

@@ -141,6 +141,10 @@ const SearchBar = ({ isFocused = false, onExpandoChange }: SearchBarProps) => {
}
const searchInput = searchInputRef.current?.value;
if (searchInput) {
if (searchInput.toLowerCase() === params.subplebbitAddress?.toLowerCase()) {
alert(t('already_in_community'));
return;
}
setInputValue('');
navigate(`/s/${searchInput}`);
}
@@ -179,6 +183,10 @@ const SearchBar = ({ isFocused = false, onExpandoChange }: SearchBarProps) => {
const handleCommunitySelect = useCallback(
(address: string) => {
if (address.toLowerCase() === params.subplebbitAddress?.toLowerCase()) {
alert(t('already_in_community'));
return;
}
setInputValue('');
setIsInputFocused(false);
setActiveDropdownIndex(-1);
@@ -186,7 +194,7 @@ const SearchBar = ({ isFocused = false, onExpandoChange }: SearchBarProps) => {
searchInputRef.current?.blur();
navigate(`/s/${address}`);
},
[navigate, setInputValue, setIsInputFocused, setActiveDropdownIndex],
[navigate, setInputValue, setIsInputFocused, setActiveDropdownIndex, params.subplebbitAddress, t],
);
const handleKeyDown = useCallback(
@@ -230,7 +238,6 @@ const SearchBar = ({ isFocused = false, onExpandoChange }: SearchBarProps) => {
autoCapitalize='off'
placeholder={placeholder}
ref={(instance) => {
// @ts-expect-error Property 'current' is read-only.
searchInputRef.current = instance;
refs.setReference(instance);
}}

View File

@@ -84,9 +84,11 @@ const PostInfo = ({ comment }: { comment: Comment | undefined }) => {
<span className={styles.postScoreWord}>{postScore === 1 ? t('point') : t('points')}</span>{' '}
{`(${postScore === '?' ? '?' : `${upvotePercentage}`}% ${t('upvoted')})`}
</div>
<div className={styles.shareLink}>
{t('share_link')}: <input type='text' value={`https://pleb.bz/s/${subplebbitAddress}/c/${cid}`} readOnly={true} />
</div>
{subplebbitAddress && cid && (
<div className={styles.shareLink}>
{t('share_link')}: <input type='text' value={`https://seedit.app/s/${subplebbitAddress}/c/${cid}`} readOnly={true} />
</div>
)}
</div>
);
};

View File

@@ -35,8 +35,8 @@ const useIsSubplebbitOffline = (subplebbit: Subplebbit) => {
const offlineTitle = isLoading
? t('loading')
: updatedAt
? isOffline && t('posts_last_synced_info', { time: getFormattedTimeAgo(updatedAt), interpolation: { escapeValue: false } })
: t('subplebbit_offline_info');
? isOffline && t('posts_last_synced_info', { time: getFormattedTimeAgo(updatedAt), interpolation: { escapeValue: false } })
: t('subplebbit_offline_info');
// ensure isOffline is false until we have enough information
const hasEnoughInfo = subplebbitOfflineStore.initialLoad === false || updatedAt !== undefined;

View File

@@ -1,14 +1,28 @@
import assert from 'assert';
import { useEffect } from 'react';
import { useLocation, useParams, Params } from 'react-router-dom';
import { isSubplebbitView, isAllView, isModView, isHomeView, isDomainView } from '../lib/utils/view-utils';
// the timestamp the last time the user visited
const lastVisitTimestamp = localStorage.getItem('seeditLastVisitTimestamp');
// update the last visited timestamp every n seconds
setInterval(() => {
localStorage.setItem('seeditLastVisitTimestamp', Date.now().toString());
}, 60 * 1000);
const VISIT_UPDATE_INTERVAL_MS = 60 * 1000;
let lastVisitIntervalId: ReturnType<typeof setInterval> | null = null;
let lastVisitIntervalUsers = 0;
const startLastVisitInterval = () => {
if (lastVisitIntervalId) return;
lastVisitIntervalId = setInterval(() => {
localStorage.setItem('seeditLastVisitTimestamp', Date.now().toString());
}, VISIT_UPDATE_INTERVAL_MS);
};
const stopLastVisitIntervalIfUnused = () => {
if (lastVisitIntervalUsers === 0 && lastVisitIntervalId) {
clearInterval(lastVisitIntervalId);
lastVisitIntervalId = null;
}
};
const timeFilterNamesToSeconds: Record<string, number | undefined> = {
'1h': 60 * 60,
@@ -120,6 +134,16 @@ const useTimeFilter = () => {
const isInDomainView = Boolean(params.domain);
const sessionKey = getSessionKeyForView(location.pathname, params);
useEffect(() => {
lastVisitIntervalUsers += 1;
startLastVisitInterval();
return () => {
lastVisitIntervalUsers -= 1;
stopLastVisitIntervalIfUnused();
};
}, []);
let timeFilterName = params.timeFilterName;
if (!timeFilterName) {
const sessionPreference = getSessionTimeFilterPreference(sessionKey);

View File

@@ -9,6 +9,12 @@ import './themes.css';
import './preload-assets.css';
import { App as CapacitorApp } from '@capacitor/app';
import { registerSW } from 'virtual:pwa-register';
import { Analytics } from '@vercel/analytics/react';
// Only enable analytics on seedit.app (Vercel deployment)
// Exclude Electron (file:// or localhost), Capacitor/APK (capacitor:// or localhost), and IPFS (ipfs:// or different domain)
const isVercelDeployment =
typeof window !== 'undefined' && (window.location.hostname === 'seedit.app' || window.location.hostname === 'www.seedit.app') && !window.isElectron;
if (window.location.hostname.startsWith('p2p.')) {
(window as any).defaultPlebbitOptions = {
@@ -38,6 +44,7 @@ root.render(
<React.StrictMode>
<Router>
<App />
{isVercelDeployment && <Analytics />}
</Router>
</React.StrictMode>,
);

View File

@@ -18,7 +18,7 @@ export const isValidURL = (url: string) => {
};
export const copyShareLinkToClipboard = async (subplebbitAddress: string, cid: string) => {
const shareLink = `https://pleb.bz/s/${subplebbitAddress}/c/${cid}`;
const shareLink = `https://seedit.app/s/${subplebbitAddress}/c/${cid}`;
await copyToClipboard(shareLink);
};

View File

@@ -27,7 +27,7 @@ const Author = () => {
const { authorComments, error, lastCommentCid, hasMore, loadMore } = useAuthorComments({ commentCid, authorAddress });
const prevErrorMessageRef = useRef<string | undefined>();
const prevErrorMessageRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (error && error.message !== prevErrorMessageRef.current) {
console.log(error);

View File

@@ -152,7 +152,7 @@ const Overview = () => {
}
}, [error, sortedComments]);
const prevErrorMessageRef = useRef<string | undefined>();
const prevErrorMessageRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (error && error.message !== prevErrorMessageRef.current) {
console.log(error);
@@ -193,7 +193,7 @@ const Comments = () => {
}
}, [error, sortedComments]);
const prevErrorMessageRef = useRef<string | undefined>();
const prevErrorMessageRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (error && error.message !== prevErrorMessageRef.current) {
console.log(error);
@@ -234,7 +234,7 @@ const Submitted = () => {
}
}, [error, sortedComments]);
const prevErrorMessageRef = useRef<string | undefined>();
const prevErrorMessageRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (error && error.message !== prevErrorMessageRef.current) {
console.log(error);
@@ -279,7 +279,7 @@ const VotedComments = ({ voteType }: { voteType: 1 | -1 }) => {
}
}, [error, paginatedCids, hasMore]);
const prevErrorMessageRef = useRef<string | undefined>();
const prevErrorMessageRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (error && error.message !== prevErrorMessageRef.current) {
console.log(error);

View File

@@ -1,17 +1,33 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { requestNotificationPermission, showLocalNotification } from '../../../lib/push';
import useContentOptionsStore from '../../../stores/use-content-options-store';
import styles from './notifications-settings.module.css';
const clearTimeoutRef = (timeoutRef: { current: ReturnType<typeof setTimeout> | null }) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
const NotificationsSettings = () => {
const { t } = useTranslation();
const { enableLocalNotifications, setEnableLocalNotifications } = useContentOptionsStore();
const [permissionStatus, setPermissionStatus] = useState<string | null>(null);
const [platform, setPlatform] = useState<NodeJS.Platform | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [showDeniedMessage, setShowDeniedMessage] = useState(false);
const [showSuccessMessage, setShowSuccessMessage] = useState(false);
const [flashStatus, setFlashStatus] = useState<'denied' | 'success' | null>(null);
const flashTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scheduleFlashReset = useCallback((status: 'denied' | 'success') => {
setFlashStatus(status);
clearTimeoutRef(flashTimeoutRef);
flashTimeoutRef.current = setTimeout(() => {
setFlashStatus(null);
flashTimeoutRef.current = null;
}, 5000);
}, []);
// Function to check permission via API, memoized with useCallback
const checkPermissionStatus = useCallback(async () => {
@@ -28,8 +44,7 @@ const NotificationsSettings = () => {
if (enableLocalNotifications) {
setEnableLocalNotifications(false);
}
setShowDeniedMessage(true);
setTimeout(() => setShowDeniedMessage(false), 5000);
scheduleFlashReset('denied');
} else if (nativeStatus === 'not-determined') {
console.warn('[NotificationsSettings] Permission status is not-determined, cannot test.');
} else if (nativeStatus === 'not-supported') {
@@ -41,23 +56,39 @@ const NotificationsSettings = () => {
console.error('[Electron Native] Error checking notification permissions:', err);
setPermissionStatus('unknown');
}
}, [setEnableLocalNotifications, enableLocalNotifications]);
}, [enableLocalNotifications, scheduleFlashReset, setEnableLocalNotifications]);
// Run the check on mount
useEffect(() => {
if (window.electronApi) {
if (window.electronApi.getPlatform) {
window.electronApi.getPlatform().then(setPlatform).catch(console.error);
if (!window.electronApi) return;
let cancelled = false;
const loadPlatform = async () => {
if (!window.electronApi?.getPlatform) return;
try {
const value = await window.electronApi.getPlatform();
if (!cancelled) {
setPlatform(value);
}
} catch (error) {
console.error(error);
}
checkPermissionStatus();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Keep dependencies empty - runs only once
};
loadPlatform();
void checkPermissionStatus();
return () => {
cancelled = true;
};
}, [checkPermissionStatus]);
useEffect(() => () => clearTimeoutRef(flashTimeoutRef), []);
const handleCheckboxChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const isEnabled = event.target.checked;
setIsLoading(true);
setShowSuccessMessage(false);
setFlashStatus(null);
try {
if (isEnabled) {
@@ -67,12 +98,10 @@ const NotificationsSettings = () => {
setPermissionStatus(currentStatus);
if (currentStatus === 'granted') {
setEnableLocalNotifications(true);
setShowSuccessMessage(true);
setTimeout(() => setShowSuccessMessage(false), 5000);
scheduleFlashReset('success');
} else if (currentStatus === 'denied') {
setEnableLocalNotifications(false); // Ensure it's off if denied
setShowDeniedMessage(true);
setTimeout(() => setShowDeniedMessage(false), 5000);
scheduleFlashReset('denied');
} else if (currentStatus === 'not-determined') {
setEnableLocalNotifications(false); // Keep it off until granted
console.warn('[NotificationsSettings] Permission not determined. User must grant via OS prompt.');
@@ -87,13 +116,11 @@ const NotificationsSettings = () => {
if (granted) {
setEnableLocalNotifications(true);
setPermissionStatus('granted');
setShowSuccessMessage(true);
setTimeout(() => setShowSuccessMessage(false), 5000);
scheduleFlashReset('success');
} else {
setEnableLocalNotifications(false);
setPermissionStatus('denied');
setShowDeniedMessage(true);
setTimeout(() => setShowDeniedMessage(false), 5000);
scheduleFlashReset('denied');
}
}
} else {
@@ -121,8 +148,7 @@ const NotificationsSettings = () => {
});
} else if (status === 'denied') {
alert('Notifications are denied in System Settings.');
setShowDeniedMessage(true);
setTimeout(() => setShowDeniedMessage(false), 5000);
scheduleFlashReset('denied');
} else if (status === 'not-determined') {
alert('Permission not yet granted. The app will ask when it first tries to notify you (or test again).');
} else {
@@ -162,14 +188,14 @@ const NotificationsSettings = () => {
)}
{/* Permission status messages */}
{showDeniedMessage && permissionStatus === 'denied' && (
{flashStatus === 'denied' && permissionStatus === 'denied' && (
<span className={styles.permissionStatus} data-status={permissionStatus}>
<span className={styles.permissionStatusDenied}>
{window.electronApi?.isElectron && platform === 'darwin'
? 'Permission denied. Please go to System Settings > Notifications > Seedit and enable notifications.'
: window.electronApi?.isElectron
? `Permission denied. Please check your system's ${platform && `(${platform}) `} notification settings for this application.`
: `Permission denied. Please allow notifications for this site in your browser settings.`}
? `Permission denied. Please check your system's ${platform ? `(${platform}) ` : ''} notification settings for this application.`
: `Permission denied. Please allow notifications for this site in your browser settings.`}
</span>
</span>
)}
@@ -178,7 +204,7 @@ const NotificationsSettings = () => {
<span className={styles.permissionStatusRequesting}>Click "Allow" to enable notifications</span>
</span>
)}
{showSuccessMessage && permissionStatus === 'granted' && enableLocalNotifications && (
{flashStatus === 'success' && permissionStatus === 'granted' && enableLocalNotifications && (
<span className={styles.permissionStatus} data-status={permissionStatus}>
<span className={styles.permissionStatusSuccess}>
Success! You're done.

View File

@@ -5,16 +5,16 @@ import { setAccount, useAccount, usePlebbitRpcSettings } from '@plebbit/plebbit-
import styles from './plebbit-options.module.css';
interface SettingsProps {
ipfsGatewayUrlsRef?: RefObject<HTMLTextAreaElement>;
mediaIpfsGatewayUrlRef?: RefObject<HTMLInputElement>;
pubsubProvidersRef?: RefObject<HTMLTextAreaElement>;
ethRpcRef?: RefObject<HTMLTextAreaElement>;
solRpcRef?: RefObject<HTMLTextAreaElement>;
maticRpcRef?: RefObject<HTMLTextAreaElement>;
avaxRpcRef?: RefObject<HTMLTextAreaElement>;
httpRoutersRef?: RefObject<HTMLTextAreaElement>;
plebbitRpcRef?: RefObject<HTMLInputElement>;
plebbitDataPathRef?: RefObject<HTMLInputElement>;
ipfsGatewayUrlsRef?: RefObject<HTMLTextAreaElement | null>;
mediaIpfsGatewayUrlRef?: RefObject<HTMLInputElement | null>;
pubsubProvidersRef?: RefObject<HTMLTextAreaElement | null>;
ethRpcRef?: RefObject<HTMLTextAreaElement | null>;
solRpcRef?: RefObject<HTMLTextAreaElement | null>;
maticRpcRef?: RefObject<HTMLTextAreaElement | null>;
avaxRpcRef?: RefObject<HTMLTextAreaElement | null>;
httpRoutersRef?: RefObject<HTMLTextAreaElement | null>;
plebbitRpcRef?: RefObject<HTMLInputElement | null>;
plebbitDataPathRef?: RefObject<HTMLInputElement | null>;
}
const IPFSGatewaysSettings = ({ ipfsGatewayUrlsRef, mediaIpfsGatewayUrlRef }: SettingsProps) => {

View File

@@ -116,7 +116,7 @@ const SubplebbitDataEditor = () => {
const subplebbitSettings = useMemo(() => JSON.stringify(currentSettings, null, 2), [currentSettings]);
// Update text when settings change, but not when user is actively typing
const timeoutRef = useRef<NodeJS.Timeout>();
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
useEffect(() => {
// Clear any pending timeout
if (timeoutRef.current) {

View File

@@ -355,7 +355,7 @@ const Subplebbit = () => {
const hasUnhiddenAnyNsfwCommunity = !hideAdultCommunities || !hideGoreCommunities || !hideAntiCommunities || !hideVulgarCommunities;
const isBroadlyNsfwSubplebbit = useIsBroadlyNsfwSubplebbit(subplebbitAddress || '');
const prevErrorMessageRef = useRef<string | undefined>();
const prevErrorMessageRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (error && error.message !== prevErrorMessageRef.current) {
console.log(error);

View File

@@ -581,7 +581,7 @@ const Subplebbits = () => {
}, [documentTitle]);
const renderErrors = () => {
const errorsToDisplay: JSX.Element[] = [];
const errorsToDisplay: React.JSX.Element[] = [];
Object.entries(errors).forEach(([source, errorObj]) => {
if (!errorObj) return;

5
vercel.json Normal file
View File

@@ -0,0 +1,5 @@
{
"buildCommand": "yarn build",
"outputDirectory": "build",
"framework": "vite"
}

View File

@@ -13,17 +13,21 @@ export default defineConfig({
react({
babel: {
plugins: [
['babel-plugin-react-compiler', {
verbose: true
}]
]
}
[
'babel-plugin-react-compiler',
{
verbose: true,
},
],
],
},
}),
// Only include React Scan in development mode - never in production builds
(isDevelopment || (!isProduction && process.env.NODE_ENV !== 'production')) && reactScan({
showToolbar: true,
playSound: true,
}),
!isProduction &&
reactScan({
showToolbar: true,
playSound: true,
}),
nodePolyfills({
globals: {
Buffer: true,
@@ -57,20 +61,20 @@ export default defineConfig({
{
src: '/android-chrome-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png'
type: 'image/png',
},
{
src: '/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
},
{
src: '/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable',
},
],
},
workbox: {
clientsClaim: true,
@@ -85,8 +89,8 @@ export default defineConfig({
urlPattern: ({ url }) => url.pathname === '/' || url.pathname === '/index.html',
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'html-cache'
}
cacheName: 'html-cache',
},
},
// PNG caching
{
@@ -95,9 +99,9 @@ export default defineConfig({
options: {
cacheName: 'images',
expiration: {
maxEntries: 50
}
}
maxEntries: 50,
},
},
},
// Add additional asset caching
{
@@ -107,9 +111,9 @@ export default defineConfig({
cacheName: 'assets-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
}
}
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
},
},
},
// Google Fonts caching
{
@@ -119,12 +123,12 @@ export default defineConfig({
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 365 days
maxAgeSeconds: 60 * 60 * 24 * 365, // 365 days
},
cacheableResponse: {
statuses: [0, 200]
}
}
statuses: [0, 200],
},
},
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
@@ -133,27 +137,27 @@ export default defineConfig({
cacheName: 'google-fonts-webfonts',
expiration: {
maxEntries: 30,
maxAgeSeconds: 60 * 60 * 24 * 365 // 365 days
maxAgeSeconds: 60 * 60 * 24 * 365, // 365 days
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
}
statuses: [0, 200],
},
},
},
],
},
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'node-fetch': 'isomorphic-fetch',
'assert': 'assert',
'stream': 'stream-browserify',
'crypto': 'crypto-browserify',
'buffer': 'buffer',
assert: 'assert',
stream: 'stream-browserify',
crypto: 'crypto-browserify',
buffer: 'buffer',
'util/': 'util',
'util': 'util',
util: 'util',
},
},
server: {
@@ -163,11 +167,12 @@ export default defineConfig({
usePolling: true,
},
hmr: {
overlay: false
}
overlay: false,
},
},
build: {
outDir: 'dist',
// Use 'build' to match what electron/main.js expects (../build/index.html)
outDir: 'build',
emptyOutDir: true,
sourcemap: process.env.GENERATE_SOURCEMAP === 'true',
target: process.env.ELECTRON ? 'electron-renderer' : 'esnext',
@@ -177,27 +182,17 @@ export default defineConfig({
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: {
include: [
'ethers',
'assert',
'buffer',
'process',
'util',
'stream-browserify',
'isomorphic-fetch',
'workbox-core',
'workbox-precaching'
],
include: ['ethers', 'assert', 'buffer', 'process', 'util', 'stream-browserify', 'isomorphic-fetch', 'workbox-core', 'workbox-precaching'],
},
define: {
'process.env.VITE_COMMIT_REF': JSON.stringify(process.env.COMMIT_REF),
'global': 'globalThis',
'__dirname': '""',
}
global: 'globalThis',
__dirname: '""',
},
});

2457
yarn.lock
View File

File diff suppressed because it is too large Load Diff