diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ffb118b..2d4b8fe3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,10 @@ on: jobs: linux: + strategy: + fail-fast: false + matrix: + arch: [x64, arm64] runs-on: ubuntu-22.04 permissions: contents: write @@ -20,24 +24,23 @@ jobs: with: # needed for git commit history changelog fetch-depth: 0 - - name: Setup Node.js v20 - uses: actions/setup-node@v2 - with: - node-version: 20 - - name: Install dependencies (with Node v20) - run: yarn install --frozen-lockfile --ignore-engines - # make sure the ipfs executable is executable - - name: Download IPFS and set permissions (with Node v20) - run: node electron/download-ipfs && sudo chmod +x bin/linux/ipfs - - - name: Setup Node.js v22 for Electron build + - name: Setup Node.js v22 uses: actions/setup-node@v2 with: node-version: 22 + - name: Install dependencies (with Node v22) + run: yarn install --frozen-lockfile --ignore-engines + # make sure the ipfs executable is executable + - name: Download IPFS and set permissions (with Node v22) + run: node electron/download-ipfs && sudo chmod +x bin/linux/ipfs - name: Build React app (with Node v22) - run: CI='' yarn build - - name: Build Electron app for Linux (with Node v22) - run: yarn electron:build:linux + run: CI='' NODE_ENV=production yarn build + - name: Build Electron app for Linux (arm64) + if: ${{ matrix.arch == 'arm64' }} + run: yarn electron:build:linux:arm64 + - name: Build Electron app for Linux (x64) + if: ${{ matrix.arch == 'x64' }} + run: yarn electron:build:linux:x64 - name: List dist directory run: ls dist @@ -46,14 +49,22 @@ jobs: run: node scripts/release-body > release-body.txt - uses: ncipollo/release-action@v1 with: - artifacts: 'dist/5chan*.AppImage,dist/5chan-html*.zip' + artifacts: 'dist/5chan*.AppImage,dist/5chan*-arm64.AppImage,dist/5chan-html*.zip' token: ${{ secrets.GITHUB_TOKEN }} replacesArtifacts: true - bodyFile: "release-body.txt" + omitBody: true allowUpdates: true mac: - runs-on: macos-15 + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15-intel # Intel x64 + arch: x64 + - runner: macos-latest # Apple Silicon arm64 + arch: arm64 + runs-on: ${{ matrix.runner }} permissions: contents: write steps: @@ -62,10 +73,14 @@ jobs: with: # needed for git commit history changelog fetch-depth: 0 - - name: Setup Node.js v20 + - name: Setup Node.js v22 uses: actions/setup-node@v2 with: - node-version: 20 + node-version: 22 + - name: Setup Python 3.12 + uses: actions/setup-python@v5 + 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 @@ -73,20 +88,22 @@ jobs: python-version: '3.12' - run: pip install setuptools - - name: Install dependencies (with Node v20) + - name: Install dependencies (with Node v22) run: yarn install --frozen-lockfile --ignore-engines # make sure the ipfs executable is executable - - name: Download IPFS and set permissions (with Node v20) + - name: Download IPFS and set permissions (with Node v22) run: node electron/download-ipfs && sudo chmod +x bin/mac/ipfs - - - name: Setup Node.js v22 for Electron build - uses: actions/setup-node@v2 - with: - node-version: 22 - name: Build React app (with Node v22) - run: CI='' yarn build + run: CI='' NODE_ENV=production yarn build - name: Build Electron app for Mac (with Node v22) - run: yarn electron:build:mac + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + run: | + if [ "${{ matrix.arch }}" = "arm64" ]; then + yarn electron:build:mac:arm64 + else + yarn electron:build:mac:x64 + fi - name: List dist directory run: ls dist @@ -95,10 +112,10 @@ jobs: run: node scripts/release-body > release-body.txt - uses: ncipollo/release-action@v1 with: - artifacts: 'dist/5chan*.dmg' + artifacts: 'dist/5chan*.dmg,dist/5chan*-arm64.dmg' token: ${{ secrets.GITHUB_TOKEN }} replacesArtifacts: true - bodyFile: "release-body.txt" + omitBody: true allowUpdates: true windows: @@ -111,15 +128,15 @@ jobs: with: # needed for git commit history changelog fetch-depth: 0 - - name: Setup Node.js v22 # electron-rebuild needs Node 22+ + - name: Setup Node.js v22 uses: actions/setup-node@v2 with: node-version: 22 - name: Install dependencies (with Node v22) # --network-timeout and --network-concurrency are yarn v1 flags. run: yarn install --frozen-lockfile --network-timeout 100000 --network-concurrency 1 - name: Build React app (with Node v22) - run: yarn build - - name: Build Electron app for Windows (with Node v22) + run: npx cross-env NODE_ENV=production yarn build + - name: Build Electron app for Windows (x64) run: yarn electron:build:windows - name: List dist directory run: dir dist @@ -132,7 +149,7 @@ jobs: artifacts: 'dist/5chan*.exe' token: ${{ secrets.GITHUB_TOKEN }} replacesArtifacts: true - bodyFile: "release-body.txt" + omitBody: true allowUpdates: true android: @@ -153,14 +170,14 @@ jobs: gradle-version: 8.9 - uses: actions/setup-node@v2 with: - node-version: 20 + node-version: 22 - run: sudo apt install -y apksigner zipalign # install all dependencies (including devDependencies needed for React build) - - name: Install dependencies (with Node v20) + - name: Install dependencies (with Node v22) run: yarn install --frozen-lockfile --ignore-engines # build react app - - run: CI='' yarn build + - run: CI='' NODE_ENV=production yarn build # set android versionCode and versionName - run: sed -i "s/versionCode 1/versionCode $(git tag | wc -l)/" ./android/app/build.gradle - run: sed -i "s/versionName \"1.0\"/versionName \"$(node -e "console.log(require('./package.json').version)")\"/" ./android/app/build.gradle @@ -168,7 +185,7 @@ jobs: # build apk - run: npx cap sync android - run: cd android && gradle bundle - - run: cd android && ./gradlew assembleRelease + - run: cd android && ./gradlew assembleRelease --stacktrace # optimize apk - run: cd android/app/build/outputs/apk/release && zipalign 4 app-release-unsigned.apk app-release-unsigned-zip.apk # sign apk @@ -185,5 +202,31 @@ jobs: artifacts: 'dist/5chan*.apk' token: ${{ secrets.GITHUB_TOKEN }} replacesArtifacts: true - bodyFile: "release-body.txt" + omitBody: true allowUpdates: true + + finalize-release: + runs-on: ubuntu-22.04 + needs: [linux, mac, windows, android] + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Setup Node.js v22 + uses: actions/setup-node@v2 + with: + node-version: 22 + - name: Install dependencies + run: yarn install --frozen-lockfile --ignore-engines + - name: Generate final release body from GitHub assets + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_REF_NAME: ${{ github.ref_name }} + run: node scripts/release-body > release-body.txt + - name: Update release body only + uses: ncipollo/release-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + allowUpdates: true + bodyFile: "release-body.txt" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f0f94345..887ba26c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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,45 +110,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 - # 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/5chan.app" ]; then - echo "Testing dist/mac/5chan.app..." - # Run the app in background - it will start IPFS which takes time - # We just verify it launches without crashing - ./dist/mac/5chan.app/Contents/MacOS/5chan & - 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/5chan.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) @@ -151,7 +145,7 @@ jobs: - 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/5chan.app" ]; then - APP_PATH="dist/mac-arm64/5chan.app" - elif [ -d "dist/mac/5chan.app" ]; then - APP_PATH="dist/mac/5chan.app" - else - echo "Could not find 5chan.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/5chan" & - 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/5chan.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" diff --git a/.gitignore b/.gitignore index 1bae9db2..712a4d8b 100644 --- a/.gitignore +++ b/.gitignore @@ -54,9 +54,6 @@ web_modules/ # TypeScript cache *.tsbuildinfo -# Generated files -src/generated/asset-manifest.ts - # Optional npm cache directory .npm @@ -99,6 +96,11 @@ out # Nuxt.js build / generate output .nuxt dist + +# Electron Forge output +squashfs-root + +# Vite PWA dev output dev-dist # Gatsby files @@ -149,7 +151,7 @@ dev-dist /coverage # production -# (using dist, already ignored above) +/build # misc .DS_Store @@ -163,8 +165,4 @@ yarn-debug.log* yarn-error.log* .vscode -.cursor -.playwright-mcp - -.specstory -.cursorindexingignore \ No newline at end of file +.cursor \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 8edd2ed4..c266e8c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Overview -5chan is a serverless, adminless, decentralized 4chan alternative built on the Plebbit protocol. +5chan is a serverless, adminless, decentralized 4chan alternative built on the Bitsocial protocol. ## Stack @@ -31,8 +31,9 @@ yarn electron # Run Electron app ## Code Style - TypeScript strict mode -- Prettier for formatting (runs on pre-commit) -- **DRY principle**: 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/` +- **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) @@ -106,6 +107,59 @@ Discover and install skills from the open agent skills ecosystem. 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 ` | 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 ### GitHub MCP @@ -169,19 +223,64 @@ 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 title and description. Format: +When proposing or implementing code changes, always suggest a GitHub issue to track the problem. Format: -- **Title**: Use [Conventional Commits](https://www.conventionalcommits.org/) style (e.g., `fix: ...`, `feat: ...`, `perf: ...`, `refactor: ...`, `docs: ...`, `chore: ...`) -- **Description**: 2-3 informal sentences describing the problem (not the solution). Write as if the issue hasn't been fixed yet. Use markdown. +- **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. -Use the `perf` type for performance optimizations (not `fix`). +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 @@ -189,4 +288,3 @@ When stuck on a bug or issue, search the web for solutions. Developer communitie - Keep components focused—split large components - 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) - diff --git a/README.md b/README.md index 61843a1c..8a5c2134 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Currently, directory assignments are temporarily handpicked by developers throug ### How It Works -- **Current System**: Developers manually curate directory assignments by reviewing pull requests to the [5chan-multisub.json](https://github.com/bitsocialhq/lists/blob/master/5chan-multisub.json) file. +- **Current System**: Developers manually curate directory assignments by reviewing pull requests to the [5chan-directories.json](https://github.com/bitsocialhq/lists/blob/master/5chan-directories.json) file. - **Future System**: Directory board assignments will be determined through gasless voting using pubsub. Community members will vote on which board should be assigned to each directory, and the highest-voted board will automatically become the directory board. This creates a competitive marketplace where board quality and community engagement determine directory placement. @@ -72,7 +72,7 @@ To have your board appear in a directory on the 5chan homepage: - Relevant to the directory category - **99% uptime** (since a board acts like its own server—it's a P2P node) -2. Open a pull request on GitHub by editing the [5chan-multisub.json](https://github.com/bitsocialhq/lists/blob/master/5chan-multisub.json) file +2. Open a pull request on GitHub by editing the [5chan-directories.json](https://github.com/bitsocialhq/lists/blob/master/5chan-directories.json) file 3. Add your board's entry with: - Title: in the format `/directoryCode/ - Title`, e.g. "/biz/ - Business & Finance"; diff --git a/electron/after-all-artifact-build.cjs b/electron/after-all-artifact-build.cjs deleted file mode 100644 index cea36f49..00000000 --- a/electron/after-all-artifact-build.cjs +++ /dev/null @@ -1,41 +0,0 @@ -const fs = require('fs-extra'); -const path = require('path'); -const { execSync } = require('child_process'); -const packageJson = require('../package.json'); -const rootPath = path.resolve(__dirname, '..'); -const distFolderPath = path.resolve(rootPath, 'dist'); - -function addPortableToPortableExecutableFileName() { - const files = fs.readdirSync(distFolderPath); - for (const file of files) { - if (file.endsWith('.exe') && !file.match('Setup')) { - const filePath = path.resolve(distFolderPath, file); - const renamedFilePath = path.resolve(distFolderPath, file.replace('5chan', '5chan Portable')); - fs.moveSync(filePath, renamedFilePath); - } - } -} - -function createHtmlArchive() { - if (process.platform !== 'linux') { - return; - } - const zipBinPath = path.resolve(rootPath, 'node_modules', '7zip-bin', 'linux', 'x64', '7za'); - const fivechanHtmlFolderName = `5chan-html-${packageJson.version}`; - const outputFile = path.resolve(distFolderPath, `${fivechanHtmlFolderName}.zip`); - // Vite outputs to 'dist', not 'build' (CRA default) - const inputFolder = path.resolve(rootPath, 'dist'); - try { - // Exclude Electron builder artifacts from HTML archive - const excludes = '-xr!*.AppImage -xr!*.exe -xr!*.dmg -xr!*.blockmap -xr!*.yml -xr!*.yaml -xr!win-unpacked -xr!mac -xr!mac-arm64 -xr!linux-unpacked -xr!builder-*'; - execSync(`${zipBinPath} a ${outputFile} ${inputFolder} ${excludes}`); - execSync(`${zipBinPath} rn -r ${outputFile} dist ${fivechanHtmlFolderName}`); - } catch (e) { - console.error('electron build createHtmlArchive error:', e); - } -} - -module.exports = async function afterAllArtifactBuild(buildResult) { - addPortableToPortableExecutableFileName(); - createHtmlArchive(); -}; \ No newline at end of file diff --git a/electron/before-pack.js b/electron/before-pack.js index 1ceef74a..5b055d06 100644 --- a/electron/before-pack.js +++ b/electron/before-pack.js @@ -17,11 +17,28 @@ const ipfsClientLinuxPath = path.join(ipfsClientsPath, 'linux'); // const ipfsClientMacUrl = `https://github.com/plebbit/kubo/releases/download/v${ipfsClientVersion}/ipfs-darwin-amd64` // const ipfsClientLinuxUrl = `https://github.com/plebbit/kubo/releases/download/v${ipfsClientVersion}/ipfs-linux-amd64` -// official kubo download links https://docs.ipfs.tech/install/command-line/#install-official-binary-distributions -const ipfsClientVersion = '0.32.1'; -const ipfsClientWindowsUrl = `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_windows-amd64.zip`; -const ipfsClientMacUrl = `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_darwin-amd64.tar.gz`; -const ipfsClientLinuxUrl = `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_linux-amd64.tar.gz`; +// NOTE: Keep this version in sync with the kubo version in package.json to avoid repo version mismatches +const ipfsClientVersion = '0.39.0'; + +// Resolve desired build arch: allow overriding via env (so cross-arch builds pick correct binary) +const resolveBuildArch = () => { + const envArch = process.env.BUILD_ARCH; + if (envArch === 'arm64' || envArch === 'x64') return envArch; + // fallback to host arch + if (process.arch === 'arm64') return 'arm64'; + return 'x64'; +}; + +const toKuboArch = (arch) => (arch === 'arm64' ? 'arm64' : 'amd64'); + +const getKuboUrl = (platform) => { + const arch = toKuboArch(resolveBuildArch()); + if (platform === 'win32') return `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_windows-${arch}.zip`; + if (platform === 'darwin') return `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_darwin-${arch}.tar.gz`; + if (platform === 'linux') return `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_linux-${arch}.tar.gz`; + // default to linux + return `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_linux-${arch}.tar.gz`; +}; const downloadWithProgress = (url) => new Promise((resolve, reject) => { @@ -79,25 +96,6 @@ const downloadWithRetry = async (url, retries = 3) => { } }; -// plebbit kubo downloads dont need to be extracted -const download = async (url, destinationPath) => { - let binName = 'ipfs'; - if (destinationPath.endsWith('win')) { - binName += '.exe'; - } - const binPath = path.join(destinationPath, binName); - // already downloaded, don't download again - if (fs.pathExistsSync(binPath)) { - return; - } - const split = url.split('/'); - const fileName = split[split.length - 1]; - const downloadPath = path.join(destinationPath, fileName); - const file = await downloadWithRetry(url); - fs.ensureDirSync(destinationPath); - await fs.writeFile(binPath, file); -}; - // official kubo downloads need to be extracted const downloadAndExtract = async (url, destinationPath) => { let binName = 'ipfs'; @@ -111,14 +109,14 @@ const downloadAndExtract = async (url, destinationPath) => { console.log(`Downloading IPFS client from ${url} to ${destinationPath}`); const split = url.split('/'); const fileName = split[split.length - 1]; - const downloadPath = path.join(destinationPath, fileName); + const archivePath = path.join(destinationPath, fileName); const file = await downloadWithRetry(url); fs.ensureDirSync(destinationPath); - await fs.writeFile(downloadPath, file); - console.log(`Downloaded archive to ${downloadPath}`); - console.log(`Extracting ${downloadPath} to ${destinationPath}`); + await fs.writeFile(archivePath, file); + console.log(`Downloaded archive to ${archivePath}`); + console.log(`Extracting ${archivePath} to ${destinationPath}`); try { - await decompress(downloadPath, destinationPath); + await decompress(archivePath, destinationPath); console.log('Decompression complete'); } catch (err) { console.error('Error during decompression:', err); @@ -130,31 +128,26 @@ const downloadAndExtract = async (url, destinationPath) => { fs.moveSync(extractedBinPath, binPath); console.log('Binary moved'); console.log('Cleaning up temporary files'); - fs.removeSync(downloadPath); + fs.removeSync(archivePath); console.log('Cleanup complete'); }; export const downloadIpfsClients = async () => { const platform = process.platform; - console.log(`Starting IPFS client download for platform: ${platform}`); - switch (platform) { - case 'win32': - await downloadAndExtract(ipfsClientWindowsUrl, ipfsClientWindowsPath); - break; - case 'darwin': - await downloadAndExtract(ipfsClientMacUrl, ipfsClientMacPath); - break; - case 'linux': - await downloadAndExtract(ipfsClientLinuxUrl, ipfsClientLinuxPath); - break; - default: - console.warn(`Unknown platform: ${platform}, downloading all IPFS clients`); - await downloadAndExtract(ipfsClientWindowsUrl, ipfsClientWindowsPath); - await downloadAndExtract(ipfsClientMacUrl, ipfsClientMacPath); - await downloadAndExtract(ipfsClientLinuxUrl, ipfsClientLinuxPath); + console.log(`Starting IPFS client download for platform: ${platform}, targetArch: ${resolveBuildArch()}`); + const url = getKuboUrl(platform); + if (platform === 'win32') { + await downloadAndExtract(url, ipfsClientWindowsPath); + } else if (platform === 'darwin') { + await downloadAndExtract(url, ipfsClientMacPath); + } else if (platform === 'linux') { + await downloadAndExtract(url, ipfsClientLinuxPath); + } else { + console.warn(`Unknown platform: ${platform}, defaulting to linux path`); + await downloadAndExtract(url, ipfsClientLinuxPath); } }; -export default async (context) => { +export default async (_context) => { await downloadIpfsClients(); }; diff --git a/electron/build-docker.sh b/electron/build-docker.sh index 63080d48..77ecf1a3 100755 --- a/electron/build-docker.sh +++ b/electron/build-docker.sh @@ -11,7 +11,7 @@ fi node electron/download-ipfs || { echo "Error: failed script 'node electron/download-ipfs'" ; exit 1; } dockerfile=' -FROM electronuserland/builder:16 +FROM node:22 # install node_modules WORKDIR /usr/src/5chan @@ -19,44 +19,41 @@ COPY ./package.json . COPY ./yarn.lock . RUN yarn -# build native dependencies like sqlite3 -RUN electron-builder install-app-deps - -# copy source files +# copy source files and configs COPY ./bin ./bin COPY ./electron ./electron COPY ./src ./src COPY ./public ./public - -# required or yarn build fails -COPY ./.eslintrc.json ./.eslintrc.json -COPY ./.prettierrc ./.prettierrc +COPY ./forge.config.js ./forge.config.js +COPY ./vite.config.js ./vite.config.js +COPY ./tsconfig.json ./tsconfig.json +COPY ./index.html ./index.html # react build RUN yarn build ' -# build electron-builder docker image +# build electron-forge docker image # temporary .dockerignore to save build time echo $'node_modules\ndist' > .dockerignore echo "$dockerfile" | sudo docker build \ . \ - --tag 5chan-electron-builder \ + --tag 5chan-electron-forge \ --file - rm .dockerignore # build linux binary sudo docker run \ - --name 5chan-electron-builder \ + --name 5chan-electron-forge \ --volume "$root_path"/dist:/usr/src/5chan/dist \ --rm \ - 5chan-electron-builder \ + 5chan-electron-forge \ yarn electron:build:linux # build windows binary sudo docker run \ - --name 5chan-electron-builder \ + --name 5chan-electron-forge \ --volume "$root_path"/dist:/usr/src/5chan/dist \ --rm \ - 5chan-electron-builder \ + 5chan-electron-forge \ yarn electron:build:windows diff --git a/electron/main.js b/electron/main.js index f12ca12d..050a362a 100644 --- a/electron/main.js +++ b/electron/main.js @@ -94,7 +94,7 @@ const createMainWindow = () => { nodeIntegration: false, contextIsolation: true, devTools: true, // TODO: change to isDev when no bugs left - preload: path.join(dirname, '../dist/electron/preload.cjs'), + preload: path.join(dirname, '../build/electron/preload.cjs'), }, }); @@ -140,7 +140,7 @@ const createMainWindow = () => { callback({ responseHeaders: details.responseHeaders }); }); - const startURL = isDev ? 'http://localhost:3000' : `file://${path.join(dirname, '../dist/index.html')}`; + const startURL = isDev ? 'http://localhost:3000' : `file://${path.join(dirname, '../build/index.html')}`; mainWindow.loadURL(startURL); @@ -244,7 +244,7 @@ const createMainWindow = () => { if (process.platform !== 'darwin') { // tray - const trayIconPath = path.join(dirname, '..', isDev ? 'public' : 'dist', 'electron-tray-icon.png'); + const trayIconPath = path.join(dirname, '..', isDev ? 'public' : 'build', 'electron-tray-icon.png'); const tray = new Tray(trayIconPath); tray.setToolTip('5chan'); const trayMenu = Menu.buildFromTemplate([ @@ -318,6 +318,15 @@ const createMainWindow = () => { }; app.whenReady().then(() => { + // Set app name and dock icon for development mode on macOS + if (process.platform === 'darwin') { + app.setName('seedit'); + if (app.dock) { + const iconPath = path.join(dirname, '..', isDev ? 'public' : 'build', 'icon.png'); + app.dock.setIcon(iconPath); + } + } + createMainWindow(); app.on('activate', () => { diff --git a/electron/start-ipfs.js b/electron/start-ipfs.js index af6d56cb..58039350 100644 --- a/electron/start-ipfs.js +++ b/electron/start-ipfs.js @@ -6,43 +6,92 @@ 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//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) => new Promise((resolve, reject) => { - const spawedProcess = spawn(...args); - spawedProcess.on('exit', (exitCode, signal) => { + const spawnedProcess = spawn(...args); + spawnedProcess.on('exit', (exitCode, signal) => { if (exitCode === 0) resolve(); - else reject(Error(`spawnAsync process '${spawedProcess.pid}' exited with code '${exitCode}' signal '${signal}'`)); + else reject(Error(`spawnAsync process '${spawnedProcess.pid}' exited with code '${exitCode}' signal '${signal}'`)); }); - spawedProcess.stderr.on('data', (data) => console.error(data.toString())); - spawedProcess.stdin.on('data', (data) => console.log(data.toString())); - spawedProcess.stdout.on('data', (data) => console.log(data.toString())); - spawedProcess.on('error', (data) => console.error(data.toString())); + // Always surface errors from short-lived commands + spawnedProcess.stderr.on('data', (data) => console.error(data.toString())); + // Short-lived command stdout can be useful in dev, but is noisy in prod + if (isDev) { + spawnedProcess.stdout.on('data', (data) => console.log(data.toString())); + } else { + // Drain to avoid backpressure without logging + spawnedProcess.stdout.on('data', () => {}); + } + spawnedProcess.on('error', (data) => console.error(data.toString?.() || String(data))); }); 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`); @@ -51,16 +100,17 @@ const startIpfs = async () => { console.log({ ipfsPath, ipfsDataPath }); fs.ensureDirSync(ipfsDataPath); - const env = { IPFS_PATH: ipfsDataPath }; + // Reduce IPFS daemon log verbosity in production to avoid UI lag from excessive logging + const env = { ...process.env, IPFS_PATH: ipfsDataPath, ...(isDev ? {} : { GOLOG_LOG_LEVEL: 'error' }) }; // init ipfs client on first launch try { await spawnAsync(ipfsPath, ['init'], { env, hideWindows: true }); - } catch (e) {} + } catch {} // make sure repo is migrated try { await spawnAsync(ipfsPath, ['repo', 'migrate'], { env, hideWindows: true }); - } catch (e) {} + } catch {} // dont use 8080 port because it's too common await spawnAsync(ipfsPath, ['config', '--json', 'Addresses.Gateway', '"/ip4/127.0.0.1/tcp/6473"'], { @@ -83,17 +133,16 @@ const startIpfs = async () => { let lastError; ipfsProcess.stderr.on('data', (data) => { lastError = data.toString(); - console.error(data.toString()); + if (isDev) console.error(lastError); }); - ipfsProcess.stdin.on('data', (data) => console.log(data.toString())); - ipfsProcess.stdout.on('data', (data) => { - data = data.toString(); - console.log(data); - if (data.includes('Daemon is ready')) { + ipfsProcess.stdout.on('data', (chunk) => { + const text = chunk.toString(); + if (isDev) console.log(text); + if (text.includes('Daemon is ready')) { resolve(); } }); - ipfsProcess.on('error', (data) => console.error(data.toString())); + ipfsProcess.on('error', (err) => console.error(err?.toString?.() || String(err))); ipfsProcess.on('exit', () => { console.error(`ipfs process with pid ${ipfsProcess.pid} exited`); reject(Error(lastError)); @@ -135,7 +184,7 @@ const startIpfsAutoRestart = async () => { try { // try to run exported onError callback, can be undefined DefaultExport.onError(e)?.catch?.(console.log); - } catch (e) {} + } catch {} } pendingStart = false; }; diff --git a/electron/vite.preload.config.js b/electron/vite.preload.config.js index cdd5e35a..fcfd9462 100644 --- a/electron/vite.preload.config.js +++ b/electron/vite.preload.config.js @@ -16,7 +16,7 @@ export default defineConfig({ fileName: () => 'preload.cjs', formats: ['cjs'], }, - outDir: resolve(__dirname, '../dist/electron'), + outDir: resolve(__dirname, '../build/electron'), emptyOutDir: true, rollupOptions: { external: ['electron'], diff --git a/forge.config.js b/forge.config.js new file mode 100644 index 00000000..1c715ad9 --- /dev/null +++ b/forge.config.js @@ -0,0 +1,103 @@ +import { downloadIpfsClients } from './electron/before-pack.js'; + +const config = { + packagerConfig: { + name: '5chan', + executableName: '5chan', + appBundleId: '5chan.desktop', + icon: './public/icon', // electron-forge adds the correct extension per platform + + // NOTE: asar is disabled because of a bug where electron-packager silently fails + // during asar creation with 5chan'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: '5chan', + icon: './public/icon.png', + format: 'UDZO', + }, + }, + { + name: '@electron-forge/maker-zip', + platforms: ['darwin'], + }, + // Windows + { + name: '@electron-forge/maker-squirrel', + platforms: ['win32'], + config: { + name: '5chan', + setupIcon: './public/favicon.ico', + }, + }, + // Linux + { + name: '@reforged/maker-appimage', + platforms: ['linux'], + config: { + options: { + name: '5chan', + icon: './public/icon.png', + categories: ['Network'], + }, + }, + }, + ], +}; + +export default config; diff --git a/index.html b/index.html index ad079d8e..22dd5ee4 100644 --- a/index.html +++ b/index.html @@ -18,7 +18,14 @@ diff --git a/package.json b/package.json index e2a82963..bd62b4d9 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "@capacitor/status-bar": "7.0.1", "@capawesome/capacitor-android-edge-to-edge-support": "7.2.2", "@floating-ui/react": "0.26.1", - "@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#60b9d18ebc647f76cdda8f36e2143192d7378658", + "@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#df3cb6901f63054870dc127cd7565c98617c2a4f", "@react-spring/web": "9.7.5", "@types/node": "20.8.2", "@types/react": "18.2.25", @@ -28,7 +28,7 @@ "i18next-browser-languagedetector": "7.1.0", "i18next-http-backend": "2.2.2", "json-stringify-pretty-compact": "4.0.0", - "lodash": "4.17.21", + "lodash": "4.17.23", "memoizee": "0.4.15", "react": "19.1.2", "react-dom": "19.1.2", @@ -43,7 +43,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": { "generate:assets": "node scripts/generate-asset-manifest.js", @@ -56,15 +57,20 @@ "test": "vitest", "preview": "vite preview", "analyze-bundle": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=true vite build && npx source-map-explorer 'dist/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": "yarn build:preload && yarn electron:before && electron .", + "electron:no-delete-data": "yarn build:preload && 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-builder build --publish never -l", - "electron:build:windows": "yarn build && yarn build:preload && electron-builder build --publish never -w", - "electron:build:mac": "yarn build && yarn build:preload && electron-builder build --publish never -m", - "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:build:linux:x64": "yarn build && yarn build:preload && electron-forge make --platform=linux --arch=x64", + "electron:build:linux:arm64": "yarn build && yarn build:preload && electron-forge make --platform=linux --arch=arm64", + "electron:build:mac:x64": "yarn build && yarn build:preload && electron-forge make --platform=darwin --arch=x64", + "electron:build:mac:arm64": "yarn build && yarn build:preload && electron-forge make --platform=darwin --arch=arm64", + "electron:before": "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'", "lint": "oxlint src/**/*.{js,ts,tsx}", @@ -93,7 +99,6 @@ "@capacitor/android": "7.4.5", "@capacitor/cli": "7.4.5", "@capacitor/core": "7.4.5", - "@electron/rebuild": "4.0.0", "@react-scan/vite-plugin-react-scan": "0.1.8", "@types/memoizee": "0.4.9", "@typescript/native-preview": "^7.0.0-dev.20251226.1", @@ -109,11 +114,17 @@ "cz-conventional-changelog": "3.3.0", "decompress": "4.2.1", "electron": "36.9.5", - "electron-builder": "24.13.2", + "@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", + "@reforged/maker-appimage": "5.1.1", "husky": "4.3.8", "isomorphic-fetch": "3.0.0", "jsdom": "27.3.0", "lint-staged": "12.3.8", + "node-fetch": "3.3.2", "oxfmt": "0.20.0", "oxlint": "1.35.0", "progress": "2.0.3", @@ -143,46 +154,12 @@ "preact": "10.27.3", "@remix-run/router": "1.23.2", "undici": "^7.18.2", - "diff": "^8.0.3" + "diff": "^8.0.3", + "lodash": "4.17.23", + "seroval": "1.4.1", + "tar": "7.5.4" }, "main": "electron/main.js", - "build": { - "appId": "5chan.desktop", - "productName": "5chan", - "beforePack": "electron/before-pack.js", - "afterAllArtifactBuild": "electron/after-all-artifact-build.cjs", - "extraResources": [ - { - "from": "bin/${os}", - "to": "bin", - "filter": [ - "**/*" - ] - } - ], - "files": [ - "dist/**/*", - "electron/**/*", - "package.json", - "node_modules/**/*" - ], - "extends": null, - "mac": { - "target": "dmg", - "category": "public.app-category.social-networking", - "type": "distribution" - }, - "win": { - "target": [ - "portable", - "nsis" - ] - }, - "linux": { - "target": "AppImage", - "category": "Network" - } - }, "lint-staged": { "src/**/*.{js,ts,tsx}": [ "oxfmt --write" diff --git a/scripts/find-forge-executable.js b/scripts/find-forge-executable.js new file mode 100644 index 00000000..5c068b03 --- /dev/null +++ b/scripts/find-forge-executable.js @@ -0,0 +1,139 @@ +#!/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 || '5chan').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 }); + let fallback = null; // Track a valid candidate that doesn't match appName + + 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)) { + const lowerAppName = entry.name.toLowerCase(); + if (lowerAppName.includes(appName)) { + return exePath; // Exact match, return immediately + } + fallback = fallback || exePath; // Store as fallback, continue searching + } + } + + const result = findExecutable(fullPath, platform); + if (result) { + const resultName = result.split('/').pop().toLowerCase(); + if (resultName.includes(appName)) { + return result; // Exact match from recursion + } + fallback = fallback || result; // Store as fallback + } + } 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; // Exact match, return immediately + } + fallback = fallback || fullPath; // Store as fallback, continue searching + } + } 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; // Exact match, return immediately + } + fallback = fallback || fullPath; // Store as fallback, continue searching + } + } + } else if (platform === 'linux') { + // Linux executables (AppImage or unpacked) + if (entry.name.endsWith('.AppImage')) { + const lowerName = entry.name.toLowerCase(); + if (lowerName.includes(appName)) { + return fullPath; // Exact match, return immediately + } + fallback = fallback || fullPath; // Store as fallback, continue searching + } + // 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; // Exact match, return immediately + } + fallback = fallback || fullPath; // Store as fallback, continue searching + } + } + } + } + } + + return fallback; // Return fallback candidate if no exact appName match found +} + +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); diff --git a/scripts/release-body.js b/scripts/release-body.js index e086ce45..50d30790 100644 --- a/scripts/release-body.js +++ b/scripts/release-body.js @@ -1,34 +1,118 @@ -import {execSync} from 'child_process' -import path from 'path' -import {fileURLToPath} from 'url' -import {readFileSync} from 'fs' +import { execSync } from 'child_process'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { readFileSync, readdirSync } from 'fs'; +import fetch from 'node-fetch'; -const dirname = path.join(path.dirname(fileURLToPath(import.meta.url))) -const conventionalChangelog = path.join(dirname, '..', 'node_modules', '.bin', 'conventional-changelog') -const packageJsonPath = path.join(dirname, '..', 'package.json') -const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) -const version = packageJson.version +const dirname = path.join(path.dirname(fileURLToPath(import.meta.url))); +const conventionalChangelog = path.join(dirname, '..', 'node_modules', '.bin', 'conventional-changelog'); +const version = JSON.parse(readFileSync(path.join(dirname, '..', 'package.json'), 'utf8')).version; -// sometimes release-count 1 is empty -let releaseChangelog = - execSync(`${conventionalChangelog} --preset angular --release-count 1`).toString() || - execSync(`${conventionalChangelog} --preset angular --release-count 2`).toString() +// changelog (use last non-empty) +let releaseChangelog = + execSync(`${conventionalChangelog} --preset angular --release-count 1`).toString() || + execSync(`${conventionalChangelog} --preset angular --release-count 2`).toString(); +releaseChangelog = releaseChangelog.trim().replace(/\n\n+/g, '\n\n'); -// format -releaseChangelog = releaseChangelog.trim().replace(/\n\n+/g, '\n\n') +// discover artifacts +const distDir = path.join(dirname, '..', 'dist'); +let files = []; +try { + files = readdirSync(distDir); +} catch {} + +// In CI finalization, dist is empty. Prefer GitHub release assets when available. +const getReleaseAssetNames = async () => { + try { + const token = process.env.GITHUB_TOKEN; + const repo = process.env.GITHUB_REPOSITORY; // owner/repo + const tag = process.env.GITHUB_REF_NAME || `v${version}`; + if (!token || !repo || !tag) return []; + const res = await fetch(`https://api.github.com/repos/${repo}/releases/tags/${tag}`, { + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': '5chan-release-notes', + 'X-GitHub-Api-Version': '2022-11-28', + Accept: 'application/vnd.github+json', + }, + }); + if (!res.ok) return []; + const data = await res.json(); + return (data.assets || []).map((a) => a.name).filter(Boolean); + } catch (e) { + return []; + } +}; + +const remoteFiles = await getReleaseAssetNames(); +if (remoteFiles.length) { + files = remoteFiles; +} + +const linkTo = (file) => `https://github.com/plebbit/5chan/releases/download/v${version}/${file}`; +const has = (s, sub) => s.toLowerCase().includes(sub); +const isArm = (s) => has(s, 'arm64') || has(s, 'aarch64'); +const isX64 = (s) => has(s, 'x64') || has(s, 'x86_64') || !isArm(s); + +// buckets +const androidApk = files.find((f) => f.endsWith('.apk')); +const htmlZip = files.find((f) => f.includes('5chan-html') && f.endsWith('.zip')); + +const linux = files.filter((f) => f.endsWith('.AppImage')); +const mac = files.filter((f) => f.endsWith('.dmg')); +const win = files.filter((f) => f.toLowerCase().endsWith('.exe')); + +const linuxArm = linux.find(isArm); +const linuxX64 = linux.find(isX64); +const macArm = mac.find(isArm); +const macX64 = mac.find(isX64); + +const winSetupX64 = win.find((f) => has(f, 'setup') && isX64(f)); +const winPortableX64 = win.find((f) => has(f, 'portable') && isX64(f)); + +// small section builder without push() +const section = (title, lines) => { + const body = lines.filter(Boolean).join('\n'); + return body ? `### ${title}\n${body}` : ''; +}; + +const macSection = section('macOS', [ + macArm && `- Apple Silicon (arm64): [Download DMG](${linkTo(macArm)})`, + macX64 && `- Intel (x64): [Download DMG](${linkTo(macX64)})`, + mac.length > 0 && + `- If macOS shows "5chan.app is damaged and can't be opened. You should move it to the Trash.", run this in Terminal to fix it: \`xattr -dr com.apple.quarantine "/Applications/5chan.app"\` then open the app again.`, +]); + +const winSection = section('Windows', [ + winSetupX64 && `- Installer (x64): [Download EXE](${linkTo(winSetupX64)})`, + winPortableX64 && `- Portable (x64): [Download EXE](${linkTo(winPortableX64)})`, + win.length > 0 && + `- If Windows shows "Windows protected your PC" (SmartScreen), click "More info" then "Run anyway". To permanently allow, right-click the .exe → Properties → check "Unblock", or run in PowerShell: \`Unblock-File -Path .\\path\\to\\file.exe\`.`, +]); + +const linuxSection = section('Linux', [ + linuxArm && `- AppImage (arm64): [Download](${linkTo(linuxArm)})`, + linuxX64 && `- AppImage (x64): [Download](${linkTo(linuxX64)})`, +]); + +const androidSection = section('Android', [androidApk && `- APK: [Download](${linkTo(androidApk)})`]); + +const htmlSection = section('Static HTML build', [htmlZip && `- 5chan-html (zip): [Download](${linkTo(htmlZip)})`]); + +const downloads = [macSection, winSection, linuxSection, androidSection, htmlSection].filter(Boolean).join('\n\n'); const releaseBody = `This version rebrands the app to 5chan, introducing a set of new features and several performance improvements. - Web app: https://5chan.app -- Decentralized web app: https://5chan.eth (only works on [Brave Browser](https://brave.com/) or via [IPFS Companion](https://docs.ipfs.tech/install/ipfs-companion/#prerequisites)) +- Decentralized web app via IPFS/IPNS gateways (works on any browser): [5chan.eth.limo](https://5chan.eth.limo), [5chan.eth.link](https://5chan.eth.link), [dweb.link/ipfs.io](https://dweb.link/ipns/5chan.eth) +- Decentralized web app via IPFS node you run: https://5chan.eth (works on [Brave Browser](https://brave.com/) or use [IPFS Companion](https://docs.ipfs.tech/install/ipfs-companion/#prerequisites)) ## Downloads -- Android app: [Download APK](https://github.com/plebbit/5chan/releases/download/v${version}/5chan-${version}.apk) -- Linux app: [Download AppImage](https://github.com/plebbit/5chan/releases/download/v${version}/5chan-${version}.AppImage) -- macOS app: [Download DMG](https://github.com/plebbit/5chan/releases/download/v${version}/5chan-${version}.dmg) -- Windows app: [Download EXE](https://github.com/plebbit/5chan/releases/download/v${version}/5chan.Setup.${version}.exe) +${downloads} -${releaseChangelog}` +## Changes -console.log(releaseBody) \ No newline at end of file +${releaseChangelog}`; + +console.log(releaseBody); diff --git a/src/app.tsx b/src/app.tsx index 81f5eba1..5a6d690a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -9,7 +9,7 @@ import useCreateBoardModalStore from './stores/use-create-board-modal-store'; import useSpecialThemeStore from './stores/use-special-theme-store'; import useIsMobile from './hooks/use-is-mobile'; import useTheme from './hooks/use-theme'; -import { useDefaultSubplebbits } from './hooks/use-default-subplebbits'; +import { useDirectories } from './hooks/use-directories'; import { getSubplebbitAddress, isPostRoute, isPendingPostRoute, isModQueueRoute } from './lib/utils/route-utils'; import styles from './app.module.css'; import FAQ from './views/faq'; @@ -44,8 +44,8 @@ const BoardLayout = () => { const isInAllView = isAllView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); const isInModView = isModView(location.pathname); - const defaultSubplebbits = useDefaultSubplebbits(); - const subplebbitAddress = boardIdentifier ? getSubplebbitAddress(boardIdentifier, defaultSubplebbits) : undefined; + const directories = useDirectories(); + const subplebbitAddress = boardIdentifier ? getSubplebbitAddress(boardIdentifier, directories) : undefined; const pendingPost = useAccountComment({ commentIndex: accountCommentIndex ? parseInt(accountCommentIndex) : undefined }); const { closeCreateBoardModal } = useCreateBoardModalStore(); diff --git a/src/components/board-buttons/board-buttons.tsx b/src/components/board-buttons/board-buttons.tsx index 381c965f..6ee39ad0 100644 --- a/src/components/board-buttons/board-buttons.tsx +++ b/src/components/board-buttons/board-buttons.tsx @@ -3,7 +3,7 @@ import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; import { useAccountComment, useSubscribe } from '@plebbit/plebbit-react-hooks'; import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages'; import { isAllView, isCatalogView, isModView, isModQueueView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; -import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits'; +import { useDirectories } from '../../hooks/use-directories'; import { getBoardPath, isDirectoryBoard } from '../../lib/utils/route-utils'; import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; @@ -35,7 +35,7 @@ interface BoardButtonsProps { const CatalogButton = ({ address, isInAllView, isInSubscriptionsView, isInModView }: BoardButtonsProps) => { const { t } = useTranslation(); const params = useParams(); - const defaultSubplebbits = useDefaultSubplebbits(); + const directories = useDirectories(); const createCatalogLink = () => { if (isInAllView) { @@ -50,9 +50,9 @@ const CatalogButton = ({ address, isInAllView, isInSubscriptionsView, isInModVie } let boardPath = ''; if (address) { - boardPath = getBoardPath(address, defaultSubplebbits); - } else if (Array.isArray(defaultSubplebbits) && defaultSubplebbits.length > 0 && defaultSubplebbits[0]?.address) { - boardPath = getBoardPath(defaultSubplebbits[0].address, defaultSubplebbits); + boardPath = getBoardPath(address, directories); + } else if (Array.isArray(directories) && directories.length > 0 && directories[0]?.address) { + boardPath = getBoardPath(directories[0].address, directories); } return `/${boardPath}/catalog`; }; @@ -78,7 +78,7 @@ const SubscribeButton = ({ address }: BoardButtonsProps) => { const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isInModView, isInModQueueView }: BoardButtonsProps) => { const { t } = useTranslation(); const params = useParams(); - const defaultSubplebbits = useDefaultSubplebbits(); + const directories = useDirectories(); const createReturnLink = () => { if (isInAllView) { @@ -99,9 +99,9 @@ const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isInModView } let boardPath = ''; if (address) { - boardPath = getBoardPath(address, defaultSubplebbits); - } else if (Array.isArray(defaultSubplebbits) && defaultSubplebbits.length > 0 && defaultSubplebbits[0]?.address) { - boardPath = getBoardPath(defaultSubplebbits[0].address, defaultSubplebbits); + boardPath = getBoardPath(address, directories); + } else if (Array.isArray(directories) && directories.length > 0 && directories[0]?.address) { + boardPath = getBoardPath(directories[0].address, directories); } return `/${boardPath}`; }; @@ -116,13 +116,13 @@ const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isInModView const VoteButton = () => { const { t } = useTranslation(); const params = useParams(); - const defaultSubplebbits = useDefaultSubplebbits(); + const directories = useDirectories(); // Get the boardIdentifier from params (try boardIdentifier first, then subplebbitAddress for backward compatibility) const boardIdentifier = params.boardIdentifier || params.subplebbitAddress; // Only render the vote button if we're on a directory board route - if (!boardIdentifier || !isDirectoryBoard(boardIdentifier, defaultSubplebbits)) { + if (!boardIdentifier || !isDirectoryBoard(boardIdentifier, directories)) { return null; } @@ -418,9 +418,9 @@ export const MobileBoardButtons = () => { const { filteredCount, searchText } = useCatalogFiltersStore(); // Check if we should show the vote button (only for directory boards) - const defaultSubplebbits = useDefaultSubplebbits(); + const directories = useDirectories(); const boardIdentifier = params.boardIdentifier || params.subplebbitAddress; - const showVoteButton = boardIdentifier && isDirectoryBoard(boardIdentifier, defaultSubplebbits); + const showVoteButton = boardIdentifier && isDirectoryBoard(boardIdentifier, directories); return (
@@ -539,9 +539,9 @@ export const DesktopBoardButtons = () => { const { filteredCount, searchText } = useCatalogFiltersStore(); // Check if we should show the vote button (only for directory boards) - const defaultSubplebbits = useDefaultSubplebbits(); + const directories = useDirectories(); const boardIdentifier = params.boardIdentifier || params.subplebbitAddress; - const showVoteButton = boardIdentifier && isDirectoryBoard(boardIdentifier, defaultSubplebbits); + const showVoteButton = boardIdentifier && isDirectoryBoard(boardIdentifier, directories); return ( <> @@ -655,9 +655,9 @@ const SearchOPsBar = () => { const isInAllView = isAllView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); const isInModView = isModView(location.pathname); - const defaultSubplebbits = useDefaultSubplebbits(); + const directories = useDirectories(); const resolvedAddress = useResolvedSubplebbitAddress(); - const boardPath = resolvedAddress ? getBoardPath(resolvedAddress, defaultSubplebbits) : params?.boardIdentifier || params?.subplebbitAddress; + const boardPath = resolvedAddress ? getBoardPath(resolvedAddress, directories) : params?.boardIdentifier || params?.subplebbitAddress; const handleSearch = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { diff --git a/src/components/board-header/board-header.tsx b/src/components/board-header/board-header.tsx index a2123a50..920acf00 100644 --- a/src/components/board-header/board-header.tsx +++ b/src/components/board-header/board-header.tsx @@ -8,7 +8,7 @@ import Plebbit from '@plebbit/plebbit-js'; import { useStableSubplebbit } from '../../hooks/use-stable-subplebbit'; import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils'; import styles from './board-header.module.css'; -import { useMultisubMetadata, useDefaultSubplebbits } from '../../hooks/use-default-subplebbits'; +import { useDirectoriesMetadata, useDirectories } from '../../hooks/use-directories'; import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; import useIsMobile from '../../hooks/use-is-mobile'; import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline'; @@ -59,11 +59,11 @@ const BoardHeader = () => { const stableSubplebbit = useStableSubplebbit(subplebbitAddress); const { address, shortAddress } = stableSubplebbit || {}; - const multisubMetadata = useMultisubMetadata(); - const defaultSubplebbits = useDefaultSubplebbits(); + const directoriesMetadata = useDirectoriesMetadata(); + const directories = useDirectories(); // Find matching subplebbit from default list to get its title - const defaultSubplebbit = subplebbitAddress ? defaultSubplebbits.find((s) => s.address === subplebbitAddress) : null; + const defaultSubplebbit = subplebbitAddress ? directories.find((s) => s.address === subplebbitAddress) : null; // Use accounts store with selector to only subscribe to subscriptions count const subscriptionsCount = useAccountsStore((state) => { @@ -74,7 +74,7 @@ const BoardHeader = () => { const subscriptionsSubtitle = t('subscriptions_subtitle', { count: subscriptionsCount }); const title = isInAllView - ? multisubMetadata?.title || '/all/ - 5chan Directories' + ? directoriesMetadata?.title || '/all/ - 5chan Directories' : isInSubscriptionsView ? '/subs/ - Subscriptions' : isInModView diff --git a/src/components/catalog-row/catalog-row.tsx b/src/components/catalog-row/catalog-row.tsx index e3cdb44c..19aca254 100644 --- a/src/components/catalog-row/catalog-row.tsx +++ b/src/components/catalog-row/catalog-row.tsx @@ -9,7 +9,7 @@ import { shouldShowSnow } from '../../lib/snow'; import { getHasThumbnail } from '../../lib/utils/media-utils'; import { getFormattedTimeAgo } from '../../lib/utils/time-utils'; import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils'; -import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits'; +import { useDirectories } from '../../hooks/use-directories'; import { getBoardPath } from '../../lib/utils/route-utils'; import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; import useCatalogStyleStore from '../../stores/use-catalog-style-store'; @@ -125,8 +125,8 @@ const CatalogPost = memo( const params = useParams(); const isInAllView = isAllView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); - const defaultSubplebbits = useDefaultSubplebbits(); - const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : ''; + const directories = useDirectories(); + const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : ''; const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]); const postLink = boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`; diff --git a/src/components/create-board-modal/create-board-modal.tsx b/src/components/create-board-modal/create-board-modal.tsx index c6d78e52..e8583bb8 100644 --- a/src/components/create-board-modal/create-board-modal.tsx +++ b/src/components/create-board-modal/create-board-modal.tsx @@ -42,8 +42,8 @@ const CreateBoardModal = () => {

Submitting to a Directory

Open a PR editing{' '} - - 5chan-multisub.json + + 5chan-directories.json {' '} with your board's title, address, and NSFW status. Devs will review and merge if approved. 99% uptime is required.

diff --git a/src/components/directory-modal/directory-modal.tsx b/src/components/directory-modal/directory-modal.tsx index 3edada33..3b9a6c7c 100644 --- a/src/components/directory-modal/directory-modal.tsx +++ b/src/components/directory-modal/directory-modal.tsx @@ -50,8 +50,8 @@ const DirectoryModal = () => {

Directory Submission

To submit your board, open a PR editing{' '} - - 5chan-multisub.json + + 5chan-directories.json {' '} with your board's title, address, and NSFW status. Requirements: 99% uptime (they're P2P nodes), active, well-moderated, and relevant to the category. Devs will review and approve/reject based on these criteria. diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index 166d60ac..9d3adf0c 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -11,7 +11,7 @@ import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-util import { isValidURL } from '../../lib/utils/url-utils'; import { isAllView, isModQueueView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; import useModQueueStore from '../../stores/use-mod-queue-store'; -import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits'; +import { useDirectories } from '../../hooks/use-directories'; import { getBoardPath } from '../../lib/utils/route-utils'; import useAvatarVisibilityStore from '../../stores/use-avatar-visibility-store'; import useAuthorAddressClick from '../../hooks/use-author-address-click'; @@ -85,8 +85,8 @@ const PostInfo = ({ const { showOmittedReplies } = useShowOmittedReplies(); const { imageUrl: avatarImageUrl } = useAuthorAvatar({ author }); const { hideAvatars } = useAvatarVisibilityStore(); - const defaultSubplebbits = useDefaultSubplebbits(); - const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : undefined; + const directories = useDirectories(); + const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : undefined; const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]); const params = useParams(); @@ -453,7 +453,7 @@ const PostMedia = ({ const { url } = commentMediaInfo || {}; let type = commentMediaInfo?.type; const gifFrameUrl = useFetchGifFirstFrame(url); - const defaultSubplebbits = useDefaultSubplebbits(); + const directories = useDirectories(); if (type === 'gif' && gifFrameUrl !== null) { type = 'animated gif'; @@ -465,7 +465,7 @@ const PostMedia = ({ const [showThumbnail, setShowThumbnail] = useState(true); const mediaDimensions = getMediaDimensions(commentMediaInfo); - const boardPath = getBoardPath(subplebbitAddress, defaultSubplebbits); + const boardPath = getBoardPath(subplebbitAddress, directories); return (

@@ -528,8 +528,8 @@ const Reply = ({ postReplyCount, reply, roles, threadNumber }: PostProps) => { } const { author, cid, deleted, link, linkHeight, linkWidth, postCid, reason, removed, spoiler, subplebbitAddress, thumbnailUrl, parentCid } = post || {}; - const defaultSubplebbits = useDefaultSubplebbits(); - const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : undefined; + const directories = useDirectories(); + const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : undefined; const location = useLocation(); const route = boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`; @@ -591,8 +591,8 @@ const PostDesktop = ({ const isInPostPageView = isPostPageView(location.pathname, params); const isInAllView = isAllView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); - const defaultSubplebbits = useDefaultSubplebbits(); - const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : undefined; + const directories = useDirectories(); + const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : undefined; const { hidden, unhide, hide } = useHide({ cid }); const isHidden = hidden && !isInPostPageView; diff --git a/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx b/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx index 5dbbfe4c..33bf8269 100644 --- a/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx +++ b/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx @@ -9,7 +9,7 @@ import { getCommentMediaInfo } from '../../../lib/utils/media-utils'; import { copyShareLinkToClipboard, isValidURL, type ShareLinkType } from '../../../lib/utils/url-utils'; import { copyToClipboard } from '../../../lib/utils/clipboard-utils'; import { getBoardPath } from '../../../lib/utils/route-utils'; -import { useDefaultSubplebbits } from '../../../hooks/use-default-subplebbits'; +import { useDirectories } from '../../../hooks/use-directories'; import { isAllView, isCatalogView, isPostPageView, isSubscriptionsView } from '../../../lib/utils/view-utils'; import useHide from '../../../hooks/use-hide'; import _ from 'lodash'; @@ -21,8 +21,8 @@ type CopyLinkButtonProps = const CopyLinkButton = ({ cid, subplebbitAddress, linkType, onClose }: CopyLinkButtonProps) => { const { t } = useTranslation(); - const defaultSubplebbits = useDefaultSubplebbits(); - const boardIdentifier = getBoardPath(subplebbitAddress, defaultSubplebbits); + const directories = useDirectories(); + const boardIdentifier = getBoardPath(subplebbitAddress, directories); return (
{ diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index d920e83b..2cea55fc 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -9,7 +9,7 @@ import { getHasThumbnail, getLinkMediaInfo } from '../../lib/utils/media-utils'; import { formatMarkdown } from '../../lib/utils/post-utils'; import { isValidURL } from '../../lib/utils/url-utils'; import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; -import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits'; +import { useDirectories } from '../../hooks/use-directories'; import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline'; @@ -71,7 +71,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const isInModView = isModView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); const subscriptions = account?.subscriptions || []; - const defaultSubplebbits = useDefaultSubplebbits(); + const directories = useDirectories(); const { accountSubplebbits } = useAccountSubplebbits(); const accountSubplebbitAddresses = Object.keys(accountSubplebbits); @@ -338,7 +338,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: - {subplebbits.map((sub) => { + {communities.map((sub) => { const address = sub.address; if (!address) return null; return ( ); })} @@ -594,7 +593,7 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp (state) => { const activeAccountId = state.activeAccountId; const activeAccount = activeAccountId ? state.accounts[activeAccountId] : undefined; - const accountSubplebbits = activeAccount?.subplebbits || {}; + const accountSubplebbits = activeAccount?.communities || {}; return Object.keys(accountSubplebbits); }, (prev, next) => { @@ -603,14 +602,14 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp }, ); - const defaultSubplebbits = useDefaultSubplebbits(); + const directories = useDirectories(); const resolvedAddress = useMemo(() => { if (boardIdentifier) { - return getSubplebbitAddress(boardIdentifier, defaultSubplebbits); + return getSubplebbitAddress(boardIdentifier, directories); } return undefined; - }, [boardIdentifier, defaultSubplebbits]); + }, [boardIdentifier, directories]); const subplebbitAddresses = useMemo(() => { if (resolvedAddress) { @@ -652,7 +651,7 @@ export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueV (state) => { const activeAccountId = state.activeAccountId; const activeAccount = activeAccountId ? state.accounts[activeAccountId] : undefined; - const accountSubplebbits = activeAccount?.subplebbits || {}; + const accountSubplebbits = activeAccount?.communities || {}; return Object.keys(accountSubplebbits); }, (prev, next) => { @@ -661,18 +660,18 @@ export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueV }, ); - const defaultSubplebbits = useDefaultSubplebbits(); + const directories = useDirectories(); const boardIdentifier = propBoardIdentifier || params.boardIdentifier; const resolvedAddress = useMemo(() => { if (boardIdentifier) { - return getSubplebbitAddress(boardIdentifier, defaultSubplebbits); + return getSubplebbitAddress(boardIdentifier, directories); } return undefined; - }, [boardIdentifier, defaultSubplebbits]); + }, [boardIdentifier, directories]); - const subplebbitsWithMetadata = useAccountSubplebbitsWithMetadata(); + const communitiesWithMetadata = useAccountSubplebbitsWithMetadata(); const subplebbitAddresses = useMemo(() => { if (resolvedAddress) { @@ -740,7 +739,7 @@ export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueV {!resolvedAddress && (
- +
)} diff --git a/src/views/not-found/not-found.tsx b/src/views/not-found/not-found.tsx index 82c98fe3..b2a471eb 100644 --- a/src/views/not-found/not-found.tsx +++ b/src/views/not-found/not-found.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { useSubplebbitField } from '../../hooks/use-stable-subplebbit'; -import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits'; +import { useDirectories } from '../../hooks/use-directories'; import { getSubplebbitAddress } from '../../lib/utils/route-utils'; import { HomeLogo } from '../home'; import styles from './not-found.module.css'; @@ -18,8 +18,8 @@ const NotFound = () => { // Extract boardIdentifier from pathname (could be directory code or address) const pathParts = location.pathname.split('/').filter(Boolean); const boardIdentifier = pathParts[0] && pathParts[0] !== 'not-found' && pathParts[0] !== 'faq' ? pathParts[0] : ''; - const defaultSubplebbits = useDefaultSubplebbits(); - const subplebbitAddress = boardIdentifier ? getSubplebbitAddress(boardIdentifier, defaultSubplebbits) : ''; + const directories = useDirectories(); + const subplebbitAddress = boardIdentifier ? getSubplebbitAddress(boardIdentifier, directories) : ''; // Only subscribe to address and shortAddress to avoid rerenders from updatingState changes const address = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.address); const shortAddress = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.shortAddress); diff --git a/src/views/pending-post/pending-post.tsx b/src/views/pending-post/pending-post.tsx index a381d1c6..dd7d74f0 100644 --- a/src/views/pending-post/pending-post.tsx +++ b/src/views/pending-post/pending-post.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useAccountComment, useAccountComments } from '@plebbit/plebbit-react-hooks'; -import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits'; +import { useDirectories } from '../../hooks/use-directories'; import { getBoardPath } from '../../lib/utils/route-utils'; import { Post } from '../post'; @@ -11,7 +11,7 @@ const PendingPost = () => { const commentIndex = accountCommentIndex ? parseInt(accountCommentIndex) : undefined; const post = useAccountComment({ commentIndex }); const navigate = useNavigate(); - const defaultSubplebbits = useDefaultSubplebbits(); + const directories = useDirectories(); useEffect(() => window.scrollTo(0, 0), []); @@ -31,10 +31,10 @@ const PendingPost = () => { useEffect(() => { if (post?.cid && post?.subplebbitAddress) { - const boardPath = getBoardPath(post.subplebbitAddress, defaultSubplebbits); + const boardPath = getBoardPath(post.subplebbitAddress, directories); navigate(`/${boardPath}/thread/${post.cid}`, { replace: true }); } - }, [post, navigate, defaultSubplebbits]); + }, [post, navigate, directories]); return ; }; diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index 8f58a58a..e1416051 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -5,7 +5,7 @@ import { useSubplebbitField } from '../../hooks/use-stable-subplebbit'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { isAllView } from '../../lib/utils/view-utils'; import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; -import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits'; +import { useDirectories } from '../../hooks/use-directories'; import { isDirectoryBoard } from '../../lib/utils/route-utils'; import useIsMobile from '../../hooks/use-is-mobile'; import ErrorDisplay from '../../components/error-display/error-display'; @@ -113,7 +113,7 @@ const PostPage = () => { const subplebbit = useSubplebbit({ subplebbitAddress }); const { error: subplebbitError, shortAddress, title } = subplebbit || {}; - const defaultSubplebbits = useDefaultSubplebbits(); + const directories = useDirectories(); // if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread const postComment = useComment({ commentCid: comment?.postCid }); @@ -133,7 +133,7 @@ const PostPage = () => { useEffect(() => { const boardIdentifier = params.boardIdentifier; - const isDirectory = boardIdentifier ? isDirectoryBoard(boardIdentifier, defaultSubplebbits) : false; + const isDirectory = boardIdentifier ? isDirectoryBoard(boardIdentifier, directories) : false; let boardTitle: string; if (isInAllView) { @@ -147,7 +147,7 @@ const PostPage = () => { const postTitle = post?.title?.slice(0, 30) || post?.content?.slice(0, 30); const postTitlePart = postTitle ? ` - ${postTitle.trim()}...` : ''; document.title = `${boardTitle}${postTitlePart} - 5chan`; - }, [title, shortAddress, subplebbitAddress, post?.title, post?.content, isInAllView, t, params.boardIdentifier, defaultSubplebbits]); + }, [title, shortAddress, subplebbitAddress, post?.title, post?.content, isInAllView, t, params.boardIdentifier, directories]); const shouldShowCommentError = comment?.error?.message && !comment?.cid; const shouldShowPostError = post?.error && post?.replyCount > 0 && post?.replies?.length === 0; diff --git a/src/views/rules/rules.tsx b/src/views/rules/rules.tsx index f6c69f5c..1736b5bf 100644 --- a/src/views/rules/rules.tsx +++ b/src/views/rules/rules.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, FormEvent } from 'react'; import { useSubplebbit } from '@plebbit/plebbit-react-hooks'; import { Footer, HomeLogo } from '../home'; -import { useDefaultSubplebbits, MultisubSubplebbit } from '../../hooks/use-default-subplebbits'; +import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories'; import styles from './rules.module.css'; const getBoardShortCode = (title?: string): string => { @@ -16,7 +16,7 @@ const getBoardName = (title?: string): string => { return match ? match[1] : title; }; -const BoardRulesDisplay = ({ subplebbitAddress, defaultSubplebbits }: { subplebbitAddress: string; defaultSubplebbits: MultisubSubplebbit[] }) => { +const BoardRulesDisplay = ({ subplebbitAddress, directories }: { subplebbitAddress: string; directories: DirectoryCommunity[] }) => { const subplebbit = useSubplebbit({ subplebbitAddress }); const { rules, state, title, shortAddress } = subplebbit || {}; @@ -42,7 +42,7 @@ const BoardRulesDisplay = ({ subplebbitAddress, defaultSubplebbits }: { subplebb const isLoaded = state === 'succeeded'; - const defaultSub = defaultSubplebbits.find((sub) => sub.address === subplebbitAddress); + const defaultSub = directories.find((sub) => sub.address === subplebbitAddress); let displayTitle: string; if (defaultSub?.title) { const shortCode = getBoardShortCode(defaultSub.title); @@ -87,11 +87,11 @@ const BoardRulesDisplay = ({ subplebbitAddress, defaultSubplebbits }: { subplebb }; const BoardSelector = ({ - defaultSubplebbits, + directories, selectedAddress, onSelect, }: { - defaultSubplebbits: MultisubSubplebbit[]; + directories: DirectoryCommunity[]; selectedAddress: string; onSelect: (address: string) => void; }) => { @@ -121,7 +121,7 @@ const BoardSelector = ({