Creating mono-repo from psf-memo-client

This commit is contained in:
Chris Troutner
2026-08-26 09:10:39 -07:00
commit c168c652de
191 changed files with 49095 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
build/
docs/
tmp/
target/
.gitsigners
+10
View File
@@ -0,0 +1,10 @@
{:paths ["test"]
:tasks
{test {:doc "Run helper tests"
:task (do
(require 'clojure.test)
(require 'swarmforge.handoff-test)
(require 'swarmforge.script-test)
(let [{:keys [fail error]} (clojure.test/run-tests 'swarmforge.handoff-test
'swarmforge.script-test)]
(System/exit (+ fail error))))}}}
Executable
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: close-swarm [project-root]" >&2
echo "Stops the SwarmForge swarm for the given project (default: current directory)." >&2
exit 1
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
fi
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${1:-.}" && pwd)"
STATE_DIR="$PROJECT_ROOT/.swarmforge"
SOCKET_FILE="$STATE_DIR/tmux-socket"
SESSIONS_FILE="$STATE_DIR/sessions.tsv"
WINDOW_IDS_FILE="$STATE_DIR/window-ids"
WINDOWS_STATE_FILE="$STATE_DIR/windows.tsv"
if [[ ! -d "$STATE_DIR" ]]; then
echo "No SwarmForge swarm found at $PROJECT_ROOT (missing .swarmforge/)." >&2
exit 1
fi
if [[ -x "$SELF_DIR/swarmforge/scripts/swarm-cleanup.sh" ]]; then
SCRIPT_DIR="$SELF_DIR/swarmforge/scripts"
elif [[ -x "$SELF_DIR/swarm-cleanup.sh" ]]; then
SCRIPT_DIR="$SELF_DIR"
elif [[ -x "$PROJECT_ROOT/swarmforge/scripts/swarm-cleanup.sh" ]]; then
SCRIPT_DIR="$PROJECT_ROOT/swarmforge/scripts"
else
echo "Could not find swarm-cleanup.sh relative to $SELF_DIR or $PROJECT_ROOT." >&2
exit 1
fi
if [[ ! -f "$SOCKET_FILE" ]]; then
echo "No SwarmForge swarm found at $PROJECT_ROOT (missing .swarmforge/tmux-socket)." >&2
exit 1
fi
TMUX_SOCKET="$(tr -d '[:space:]' < "$SOCKET_FILE")"
if [[ -z "$TMUX_SOCKET" ]]; then
echo "No SwarmForge swarm found at $PROJECT_ROOT (empty tmux socket)." >&2
exit 1
fi
sessions=()
if [[ -f "$SESSIONS_FILE" ]]; then
while IFS=$'\t' read -r _index _role session _rest; do
[[ -n "${session:-}" ]] || continue
sessions+=("$session")
done < "$SESSIONS_FILE"
fi
if (( ${#sessions[@]} == 0 )) && [[ -f "$WINDOWS_STATE_FILE" ]]; then
while IFS=$'\t' read -r _index _window_id session _title; do
[[ -n "${session:-}" ]] || continue
sessions+=("$session")
done < "$WINDOWS_STATE_FILE"
fi
if (( ${#sessions[@]} == 0 )) && [[ -S "$TMUX_SOCKET" ]]; then
while IFS= read -r session; do
[[ -n "$session" ]] || continue
sessions+=("$session")
done < <(tmux -S "$TMUX_SOCKET" list-sessions -F '#{session_name}' 2>/dev/null || true)
fi
if [[ -n "${SWARMFORGE_TERMINAL:-}" && -z "${SWARMFORGE_TERMINAL_BACKEND:-}" ]]; then
export SWARMFORGE_TERMINAL_BACKEND="$SWARMFORGE_TERMINAL"
fi
"$SCRIPT_DIR/swarm-cleanup.sh" "$TMUX_SOCKET" "$WINDOW_IDS_FILE" "${sessions[@]+${sessions[@]}}"
+97
View File
@@ -0,0 +1,97 @@
# Plan: Option B — `SWARMFORGE_DIR` (external machinery, per-project config)
> **Status**: pending implementation — working document to resume later.
> **Context**: came out of the trial run with `saas-prototype` (see the fork README).
> The goal is that projects do not carry SwarmForge code, only their configuration.
## 1. Goal
The project stops carrying SwarmForge **code** (scripts) and **shared rules**
(articles, default roles). It only keeps its **own configuration** (`swarmforge.conf` +
`project.prompt` + overrides). The fork (or any shared location via `SWARMFORGE_DIR`)
is the sole source of the machinery. With `SWARMFORGE_DIR` unset, **everything still works
as today** (full backward compatibility).
## 2. Design: where each thing lives
```
SWARMFORGE_DIR (base, e.g. ~/.local/share/swarmforge = fork clone)
├── swarm/ + swarmforge/scripts/ ← launcher, daemon, helpers, adapters
├── swarmforge/roles/*.prompt ← default roles
└── swarmforge/constitution/articles/ ← engineering, handoffs, workflow (shared)
PROJECT
└── swarmforge/ ← "thin": ONLY per-project material
├── swarmforge.conf ← project roles + models
└── constitution/articles/project.prompt (+ local-*.prompt, role overrides if any)
```
**Merge rule**: the project wins by name — if the project has
`roles/cleaner.prompt`, that takes precedence; otherwise the base one is used.
## 3. Code changes (file by file)
| File | Change | Why |
|---|---|---|
| `swarmforge.bb``context` | Add `:base-dir` = `(or (System/getenv "SWARMFORGE_DIR") (fs/path working-dir "swarmforge"))`; keep `:swarm-forge-dir` = `working-dir/swarmforge` (project config) | Shared base vs. per-project config |
| `swarmforge.bb``parse-config` | `roles-dir` = per-role lookup: `project/roles/<role>.prompt` if it exists, else `base/roles/<role>.prompt` | Role overrides |
| `swarmforge.bb` → new `sync-shared-config!` | For each worktree **and for master**: copy from `base` the shared articles and default roles into the destination `swarmforge/` **only if missing** (project ones win); scripts as today | The agent reads `swarmforge/constitution.prompt` relative to its cwd — after sync, the merged view is there |
| `swarmforge.bb``prepare-workspace!` / setup | When syncing on master (project root), add derived files (shared articles) to `.gitignore` so the project's git stays clean | Shared articles are generated at startup, not committed |
| `write-agent-instruction-file!` | **No changes** | Relative paths still work because sync merges the view into each worktree |
| `check-helper-scripts!` | **No changes** (validates `script-dir`, which points at the base) | — |
| `handoffd.bb`, helpers, adapters | **No changes** | Already resolve the project via git (`roles.tsv`) |
| `swarm` wrapper | Small install/docs tweak: when installed globally, it runs the base `swarmforge.sh`; the download block remains for first-time setup | Global install |
**Estimated total**: ~40-60 new/modified lines in `swarmforge.bb` + docs. Nothing else.
## 4. User setup (once)
```bash
# 1. Install the machinery once
git clone https://github.com/pablo-io/swarm-forge ~/.local/share/swarmforge
ln -s ~/.local/share/swarmforge/swarm ~/.local/bin/swarm
export SWARMFORGE_DIR=~/.local/share/swarmforge # (in your .bashrc)
# 2. In any project: create ONLY the config
mkdir -p swarmforge/constitution/articles
# swarmforge.conf + project.prompt (+ local-* / overrides if applicable)
# 3. Run
cd /my/project && swarm
```
## 5. Test plan
1. **`bb test`** — the existing suite (24 tests) must pass with no semantic changes.
2. **Mode B**: minimal project with only conf + `project.prompt` → launch with `SWARMFORGE_DIR`
→ verify: worktrees with the merged view (shared articles + roles + scripts),
functional master, clean startup.
3. **Handoff smoke**: a real end-to-end handoff (like the `saas-prototype` run)
under mode B.
4. **Backward compatibility**: `saas-prototype` (with full `swarmforge/`) without the env var →
must keep working the same.
5. **Sync**: change a script in the fork (e.g. a fix) → it is reflected without copying
anything into the project.
## 6. Optional migration of `saas-prototype` (after validating B)
- Thin its `swarmforge/`: delete `scripts/`, `roles/`, and shared articles; leave
`swarmforge.conf` + `project.prompt` (with the design rules).
- Re-copy updated prompts from the fork (`architect.prompt` with the written-report
rule) — now via the base, not manual copy.
## 7. Risks / open decisions
- **Shared articles synced onto master** will be gitignored (derived) — if someone wants
to version them explicitly, they can commit them (sync does not overwrite existing ones).
- **`roles.tsv` and state** stay under the project's `.swarmforge/` (gitignored) — unchanged.
- Agent instructions remain relative to the worktree — key to the design
(zero protocol changes).
## 8. Suggested implementation order
1. `context` + `parse-config` (base dir + role lookup)
2. `sync-shared-config!` + gitignore for derived files
3. `bb test`
4. Mode B trial with a minimal project
5. Doc in the fork README ("SWARMFORGE_DIR mode" section)
+154
View File
@@ -0,0 +1,154 @@
# Proposal: Configurable quality level per project
> **Status**: proposal pending implementation — working document.
> **Origin**: evaluation of the `saas-prototype` run (see the project's `REPORT.md`): fixed
> gates (CRAP≤6, mutation run, DRY, soft Gherkin) cost tokens that not every project needs.
> This proposal adds a configurable **quality axis** per project.
> **Note**: the two-pack review (section 5) narrows the proposal — the real value is depth
> within a pack, not replacing pack choice.
## 1. The 3 levels
| Level | Relative cost | Typical use |
|---|---|---|
| `minimal` | ~1x | Prototypes, spikes, throwaway code |
| `standard` | ~2x | Reasonable default for product features |
| `maximum` | ~3-4x | **Current rigor** — critical libraries, security/payments, code consumed by others |
`maximum` = what the pipeline already does today (nothing beyond that for now).
## 2. Gates by level
| Gate | minimal | standard | maximum (current) |
|---|---|---|---|
| TDD + unit tests | ✓ | ✓ | ✓ |
| Acceptance Gherkin | optional | ✓ (without full APS pipeline) | ✓ full APS pipeline |
| CRAP | — | improve what is reasonable, no hard gate | **≤6** |
| DRY | — | reduce reasonable duplication | tooling, strict |
| Mutation scan + split >100 sites | — | ✓ (count only — cheap) | ✓ |
| Full mutation run | — | — | ✓ differential, kill non-equivalents |
| Soft Gherkin mutation | — | — | ✓ |
| Property tests | — | — | support |
| Written report | — | optional | ✓ |
## 3. Role responsibility by level (four-pack)
| Role | minimal | standard | maximum |
|---|---|---|---|
| **specifier** | Scoping + human gate only (no mandatory Gherkin) | Gherkin ✓ | Gherkin + QA suite |
| **coder** | Implements with TDD — required | ✓ | ✓ |
| **refactorer** | No gates → **no real work** | Reasonable CRAP + DRY + mutation scan | full gates |
| **architect** | No gates → **no real work** | light structural review only | full gates |
**Conclusion**: level and workflow are correlated. At `minimal`, refactorer/architect have no
gates to apply — configuring 4 roles would waste tokens with no benefit.
## 4. Mechanism (prompts/articles only, zero code)
```
1. PROJECT (project.prompt): "## Quality Level → Quality level: standard"
2. SHARED (quality.prompt): the ON/OFF gate table by level
3. EACH ROLE PROMPT (one line): "Apply only the gates that are ON for
the project's level (see quality article)"
```
The agent reads the level in `project.prompt`, the table in `quality.prompt`, and its role
prompt tells it to apply only the ON gates → consistent interpretation across roles.
The `quality.prompt` article also includes the **role mapping by level**: "at minimal,
configure only specifier+coder (2 windows in `swarmforge.conf`); at standard, add
refactorer; at maximum, all 4".
**Honest nuance**: the adjustment is *prompt-soft* — agents follow the level by instruction.
Hard enforcement would need a `tools/quality-check` (validate level artifacts), an optional
later step.
## 5. Review: does two-pack already solve part of this?
**Result of reviewing two-pack's real scope (original project):**
- **coder (two-pack)**: TDD + unit tests ONLY — explicitly excludes acceptance, Gherkin, IR,
Gherkin mutation, property tests, CRAP, DRY, and language mutation.
- **cleaner (two-pack, batch)**: coverage, **CRAP≤6**, **DRY**, structure/encapsulation/dependencies
and **mutation run on uncovered behavior** + tests to kill mutants.
**two-pack quality profile**: unit tests ✓ · CRAP≤6 ✓ · DRY ✓ · mutation run ✓ ·
structure ✓ (inside cleaner) · acceptance/Gherkin ✗ · property ✗ · separate QA ✗.
### Conclusion
1. **two-pack is NOT `minimal`**: it keeps the hard hardening gates (CRAP≤6, DRY, mutation
run). It is "full hardening without specification" — not the cheap option on the depth
axis.
2. **Packs already encode a quality axis**: which gates EXIST (two-pack: no spec;
four-pack: spec + architecture; six-pack: + hardender + QA).
3. **The level axis adds what packs do NOT cover**: the DEPTH of each active gate
(CRAP≤6 vs "improve reasonably"; mutation run vs scan-only; soft Gherkin on/off).
4. **Practical implication**: for "cheap", choosing two-pack already drops the expensive
layers (spec/architecture) — the main cost lever is the pack. Level is for scaling depth
WITHIN a pack (e.g. two-pack without mutation run, four-pack without Gherkin mutation).
The run confirmed it: the main waste was four-pack for a login form, not gate depth.
**Verdict**: the level proposal remains valid but is **narrower** than it first seemed: its
real value is depth within a pack, not replacing pack choice. Possible simplification: start
with only two levels (standard = current, light = no mutation run or Gherkin mutation) and
let pack choice do the rest.
## 6. Analysis: spec vs hardening priority (the critique of two-pack)
**two-pack's logic**: TDD already specifies behavior at the unit-test level; Gherkin is a
second layer (reviewable contract + end-to-end acceptance) that is expensive (APS pipeline);
hardening gates (CRAP≤6, DRY, mutation run) are the code quality floor.
**The critique (valid)**: for a small task the priority is inverted — mutation run is
expensive and protects code that may be thrown away in a prototype; cheap spec ensures the
RIGHT thing is built. A well-hardened but wrong feature is still wrong. Logical order:
first WHAT (spec), then HOW (gates).
| | two-pack | spec-first variant (proposal) |
|---|---|---|
| Base spec | unit tests (TDD) | Light Gherkin (reviewable contract + human approval) + TDD |
| Code protection | CRAP≤6 + DRY + **mutation run** | Reasonable CRAP/DRY, **no mutation run** |
| Cost | ~2-3x | ~1.5-2x |
| Risk covered | dirty/unchangeable code | **building the wrong thing** |
**The gap it reveals**: two-pack assumes Gherkin comes with the full APS pipeline cost
(parser + entrypoint generator + runtime + step handlers). It offers no "spec-lite" variant:
write Gherkin as a reviewable contract without building the pipeline or running mutation.
**Refinement of the `light` level**:
> `light` = Gherkin written as contract + human approval + TDD + reasonable CRAP/DRY —
> **no APS pipeline, no mutation run, no Gherkin mutation, no property tests**.
This variant covers the most important risk (is it the right thing? does a human approve?)
at lower cost than "mutation run without spec".
## 7. Spec-light: Gherkin or other alternatives?
**Comparison of options for a cheap, reviewable contract:**
| Option | Cost | Human pre-code contract | Real enforcement | Risk | Upgrade to spec-full |
|---|---|---|---|---|---|
| TDD tests as spec (two-pack) | ~1x | ❌ | ✅ | user sees behavior at the end | — |
| Prose criteria (markdown) | ~1x | ✅ imprecise | ❌ | ambiguity | rewrite |
| **Gherkin written-only (light)** | ~1.5x | ✅ precise | ❌ | **spec drift** | ✅ zero rewrite |
| Given/When/Then scenarios in markdown | ~1x | ✅ | ❌ | no standard format | medium rewrite |
**Key point**: in light, real enforcement comes from the coder's TDD — it turns each approved
scenario into unit tests. Gherkin remains a human contract + guide, not verification.
**Spec drift** risk is mitigated by a light workflow rule: *"the coder maps each approved
scenario to unit tests; the handoff/report confirms the scenario→tests mapping"*.
**Recommendation**: in four-pack, light = natural degradation — the specifier already writes
Gherkin and asks for approval; the back half of the pipeline is cut (the coder does not build
entrypoint generator/runtime/step handlers, implements with TDD mapping scenarios). If you
later scale to spec-full, the `.feature` files are already there — you only build the pipeline
around them.
**Recommended cheap add-on**: in light, the coder runs `gherkin-parser` ONLY to validate that
the spec parses (seconds, no pipeline build) — prevents broken Gherkin syntax from passing
as a contract.
**When to choose each**: never going to scale → prose or TDD-only; may scale → Gherkin
written-only (the format IS the upgrade path); human must approve before coding → Gherkin.
+271
View File
@@ -0,0 +1,271 @@
# SwarmForge — Handoff protocol and deterministic pipeline
> Working document complementary to `swarmforge.md`.
> Example based on the full `six-pack` workflow (specifier → coder → cleaner → architect → hardender → QA).
---
## 1. Handoff semantics (full example)
**Task**: *"Implement a shopping cart with tax calculation"* → stable task name: **`cart-tax`**. That name travels the entire chain unchanged.
### 1.1 The message contract
Only **two message types** exist, and only the headers the agent may write:
```text
type: git_handoff → "I committed work; merge and process it"
to: coder
priority: 50 → 00 = urgent · 50 = normal · 99 = low
task: cart-tax → stable name that travels the chain
commit: 3f9a2c1d7e → canonical 10-hex hash (the gate validates and canonicalizes it)
```
```text
type: note → short message (only if the constitution/role authorizes it)
to: architect
priority: 70
message: <1 line, max 80 chars>
```
Agents **never write the payload or reserved headers** (`id`, `from`, `role`, `recipient`, `created_at`, `enqueued_at`…): the tool generates all of that.
### 1.2 The specifier opens the chain
The specifier talks with you, writes `features/cart.feature` (Gherkin) + the end-to-end QA suite, and **asks for your explicit approval**. Only after your OK does it commit and write its draft:
```text
type: git_handoff
to: coder
priority: 50
task: cart-tax
commit: 3f9a2c1d7e
```
It runs `swarm_handoff.sh draft` → the **validation gate** does 4 checks: `coder` is a known role, `50` is a valid priority, the commit **resolves to exactly one object and is a commit** (via `git rev-parse --disambiguate`), and there are no reserved fields or agent-written body. It generates the payload and installs it atomically in the outbox:
```text
50_20260710T120000Z_000042_from_specifier_to_coder.handoff
```
The **daemon** (1 s polling) copies the file to the coder's `inbox/new/` **adding delivery headers**, and wakes the coder by typing into its tmux pane: *"You have new handoff mail. If idle, run ready_for_next.sh."* + Enter.
The delivered file (this is what the coder sees):
```text
id: 20260710T120000Z_000042_from_specifier
from: specifier
to: coder
recipient: coder ← added by the daemon (per-recipient copy)
priority: 50
type: git_handoff
role: specifier
task: cart-tax
commit: 3f9a2c1d7e
created_at: 2026-07-10T12:00:00Z
enqueued_at: 2026-07-10T12:00:01Z ← added by the daemon
Re-read your role and constitution.
merge_and_process specifier 3f9a2c1d7e
```
### 1.3 The coder consumes the task
The coder runs `ready_for_next.sh` → the helper moves the file from `inbox/new/` to `inbox/in_process/`, **adds `dequeued_at`**, and prints:
```text
TASK: .swarmforge/handoffs/inbox/in_process/50_..._from_specifier_to_coder.handoff
FROM: specifier
TYPE: git_handoff
PRIORITY: 50
TASK_NAME: cart-tax
PAYLOAD:
Re-read your role and constitution.
merge_and_process specifier 3f9a2c1d7e
```
The coder does `merge_and_process specifier 3f9a2c1d7e` (merge of the specification commit), applies **TDD** (unit tests first, then implementation), runs the acceptance tests generated from the Gherkin, commits with byline (*"Implement cart tax" — `By coder.`*) and **forwards along the chain** with the same `task: cart-tax` and its new commit. Key rule: **an intermediate role ALWAYS forwards**, no matter what (even if the change is format-only).
### 1.4 The cleaner in batch mode
The cleaner is configured in `swarmforge.conf` with `batch`. If 3 handoffs arrive from the coder at the same priority, `ready_for_next_batch.sh` groups them:
```text
BATCH: .swarmforge/handoffs/inbox/in_process/batch_20260710T130000Z_000051
COUNT: 3
PRIORITY: 50
BATCH_ITEM: 1 → TASK_NAME: cart-tax ...
BATCH_ITEM: 2 → TASK_NAME: user-auth ...
BATCH_ITEM: 3 → TASK_NAME: cart-coupon ...
```
It processes all 3 as **one cleanup pass**: coverage, CRAP ≤ 6, DRY, mutation site scan (split files with >100 sites), acceptance + unit tests, commits, and forwards **once** to the architect.
### 1.5 The chain continues (architect → hardender → QA)
Each with the same mechanics: `ready_for_next.sh` (task or batch) → process its gate → verify → commit with byline → forward along the chain with `task: cart-tax` preserved.
### 1.6 QA closes: the "terminal broadcast"
When QA verifies everything (e2e UI suite, commit/manifest consistency, final CRAP/DRY), it commits and sends **a single handoff to multiple recipients** with `priority: 00`:
```text
type: git_handoff
to: specifier,coder,cleaner,architect,hardender
priority: 00
task: cart-tax
commit: b4d8e2f1a0
```
This is the **exception to the forwarding rule**: each recipient does `merge_and_process QA b4d8e2f1a0`, runs its tests, and **does NOT forward**. The specifier, on receiving the broadcast, merges and asks you for the next feature. Chain closed.
### 1.7 Each task's state machine
```text
inbox/new/ ──ready_for_next──► inbox/in_process/ ──done_with_current──► inbox/completed/
(daemon delivery) (+dequeued_at) (+completed_at)
```
- `done_with_current.sh` **picks up the next task or batch automatically** if there is a queue → agents do not sit idle.
- If a wake-up arrives while the agent is working → **it is ignored**; the queue is not lost because state lives in files.
- Swarm restart → agents re-run `ready_for_next.sh` and resume from `in_process`.
---
## 2. Rules for a deterministic pipeline
Determinism does not come from one place: it comes from **four layers of rules** that reinforce each other.
### 2.1 Layer 1 — Shared rules (constitution)
**`workflow.prompt`** (work discipline):
- Each role works **only in its assigned worktree/branch**; forbidden to diff/merge foreign branches except via explicit handoff.
- Every commit carries a byline: `By <role>.`
- Temporary files under `./tmp/` of the worktree, not `/tmp`.
- If the expected git layout does not exist → **stop and report**, do not improvise.
**`handoffs.prompt`** (protocol):
- Only `git_handoff` and `note`; notes require explicit authorization.
- On ambiguity/contradiction → **stop and ask**, do not send notes.
- **Mandatory chain forwarding**: each intermediate role forwards to the next stage after completing, even if the change is non-functional (format, manifests, metadata).
- **Terminal broadcast = merge-only**: recipients of the final handoff do not forward.
- `task:` is preserved when forwarding; invent a stable name only for new work.
- Forbidden to edit/add/commit handoff runtime state.
**`engineering.prompt`** (technical rules):
- TDD: unit tests first, then minimal production to pass.
- Quality tools (mutation/CRAP/DRY/coverage) run only on **testable modules**; "environmentally unsuitable" modules remain as excluded adapters.
- Acceptance via `gherkin-parser` (APS) — forbidden to reimplement the parser.
- Local verification before each handoff; verification commands never concurrent with each other.
- Guardrails: do not edit mutation manifests by hand; do not commit unrelated artifacts.
### 2.2 Layer 2 — Per-role rules (six-pack)
| Role | Owns | **Does Not Own** (boundary) | Verification before handoff | Handoff obligation |
|---|---|---|---|---|
| **specifier** | Gherkin + acceptance criteria + e2e QA suite | Does not run mutation or quality tools | Tests if needed; **nothing more** | **Does not commit or forward without your approval**. After your OK: commit + handoff to coder with invented `task:` |
| **coder** | Implementation of approved slices with TDD | QA suite, mutation, CRAP/DRY, Gherkin mutation | Unit tests + acceptance tests | Commit + handoff to cleaner |
| **cleaner** (batch) | Cleanup preserving behavior: names, duplication, boundaries, coverage | Mutation tests, Gherkin mutation, **new behavior** | CRAP ≤ 6, DRY, mutation site scan, acceptance + unit | Commit + handoff to architect **before taking another task/batch** |
| **architect** (batch) | Structure, boundaries, dependency direction, mutation hardening, DRY, property tests | — (inherits the chain) | Per-file mutation (differential), DRY, property tests, Gherkin soft | Commit + handoff to hardender |
| **hardender** (batch) | Mutation hardening (kill survivors), Gherkin mutation, final CRAP/DRY | Specifier's e2e QA suite | Mutation → Gherkin soft → CRAP → DRY | Commit + handoff to QA |
| **QA** (batch) | Independent final verification, turn QA suite into executable scripts, e2e via UI | Mutation and Gherkin mutation | e2e UI suite, handoff/manifest consistency, CRAP/DRY | Commit + **broadcast priority 00 to all** (merge-only) |
### 2.3 Layer 3 — Transport rules (the gate)
- `swarm_handoff.sh` **rejects** drafts with: reserved fields, unknown roles, non-numeric priority (0099), ambiguous or non-commit commits, `task` > 80 chars, agent-written bodies. The agent repairs and retries; nothing malformed enters the queue.
- Priorities: **50** = normal chain progress, **00** = terminal broadcast / urgent follow-up work. The queue orders by `priority_timestamp_sequence`, so order is **deterministic even if they arrive in the same second**.
- `batch` roles consume **all equal-priority handoffs as one unit** → cleaner/reviewer does not interrupt its pass for each delivery.
- Agents **do not talk to tmux**: the daemon is the only one with socket access; agents only write files to their outbox. Control channel and state channel are separated.
### 2.4 Layer 4 — State rules (the queue as a state machine)
- `new → in_process → completed` with audit timestamps (`enqueued_at`, `dequeued_at`, `completed_at`).
- **Resumption**: state lives in files, not memory — you restart the swarm and `ready_for_next.sh` resumes from `in_process`.
- `done_with_current.sh` **chains the next task** automatically → the pipeline advances without human intervention between gates.
### 2.5 Where determinism comes from (summary)
1. **Closed message types** (2) and **strict validation gate** → nothing ambiguous enters the system.
2. **Mandatory chain forwarding** + **merge-only broadcast** → processing order is always the same, with no skips or loops.
3. **Ownership boundaries** ("Does Not Own") → each agent only touches its own work; nobody steps on another's (coder does not do mutation; cleaner does not introduce behavior).
4. **Mandatory verification before each handoff** → a handoff only exists if its gate passed.
5. **Worktree isolation** → each role sees only its branch; merge happens explicitly via `merge_and_process` at handoff time.
6. **Stable task name + priority + sequence** → full traceability: you can follow `cart-tax` commit by commit through the whole chain.
---
## 3. Diagrams
### 3.1 Full pipeline (six roles, `six-pack`)
```mermaid
sequenceDiagram
autonumber
participant U as User
participant S as Specifier
participant C as Coder
participant CL as Cleaner (batch)
participant A as Architect (batch)
participant H as Hardender (batch)
participant Q as QA (batch)
U->>S: Implement cart with tax
S->>U: Gherkin + e2e QA suite (asks approval)
U-->>S: Approved
S->>S: commit spec + draft (type/to/priority/task/commit)
S->>S: swarm_handoff.sh → outbox (gate: canonical commit)
Note over S,C: daemon delivers to coder inbox/new + tmux wake-up
C->>C: ready_for_next.sh → in_process + TASK cart-tax
C->>C: merge_and_process specifier `<commit>` + TDD + acceptance
C->>C: commit + byline + forward (same task)
Note over C,CL: daemon delivers (several equal-priority handoffs)
CL->>CL: ready_for_next.sh → BATCH (N items)
CL->>CL: CRAP ≤ 6 + DRY + mutation scan + tests
CL->>CL: commit + forward to architect
A->>A: structure + dependencies + differential mutation + DRY
A->>A: commit + forward to hardender
H->>H: mutation hardening + Gherkin soft + CRAP/DRY
H->>H: commit + forward to QA
Q->>Q: e2e UI suite + handoff consistency
Q->>Q: commit + broadcast priority 00 (merge-only)
Q-->>S: merge_and_process QA `<commit>` — no forward
S->>U: Next feature?
```
### 3.2 Handoff chain and priorities
```mermaid
flowchart LR
U[User] -->|"intent"| S[Specifier]
S -->|"git_handoff p50 · stable task"| C[Coder]
C -->|"git_handoff p50"| CL[Cleaner · batch]
CL -->|"git_handoff p50"| A[Architect · batch]
A -->|"git_handoff p50"| H[Hardender · batch]
H -->|"git_handoff p50"| Q[QA · batch]
Q -->|"git_handoff p00 · broadcast merge-only"| S
S -.->|"human approval"| U
```
### 3.3 Task lifecycle
```mermaid
stateDiagram-v2
[*] --> new: daemon delivers .handoff
new --> in_process: ready_for_next.sh (dequeued_at)
in_process --> completed: done_with_current.sh (completed_at)
in_process --> in_process: next queued task or batch
new --> [*]: NO_TASK (empty queue)
```
---
## 4. Mermaid syntax notes (validated with v11.13.0)
- In `sequenceDiagram` messages do not use `&lt;`/`&gt;` entities — use backticks: `` `<commit>` ``.
- In `flowchart` labels do not use escaped double quotes (`\"`) — use inner single quotes or plain text.
- `<br/>` does work inside sequence messages and labels.
+308
View File
@@ -0,0 +1,308 @@
# SwarmForge — Description, protocol, and migration requirements
> Working document. Project source: https://github.com/unclebob/swarm-forge
> Goal: understand SwarmForge's architecture and evaluate adapting it to **pi** as the agent, on **Linux**, with **DeepSeek / GLM / Qwen** models.
---
## 1. Project description
**SwarmForge** is a **tmux**-based agent orchestration platform that turns a swarm of AI agents into a coordinated software engineering team. It was created by Robert C. Martin and applies his own engineering discipline (TDD, Gherkin/acceptance testing, mutation testing, CRAP/DRY analysis) to the problem of coordinating agents.
Core idea: **each agent lives in its own git worktree and its own tmux session**, and agents communicate via a **file-based handoff protocol** delivered by a daemon. There are no direct messages between agents and no direct access to the tmux socket by them.
### Branch structure
| Branch | Description | Roles |
|---|---|---|
| `main` | **Documentary**: shared operational scripts + default constitution articles | — |
| `two-pack` | Fast backend workflow (TDD + hardening, no Gherkin) | `coder``cleaner``coder` |
| `four-pack` | Compact workflow with Gherkin specification | `specifier``coder``refactorer``architect``specifier` |
| `six-pack` | Full workflow with all quality gates separated | `specifier``coder``cleaner``architect``hardender``QA` → end |
Each executable branch contains the project config: `swarmforge.conf` (topology), `roles/<role>.prompt` (per-role prompts) and `constitution.prompt` + articles (shared rules). On startup, the `./swarm` wrapper downloads the shared operational scripts from `main` (first time only) and launches the orchestrator.
### How it works at a high level
1. **Declarative configuration**: `swarmforge.conf` defines the swarm window by window:
```
window <role> <agent> <worktree> [task|batch] [extra-args...]
```
2. **Launcher** (`swarmforge.bb`, Babashka): validates the config, initializes the git repo if needed, creates a **worktree per role** under `.worktrees/`, creates a **tmux session per role** on a project-owned socket and launches each agent with its initial prompt.
3. **Agents**: each runs as an interactive TUI in its tmux pane, inside its worktree, with the handoff scripts on its `PATH`.
4. **Daemon** (`handoffd.bb`): owner of the tmux socket. Watches agent outboxes, delivers handoffs to recipient inboxes and wakes agents with a message typed into their pane.
5. **Handoff protocol**: agents create validated drafts, receive them as tasks or batches (`task`/`batch`), and report completion with `done_with_current.sh`.
6. **Optional viewer**: terminal adapters (`terminal-adapters/*.sh`) open one window per role for real-time observation, with a watchdog that reopens closed windows without losing agent state.
### Key features
- **Config-driven topology**: swarm shape comes from `swarmforge.conf`, not from code.
- **Per-project roles**: `swarmforge/roles/<role>.prompt` per branch/backlog.
- **Layered constitution**: `constitution.prompt` directs agents to read articles under `swarmforge/constitution/articles/` (shared engineering, handoff and workflow rules + local per-branch rules).
- **Per-role backends**: each role can use a different agent CLI (`claude`, `codex`, `copilot`, `grok`).
- **Observable**: one terminal window per role, or headless in tmux.
- **Self-hosted and light**: only needs tmux, git, zsh and Babashka; all state lives in `.swarmforge/` inside the project.
- **Operational robustness**: host sleep prevention (`caffeinate`/`systemd-inhibit`), task resumption after restart, file-based audit (`new` → `in_process` → `completed`).
---
## 2. Handoff protocol (summary)
The protocol separates **state** (files on the filesystem, durable and auditable) from **control** (tmux, only for notification and liveness).
### Messages
Only two types, both strictly validated:
```
type: git_handoff type: note
to: <role>[,<role>...] to: <role>[,<role>...]
priority: NN (00-99) priority: NN (00-99)
task: <stable-name> message: <1 line, max 80 chars>
commit: <10 hex>
```
- `git_handoff`: the sender has committed work; the receiver does `merge_and_process <role> <commit>`.
- `note`: short message; only when the constitution or role explicitly authorizes it.
### Flow
1. The agent commits and writes a **draft** with headers only.
2. `swarm_handoff.sh` is the **validation gate**: rejects reserved fields, unknown roles, invalid priorities, ambiguous commits (canonicalizes the hash with `git rev-parse --disambiguate`) and bodies that are not generated.
3. The helper generates the payload (`id`, `from`, `role`, `task`, `created_at`, body) and installs it atomically in `outbox/`.
4. The **daemon** polls (1s), copies the handoff to each recipient's `inbox/new/` (adding `recipient` and `enqueued_at`) and wakes the receiver.
5. The receiver runs `ready_for_next.sh` → moves to `inbox/in_process/` (adds `dequeued_at`) and prints `TASK:`/`BATCH:` with the payload.
6. On completion, `done_with_current.sh` moves to `inbox/completed/` (adds `completed_at`) and picks up the next task if one exists.
7. The daemon moves the sender's original to `sent/` or `failed/`.
### Wake-up (control plane)
The daemon "wakes" an agent by typing into its tmux pane:
```
tmux send-keys -t <session> -l "You have new handoff mail. If idle, run ready_for_next.sh."
tmux send-keys -t <session> C-m # Enter
tmux send-keys -t <session> C-j # robustness LF
```
The agent receives it as a user message. Protocol rules: if it is already working, **ignore the wake-up**; `done_with_current.sh` picks up the next task when finished. In practice, an agent with a message queue (like pi) enqueues the wake-up and delivers it when the turn ends.
### Chain rules
- Intermediate roles **always forward** a `git_handoff` to the next role in the chain, no matter what (even if the change is non-functional).
- The final handoff of the chain (broadcast) is **merge-only**: recipients merge and do not forward.
- Task names (`task:`) are preserved along the chain.
---
## 3. Migration requirements
### 3.1 Agent contract (necessary condition)
SwarmForge requires the agent to be a **long-lived interactive process in a tmux pane** that satisfies:
1. **Interactive CLI (TUI/REPL)** that keeps running — wake-ups arrive as typed text + Enter; a one-shot CLI cannot receive work.
2. **Initial prompt via command line** (or injectable via `tmux send-keys` after startup).
3. **Work in the worktree directory** (`cd <worktree> && <agent> ...`).
4. **Ability to run commands** (the helpers `swarm_handoff.sh`, `ready_for_next.sh`, `done_with_current.sh` are shell/bb on `PATH` — they are model-agnostic).
Everything else in the protocol (handoffs, worktrees, daemon, wake-ups, watchdog) **does not know about the model**: the only integration point is the launch arm in `swarmforge.bb` and the validated backend list in `parse-config`.
### 3.2 Validation: pi as agent
**Fits out of the box.** Verified in docs and installed binary:
- `pi "<prompt>"` starts the TUI, **sends the initial message and stays interactive** (confirmed in `dist/modes/interactive/interactive-mode.js`).
- **tmux officially supported** (`docs/tmux.md`). Recommendation: tmux ≥ 3.5 with `extended-keys-format csi-u` for modified keys; the basic protocol (Enter) works with any version.
- **Compatible wake-up**: in pi `Enter` = send, `Ctrl+J` = new line (the daemon's `C-j` is harmless).
- **Message queue**: a message typed while pi is working is **enqueued and delivered when the turn ends** — ideal for the protocol's wake-up semantics.
- **Sessions**: `pi -c` (continue), `--session`, `--name "SwarmForge <Role>"` (session name).
Operational requirements with pi:
| Requirement | Detail |
|---|---|
| Trust prompt | pi asks on startup in a new project and **would block the agent**. Use `-a/--approve` in the launch arm or pre-seed `~/.pi/agent/trust.json`. |
| Fixed model | Use `--model <provider>/<model>` so each role does not start at the login selector. |
| Runtime | Node.js ≥ 22 (npm install) or standalone script; Linux supported natively. |
Proposed launch arm in `swarmforge.bb`:
```clojure
"pi" (str "pi -a --name " (sq (str "SwarmForge " display))
" --model " (sq model) " "
(extra-args-prefix row)
"\"$(cat " (sq (str prompt-file)) ")\"")
```
### 3.3 Validation: opencode as agent
**Fits with a mandatory adaptation.** Verified on the real v1.18.15 binary and in source:
- `opencode` (no args) starts the persistent **interactive TUI**.
- ⚠️ The TUI **does not accept an initial message via CLI**: `--prompt` in TUI mode calls a Node `rl.question` (waits for stdin input); only `--mini --prompt` sends it as a message, but with `interactive: false` (runs and exits). `opencode run "<msg>"` is **headless one-shot** — not usable as a swarm agent.
- **Solution**: launch the TUI (`opencode --auto`) and **inject the prompt with `tmux send-keys`** after startup — the same mechanism the daemon already uses to wake. About ~10 lines in `launch-role!` (launch → sleep → `send-keys -l "$(cat prompt)"` + Enter).
```
opencode --auto -m <provider>/<model> # in the role's tmux session
# after ~2s:
tmux send-keys -t <target> -l "<initial prompt>" ; tmux send-keys -t <target> C-m
```
Operational requirements with opencode:
| Requirement | Detail |
|---|---|
| Permissions | `--auto` (auto-approve; also the hidden aliases `--yolo` / `--dangerously-skip-permissions`) — equivalent to the swarm's autonomous mode. |
| Sessions | `-c/--continue`, `-s/--session` for the restart flow ("on restart, run ready_for_next.sh"). |
| Runtime | Static binary (npm `opencode-ai` or GitHub release); Linux supported. |
| Future | `opencode serve` + `attach`/SDK/ACP would allow a native message queue without keyboard wake-ups (would require changing the architecture, not adapting it). |
### 3.4 Models: DeepSeek / GLM / Qwen
| Model | pi | opencode |
|---|---|---|
| **DeepSeek** | **Native**: `DEEPSEEK_API_KEY`, provider `deepseek`, `--model deepseek/...` | **Native** in catalog (`models.dev`): `deepseek-*` |
| **Qwen** | **Native**: `QWEN_TOKEN_PLAN_API_KEY`, providers `qwen-token-plan` / `-individual` / `-cn` (China) | **Native**: `qwen3.x-*`, `alibaba-*/qwen*` |
| **GLM (Zhipu)** | **Not native**: needs a custom provider extension (OpenAI-compatible, `api: "openai-completions"`, `thinkingFormat: "zai"`) or OpenAI-compatible proxy | **Native**: `glm-4.x`/`glm-5.x` (`opencode-go/glm-*`, `alibaba-*/glm-*`) |
Note: pi already implements the *thinking* formats of all three families (`thinkingFormat: "deepseek" | "zai" | "qwen"` in `docs/custom-provider.md`), which simplifies GLM integration: you only need to register the endpoint and models with that extension.
### 3.5 Linux (runtime)
| Requirement | Status | Detail |
|---|---|---|
| `zsh` | **Hard requirement** | Scripts use `#!/usr/bin/env zsh`. Arch: `pacman -S zsh`. |
| `tmux` | Required | Recommended ≥ 3.5 (pi with extended keys). |
| `git` | Required | Worktrees and commit protocol. |
| Babashka (`bb`) | Required | Launcher and all helpers are Babashka (cross-platform). |
| Node.js ≥ 22 | pi only | npm install of pi (or standalone script). |
| Terminal | **Headless works** | By default on Linux (no `osascript`/`wt.exe`) the launcher falls back to `none`: attaches the current shell to the first role's session and the rest stay detached (`tmux -S <socket> attach -t swarmforge-<role>`). The swarm runs fully without windows. |
| Automatic windows (optional) | To build | Write a `terminal-adapters/wezterm.sh` (or kitty) for Linux: 5-function contract (~40 lines). WezTerm is the most scriptable (`wezterm cli`); Ghostty on Linux has no remote control. |
| Shutdown | Plan for | The `close-swarm` script lives on the `main` branch; executable branches do not carry it — copy it into the project or use shutdown by "closing the first window". |
| Sleep prevention | Works | `systemd-inhibit` on Linux (systemd running). Disable with `SWARMFORGE_PREVENT_SLEEP=0`. |
### 3.6 Necessary code changes (minimal)
In `swarmforge/scripts/swarmforge.bb` (the working branch, e.g. `four-pack`):
1. **`parse-config`**: add the backend to the validated list, e.g. `#{"claude" "codex" "copilot" "grok" "pi"}`.
2. **`launch-command`**: add the new backend's arm (pi: section 3.2; opencode: section 3.3).
3. **`check-backend-dependencies!`**: no changes — already checks that the binary exists on `PATH`.
In the project config:
- `swarmforge.conf`: `window coder pi master` (or `opencode`), with `[task|batch]` and extra args per role.
Optional depending on goal:
- Shared constitution articles in `swarmforge/constitution/articles/` of the branch (the wrapper only *stages* them in `scripts/shared-articles/`; confirm agents read what the branch needs).
- Linux terminal adapter (section 3.5).
- `close-swarm` in the project.
---
## 4. Architecture diagram
```mermaid
flowchart TB
subgraph Config["Configuration (per project/branch)"]
CONF["swarmforge.conf<br/>window role agent worktree [task|batch] [args]"]
ROLES["swarmforge/roles/&lt;role&gt;.prompt"]
CONST["swarmforge/constitution.prompt<br/>+ constitution/articles/"]
end
subgraph Launcher["Launcher — swarmforge.bb (Babashka)"]
PARSE["Validate config and prompts"]
WT["Git worktrees<br/>.worktrees/&lt;role&gt; (branch per role)"]
TMUX["tmux sessions<br/>swarmforge-&lt;role&gt; · project-owned socket"]
LAUNCH["send-keys: export SWARMFORGE_ROLE<br/>+ PATH helpers + cd worktree<br/>+ &lt;agent&gt; '$(cat prompt)'"]
end
subgraph Swarm["Swarm (1 agent per role)"]
A1["Agent TUI<br/>(tmux pane)"]
A2["Agent TUI<br/>(tmux pane)"]
A3["Agent TUI<br/>(tmux pane)"]
end
subgraph State["Durable state — filesystem (.swarmforge/handoffs)"]
OUT["outbox/ · sent/ · failed/"]
IN["inbox/ new · in_process · completed"]
end
subgraph Control["Control — daemon handoffd.bb"]
DAEMON["Poll outbox → deliver to inbox<br/>→ wake-up via tmux send-keys"]
end
subgraph Viewer["Viewer (optional)"]
ADAPT["terminal-adapters/*.sh"]
WATCH["swarm-window-watchdog"]
end
CONF --> PARSE
ROLES --> PARSE
CONST --> PARSE
PARSE --> WT --> LAUNCH
PARSE --> TMUX --> LAUNCH
LAUNCH --> A1 & A2 & A3
A1 & A2 & A3 -->|"helpers on PATH:<br/>swarm_handoff.sh"| OUT
OUT --> DAEMON
DAEMON -->|"deliver .handoff"| IN
DAEMON -->|"wake-up: text + Enter"| A1 & A2 & A3
A1 & A2 & A3 -->|"ready_for_next.sh<br/>done_with_current.sh"| IN
A1 & A2 & A3 -->|"work (git)"| WT
A1 & A2 & A3 -->|"observe: tmux attach"| TMUX
TMUX --> ADAPT --> WATCH
```
## 5. Protocol diagram (one handoff cycle)
```mermaid
sequenceDiagram
autonumber
participant S as Sender agent (e.g. coder)
participant V as swarm_handoff.sh (gate)
participant O as outbox/ (sender)
participant D as Daemon handoffd.bb
participant I as inbox/ (receiver)
participant R as Receiver agent (e.g. cleaner)
Note over S: git commit (message with role byline)
S->>S: Write draft (type/to/priority/task/commit)
S->>V: swarm_handoff.sh `<draft>`
V->>V: Validate: known roles, priority 00-99,<br/>canonical commit (10 hex, --disambiguate),<br/>reserved fields, body forbidden
V->>O: Install generated .handoff (id, from, role,<br/>task, created_at, merge_and_process payload)
V-->>S: HANDOFF QUEUED
O->>D: Poll (1 s)
D->>I: Copy to each recipient inbox/new/<br/>+ recipient, enqueued_at headers
D->>R: tmux send-keys -l 'You have new handoff mail...'<br/>+ C-m (Enter) + C-j (robustness)
Note over R: If busy → ignore (queue or next<br/>done_with_current will pick it up)
R->>I: ready_for_next.sh → move to in_process/<br/>+ dequeued_at header
I-->>R: TASK: `<path>` / BATCH: `<items>` + PAYLOAD
R->>R: merge_and_process `<sender>` `<commit>`<br/>+ process the task in its worktree
R->>I: done_with_current.sh → completed/<br/>+ completed_at header
I-->>R: Next task or NO_TASK
D->>O: Move original to sent/ (or failed/)
```
### Inbox task lifecycle
```mermaid
stateDiagram-v2
[*] --> new: daemon delivers .handoff
new --> in_process: ready_for_next.sh (dequeued_at)
in_process --> completed: done_with_current.sh (completed_at)
in_process --> in_process: next queued task
new --> [*]: NO_TASK (empty queue)
failed --> [*]: delivery error (sender outbox)
```
---
## 6. Executive summary
1. **The architecture is model-agnostic**: the only integration point for a new backend is the launch arm + the validated backend list in `swarmforge.bb`; the handoff protocol, worktrees, daemon and wake-ups do not know about the agent.
2. **pi fits directly**: interactive initial message via CLI, tmux supported, message queue aligned with wake-up semantics, native DeepSeek/Qwen and GLM with a small extension.
3. **opencode fits with an adaptation**: the TUI does not accept an initial prompt via CLI → inject via `tmux send-keys` after startup (mechanism already in the system). Native DeepSeek/GLM/Qwen.
4. **Linux is a first-class citizen by design**: the swarm lives in tmux, not in windows; headless runs fully. Automatic windows are only an optional terminal adapter.
5. **Minimum requirements**: zsh + tmux (≥3.5 recommended) + git + Babashka + (Node.js for pi) + ~15 lines of changes in `swarmforge.bb` + provider config.
+4
View File
@@ -0,0 +1,4 @@
# psf-memo-db REST API base URL (no trailing slash).
# Used at build time by Create React App (must be prefixed with REACT_APP_).
#REACT_APP_MEMO_DB_URL=http://localhost:5021
REACT_APP_MEMO_DB_URL=https://memo-api.fullstackcash.net
+3
View File
@@ -0,0 +1,3 @@
# psf-memo-db REST API base URL (no trailing slash).
# Used at build time by Create React App (must be prefixed with REACT_APP_).
REACT_APP_MEMO_DB_URL=http://localhost:5021
+7
View File
@@ -0,0 +1,7 @@
node_modules/
build/
docs/
tmp/
target/
.gitsigners
+8
View File
@@ -0,0 +1,8 @@
[
{
"srcDir": "",
"destDir": "",
"files": "**/*.js",
"command": "npm run lint"
}
]
+7
View File
@@ -0,0 +1,7 @@
Copyright 2025 Chris Troutner
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+3
View File
@@ -0,0 +1,3 @@
# Pedigree
This code repository is forked from [react-bootstrap-web3-spa](https://github.com/Permissionless-Software-Foundation/react-bootstrap-web3-spa), and any updates to that upstream repository are pulled into this repository.
+24
View File
@@ -0,0 +1,24 @@
# psf-memo-client
This is a web-based single page app (SPA) written in React. It provides non-custodial wallet features for the Bitcoin Cash blockchain, including support for SLP tokens and NFTs.
This web wallet is forked from [bch-wallet-web3-spa](https://github.com/Permissionless-Software-Foundation/bch-wallet-web3-spa). It's had additional user interfaces added to it for interacting with the REST API provided by [psf-memo-db](https://github.com/Permissionless-Software-Foundation/psf-memo-db).
## Installation
```bash
git clone https://github.com/Permissionless-Software-Foundation/psf-memo-client
cd bch-wallet-web3-spa
npm install
npm start
npm run build
```
## Support
Have questions? Need help? Join our community support
[Telegram channel](https://t.me/bch_js_toolkit)
## License
[MIT](./LICENSE.md)
.
+99
View File
@@ -0,0 +1,99 @@
/*
Normal acceptance runner for psf-memo-client.
Orchestrates the acceptance pipeline:
feature file -> bb gherkin-parser -> JSON IR -> acceptance entrypoint
generator -> generated test entry points -> node test runner
It procures the latest Babashka APS tools from the Acceptance-Pipeline-
Specification repository on first use, then parses, generates, and runs every
Gherkin feature under specs/.
Exit code 0 when all acceptance tests pass; non-zero otherwise.
*/
'use strict'
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '..')
const specsDir = path.join(root, 'specs')
const buildDir = path.join(root, 'build', 'acceptance')
const irDir = path.join(buildDir, 'ir')
const genDir = path.join(buildDir, 'generated')
const apsDir = path.join(root, 'tmp', 'aps-spec')
function sh (cmd, args, opts = {}) {
return execFileSync(cmd, args, {
stdio: ['pipe', 'pipe', 'pipe'],
...opts
}).toString()
}
// Procure the latest APS tools if not already present in the worktree.
function ensureAps () {
if (fs.existsSync(apsDir)) return
fs.mkdirSync(path.dirname(apsDir), { recursive: true })
sh('git', ['clone', '--depth', '1',
'https://github.com/unclebob/Acceptance-Pipeline-Specification.git', apsDir])
}
function main () {
ensureAps()
const features = fs
.readdirSync(specsDir)
.filter((f) => f.endsWith('.feature'))
.sort()
if (features.length === 0) {
console.log('No feature files found under specs/.')
return
}
fs.mkdirSync(irDir, { recursive: true })
fs.mkdirSync(genDir, { recursive: true })
for (const featureFile of features) {
const base = featureFile.replace(/\.feature$/i, '')
const featurePath = path.join(specsDir, featureFile)
const irPath = path.join(irDir, `${base}.json`)
// 1) Parse the feature to JSON IR using the Babashka APS gherkin-parser.
sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir })
// 2) Generate executable acceptance entry points from the IR.
sh('node', [path.join(root, 'acceptance', 'lib', 'generate.js'), irPath, genDir])
}
// 3) Run every generated acceptance test.
const tests = fs
.readdirSync(genDir)
.filter((f) => f.endsWith('.acceptance.test.js'))
.sort()
let failures = 0
for (const testFile of tests) {
try {
const out = sh('node', [path.join(genDir, testFile)])
process.stdout.write(out)
console.log(`ACCEPTANCE PASS: ${testFile}`)
} catch (err) {
failures++
process.stdout.write(err.stdout || '')
process.stderr.write(err.stderr || '')
console.error(`ACCEPTANCE FAIL: ${testFile}`)
}
}
if (failures > 0) {
console.error(`ACCEPTANCE: ${failures} failing test file(s)`)
process.exit(1)
} else {
console.log(`ACCEPTANCE: all ${tests.length} generated test file(s) passed`)
}
}
main()
+112
View File
@@ -0,0 +1,112 @@
/*
Project-specific acceptance entrypoint generator.
Reads parser JSON IR and writes executable generated test entry points plus
per-feature metadata. Generated tests delegate all step behavior to the
acceptance runtime and project step handlers.
Usage:
node acceptance/lib/generate.js <json-ir> <generated-test-output-dir>
Exit codes:
0 generation succeeded
1 generation error
2 wrong command usage
*/
'use strict'
const fs = require('node:fs')
const path = require('node:path')
const crypto = require('node:crypto')
// Convert a feature path to a strict lowercase-and-hyphen metadata filename.
function metadataName (featureName) {
const slug = featureName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return `${slug || 'feature'}.json`
}
// Compute a stable relative require path from a generated file's directory to
// a target module, with a './' or '../' prefix for require().
function relativeRequire (fromDir, targetFile) {
let rel = path.relative(fromDir, targetFile).replace(/\\/g, '/')
if (!rel.startsWith('.')) rel = `./${rel}`
return rel
}
function main () {
const irArg = process.argv[2]
const outArg = process.argv[3]
if (!irArg || !outArg) {
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir>')
process.exit(2)
}
let ir
try {
ir = JSON.parse(fs.readFileSync(irArg, 'utf8'))
} catch (err) {
console.error(`Failed to read JSON IR "${irArg}": ${err.message}`)
process.exit(1)
}
const genDir = outArg
fs.mkdirSync(genDir, { recursive: true })
const featureKey = path.basename(irArg).replace(/\.json$/i, '')
const testFile = path.join(genDir, `${featureKey}.acceptance.test.js`)
const relRuntime = relativeRequire(genDir, path.join(__dirname, 'runtime.js'))
const body = `'use strict'
const { runFeature } = require('${relRuntime}')
const ir = ${JSON.stringify(ir, null, 2)}
async function main () {
const report = await runFeature(ir)
for (const r of report.results) {
console.log((r.status === 'passed' ? 'PASS ' : 'FAIL ') + r.name)
if (r.detail) console.log(' ' + r.detail)
}
if (report.failures > 0) {
console.error('ACCEPTANCE FAILURES: ' + report.failures + ' of ' + report.total)
process.exitCode = 1
}
}
main().catch((err) => { console.error(err); process.exit(1) })
`
try {
fs.writeFileSync(testFile, body)
} catch (err) {
console.error(`Failed to write generated test "${testFile}": ${err.message}`)
process.exit(1)
}
// Per-feature metadata with an implementation hash over generated files only.
const metaDir = path.join(genDir, 'metadata')
fs.mkdirSync(metaDir, { recursive: true })
const hash = crypto
.createHash('sha256')
.update(fs.readFileSync(testFile))
.digest('hex')
const metadata = {
schema_version: 1,
feature_path: `${featureKey}.feature`,
ir_path: path.resolve(irArg),
implementation_hash: `sha256:${hash}`,
hash_scope: 'generated_files',
generated_files: [testFile]
}
fs.writeFileSync(
path.join(metaDir, metadataName(featureKey)),
JSON.stringify(metadata, null, 2)
)
process.exit(0)
}
main()
+814
View File
@@ -0,0 +1,814 @@
/*
Project step handlers for the psf-memo-client acceptance pipeline.
These handlers connect Gherkin step text to real project behavior
(src/services/memo-post.js, src/services/new-post.js, src/services/memo-reply.js,
src/services/reply-thread-page.js, src/services/memo-set-name.js, and
src/services/set-name-page.js), driving them through small injected adapters
(a fake wallet, a fake feed, a fake thread, and a fake navigator) so the
acceptance run is deterministic and offline.
Regex matching with placeholder-name capture is the default style: a single
handler pattern captures the placeholder name (e.g. <message>) and fetches
the example value from the scenario example store.
The handlers serve specs/post-memo.feature, specs/memo-new.feature,
specs/reply-memo.feature, and specs/set-name.feature, whose wording differs
but which share the same underlying Memo action/page-controller behavior.
*/
'use strict'
const MemoPost = require('../../src/services/memo-post')
const NewPostPage = require('../../src/services/new-post')
const MemoReply = require('../../src/services/memo-reply')
const ReplyThreadPage = require('../../src/services/reply-thread-page')
const MemoSetName = require('../../src/services/memo-set-name')
const SetNamePage = require('../../src/services/set-name-page')
const AccountPage = require('../../src/services/account-page')
const MemoLike = require('../../src/services/memo-like')
const LikeTipPage = require('../../src/services/like-tip-page')
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX
const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX
// Default author address used by Gherkin steps that refer to "the author address".
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses.
function makeWallet (address) {
const wallet = {
walletInfo: { cashAddress: address },
utxos: [],
broadcasts: [],
getUtxos: async function () {
return this.utxos
},
sendOpReturn: async function (msg, prefix, bchOutput = []) {
// Record the broadcast attempt, then fail if configured to do so.
this.broadcasts.push({ msg, prefix, bchOutput })
if (this.failWith) throw new Error(this.failWith)
return 'aa'.repeat(32)
}
}
return wallet
}
// A fake feed reflecting posts added to the recent posts feed.
function makeFeed () {
const posts = []
return {
posts,
addPost: (post) => posts.push(post)
}
}
// A fake profile store recording display names set for addresses.
function makeProfiles () {
const names = {}
return {
names,
setName: (addr, name) => { names[addr] = name },
getName: (addr) => names[addr] || null
}
}
// A fake thread store recording replies added to a post thread.
function makeThread () {
const replies = []
return {
rootTxid: null,
replies,
addReply: (r) => replies.push(r)
}
}
// Fresh world/state object for a single scenario execution.
function createWorld () {
const wallet = makeWallet('')
const feed = makeFeed()
const memoPost = new MemoPost({ wallet, feed })
const thread = makeThread()
const memoReply = new MemoReply({ wallet, thread })
const memoLike = new MemoLike({ wallet, feed })
const world = {
wallet,
feed,
thread,
memoPost,
memoReply,
memoLike,
currentPath: null,
menuOpen: false,
likedTxids: new Set()
}
// The New Post Page controller wraps the memo post behavior. Its navigate
// adapter updates the world's current path so navigation can be asserted.
world.newPage = new NewPostPage({
memoPost,
navigate: (path) => { world.currentPath = path },
menuLinks: []
})
// The Reply Thread Page controller wraps the memo reply behavior. It does
// not navigate on success so the user stays in the thread modal.
world.replyPage = new ReplyThreadPage({
memoReply,
navigate: () => {}
})
// The Like / Tip Page controller wraps the memo like behavior.
world.likeTipPage = new LikeTipPage({ memoLike })
// The Set Name Page and Account Page controllers share a profile store so
// a name set on one page is visible on the other.
const profiles = makeProfiles()
const memoSetName = new MemoSetName({ wallet, profiles })
world.setNamePage = new SetNamePage({
memoSetName,
navigate: (path) => { world.currentPath = path }
})
world.accountPage = new AccountPage({
wallet,
profiles,
navigate: (path) => { world.currentPath = path }
})
return world
}
// Decode a raw reply payload into its parent txid (hex) and reply text.
function decodeReplyPayload (raw) {
const buf = Buffer.from(raw)
const parentTxid = buf.slice(0, 32).toString('hex')
const text = buf.slice(32).toString('utf8')
return { parentTxid, text }
}
// Decode a raw like payload back into the liked post txid (hex).
function decodeLikeTxid (raw) {
return Buffer.from(raw).toString('hex')
}
// Resolve a literal value or a <parameter> placeholder from the example store.
function resolveParam (value, example) {
const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim())
if (match) {
const param = match[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
return example[param]
}
return String(value).trim()
}
// Handler registry. Each entry: { pattern, run }.
// run receives (match, exampleStore, world, step).
const handlers = [
{
name: 'wallet authenticated for address',
pattern: /^a wallet authenticated for the address (.+)$/,
run (m, example, world) {
world.wallet.walletInfo.cashAddress = m[1].trim()
}
},
{
name: 'wallet has spendable output',
pattern: /^the wallet has (?:a )?spendable output to pay the transaction fee$/,
run (m, example, world) {
world.wallet.utxos = [{ txid: 'utxo-for-fee', value: 100000 }]
}
},
{
name: 'viewing recent posts feed',
pattern: /^I am viewing the recent posts feed$/,
run (m, example, world) {
world.currentPath = NewPostPage.RECENT_FEED_PATH
}
},
{
name: 'wallet fails to broadcast with error',
pattern: /^the wallet fails to broadcast with the error "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.wallet.failWith = example[param]
}
},
{
name: 'navigate to path',
pattern: /^I navigate to the path (.+)$/,
run (m, example, world, step) {
const target = m[1].trim()
if (step.keyword === 'Then') {
if (world.currentPath !== target) {
throw new Error(`Expected to be on path ${target}, but current path is ${world.currentPath}.`)
}
} else {
world.currentPath = target
}
}
},
{
name: 'remain on path',
pattern: /^I remain on the path (.+)$/,
run (m, example, world) {
const target = m[1].trim()
if (world.currentPath !== target) {
throw new Error(`Expected to remain on path ${target}, but current path is ${world.currentPath}.`)
}
}
},
{
name: 'open navigation menu',
pattern: /^I open the navigation menu$/,
run (m, example, world) {
world.menuOpen = true
}
},
{
name: 'menu shows link to path',
pattern: /^the menu shows a link to the path (.+)$/,
run (m, example, world) {
const target = m[1].trim()
if (!world.newPage.hasMenuLink(target)) {
throw new Error(`Navigation menu does not link to ${target}.`)
}
}
},
{
name: 'compose/type memo text',
pattern: /^I (?:compose|type) a memo with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.newPage.setInput(example[param])
}
},
{
name: 'type name text',
pattern: /^I type a name with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.setNamePage.setInput(example[param])
}
},
{
name: 'submit/click post',
pattern: /^I (?:submit the memo|click the post button)$/,
async run (m, example, world) {
await world.newPage.submit()
}
},
{
name: 'submit name',
pattern: /^I submit the name$/,
async run (m, example, world) {
await world.setNamePage.submit()
}
},
{
name: 'thread modal shows reply form',
pattern: /^the thread modal shows a reply form$/,
run (m, example, world) {
// The reply form is always considered visible once the thread is open.
if (!world.replyPage) {
throw new Error('No reply page is attached to the thread.')
}
}
},
{
name: 'post with txid has no replies',
pattern: /^a post with the txid (.+) has no replies$/,
run (m, example, world) {
const txid = m[1].trim()
world.thread.rootTxid = txid
world.replyPage.setParent(txid)
// A fresh thread store already has no replies.
if (world.thread.replies.length !== 0) {
throw new Error(`Expected post ${txid} to have no replies, but it has ${world.thread.replies.length}.`)
}
}
},
{
name: 'click comment icon on post',
pattern: /^I click the comment icon on the post with txid (.+)$/,
run (m, example, world) {
const txid = m[1].trim()
// Opening the thread modal means setting the active thread txid.
world.thread.rootTxid = txid
world.replyPage.setParent(txid)
}
},
{
name: 'thread modal opens for post',
pattern: /^the thread modal opens for the post with txid (.+)$/,
run (m, example, world) {
const txid = m[1].trim()
if (world.thread.rootTxid !== txid) {
throw new Error(`Expected thread modal to open for ${txid}, but current thread is ${world.thread.rootTxid}.`)
}
if (!world.replyPage) {
throw new Error('Thread modal opened without a reply form page.')
}
}
},
{
name: 'open reply thread',
pattern: /^I open the thread for the post with txid (.+)$/,
run (m, example, world) {
const txid = m[1].trim()
world.thread.rootTxid = txid
world.replyPage.setParent(txid)
}
},
{
name: 'type reply text',
pattern: /^I type a reply with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.replyPage.setInput(example[param])
world.replyPage.setParent(world.thread.rootTxid)
}
},
{
name: 'type reply to nested reply',
pattern: /^I type a reply to the nested reply with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.replyPage.setInput(example[param])
if (!world.nestedTxid) {
throw new Error('No nested reply has been selected.')
}
world.replyPage.setParent(world.nestedTxid)
}
},
{
name: 'submit reply',
pattern: /^I submit the reply$/,
async run (m, example, world) {
await world.replyPage.submit()
}
},
{
name: 'thread shows nested reply',
pattern: /^the thread shows a nested reply with the txid (.+)$/,
run (m, example, world) {
const txid = m[1].trim()
world.nestedTxid = txid
world.thread.addReply({
txid,
address: 'someone-else',
text: 'nested reply',
parentTxid: world.thread.rootTxid
})
}
},
{
name: 'click Set Name button',
pattern: /^I click the Set Name button$/,
run (m, example, world) {
world.accountPage.clickSetName()
}
},
{
name: 'broadcasts/attempts OP_RETURN with Memo post prefix',
pattern: /^(?:the wallet|the app) (?:broadcasts|attempts to broadcast) an OP_RETURN transaction with the Memo post prefix$/,
run (m, example, world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_POST_PREFIX) {
throw new Error(`Expected Memo post prefix ${MEMO_POST_PREFIX}, got "${last.prefix}".`)
}
if (last.msg !== world.newPage.input) {
throw new Error('Broadcast message text did not match the composed memo.')
}
}
},
{
name: 'broadcasts OP_RETURN with Memo set-name prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo set-name prefix$/,
run (m, example, world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_SET_NAME_PREFIX) {
throw new Error(`Expected Memo set-name prefix ${MEMO_SET_NAME_PREFIX}, got "${last.prefix}".`)
}
if (last.msg !== world.setNamePage.input) {
throw new Error('Broadcast name text did not match the typed name.')
}
}
},
{
name: 'broadcasts OP_RETURN with Memo reply prefix',
pattern: /^(?:the wallet|the app) broadcasts an OP_RETURN transaction with the Memo reply prefix$/,
run (m, example, world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_REPLY_PREFIX) {
throw new Error(`Expected Memo reply prefix ${MEMO_REPLY_PREFIX}, got "${last.prefix}".`)
}
const { parentTxid, text } = decodeReplyPayload(last.msg)
if (parentTxid !== world.replyPage.parentTxid) {
throw new Error('Broadcast parent txid did not match the expected reply target.')
}
if (text !== world.replyPage.input) {
throw new Error('Broadcast reply text did not match the typed reply.')
}
}
},
{
name: 'thread shows new reply from my address',
pattern: /^the thread shows a new reply from my address with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expectedText = example[param]
const myAddress = world.wallet.walletInfo.cashAddress
const found = world.thread.replies.find(
(r) => r.text === expectedText && r.address === myAddress
)
if (!found) {
throw new Error(`Thread does not show the new reply with text "${expectedText}".`)
}
}
},
{
name: 'thread shows validation/length error',
pattern: /^the thread shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'reply_validation' : 'reply_length'
if (world.replyPage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.replyPage.submitError}.`)
}
}
},
{
name: 'thread remaining byte count',
pattern: /^the thread shows a remaining byte count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) {
throw new Error(`Invalid expected count for "${param}".`)
}
const actual = world.replyPage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{
name: 'feed shows new post from my address',
pattern: /^the feed shows a new post from my address with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expectedText = example[param]
const myAddress = world.wallet.walletInfo.cashAddress
const found = world.feed.posts.find(
(p) => p.text === expectedText && p.address === myAddress
)
if (!found) {
throw new Error(`Feed does not show the new post with text "${expectedText}".`)
}
}
},
{
name: 'page shows error containing text',
pattern: /^the new post page shows an error containing "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expected = example[param]
const actual = world.newPage.broadcastError || ''
if (!actual.includes(expected)) {
throw new Error(`Expected an error containing "${expected}", got "${actual}".`)
}
}
},
{
name: 'page shows validation/length error',
pattern: /^the (?:app|new post page) shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'memo_validation' : 'memo_length'
if (world.newPage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.newPage.submitError}.`)
}
}
},
{
name: 'set name page shows validation/length error',
pattern: /^the set name page shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'name_validation' : 'name_length'
if (world.setNamePage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.setNamePage.submitError}.`)
}
}
},
{
name: 'remaining character count',
pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) {
throw new Error(`Invalid expected count for "${param}".`)
}
const actual = world.newPage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining characters, got ${actual}.`)
}
}
},
{
name: 'remaining byte count',
pattern: /^the set name page shows a remaining byte count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) {
throw new Error(`Invalid expected count for "${param}".`)
}
const actual = world.setNamePage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{
name: 'app does not broadcast any transaction',
pattern: /^(?:the wallet|the app) does not broadcast any transaction$/,
run (m, example, world) {
if (world.wallet.broadcasts.length !== 0) {
throw new Error('A transaction was broadcast when none was expected.')
}
}
},
{
name: 'account page shows name',
pattern: /^the account page shows my name as "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expected = example[param]
const actual = world.accountPage.getName()
if (actual !== expected) {
throw new Error(`Expected account name "${expected}", got "${actual}".`)
}
}
},
{
name: 'account page shows Set Name button',
pattern: /^the account page shows a Set Name button$/,
run (m, example, world) {
if (!world.accountPage.hasSetNameButton()) {
throw new Error('Account page does not show a Set Name button.')
}
}
},
{
name: 'wallet has spendable balance',
pattern: /^the wallet has a spendable balance of (.+) sats$/,
run (m, example, world) {
const balance = parseInt(resolveParam(m[1], example), 10)
if (Number.isNaN(balance)) {
throw new Error(`Invalid balance value "${m[1]}"`)
}
world.wallet.utxos = [{ txid: 'utxo-for-balance', value: balance }]
}
},
{
name: 'post with txid authored by author address',
pattern: /^a post with the txid (.+) authored by the author address$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const post = {
txid,
addr: AUTHOR_ADDRESS,
address: AUTHOR_ADDRESS,
text: 'A sample post',
likeCount: 0
}
world.feed.addPost(post)
}
},
{
name: 'post with txid authored by my address',
pattern: /^a post with the txid (.+) authored by my address$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const myAddress = world.wallet.walletInfo.cashAddress
const post = {
txid,
addr: myAddress,
address: myAddress,
text: 'My own post',
likeCount: 0
}
world.feed.addPost(post)
}
},
{
name: 'click heart icon on post',
pattern: /^I click the heart icon on the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const post = world.feed.posts.find((p) => p.txid === txid)
const authorAddress = post ? post.addr : AUTHOR_ADDRESS
world.likeTipPage.open(txid, authorAddress)
}
},
{
name: 'like/tip modal opens for post',
pattern: /^a like\/tip modal opens for the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
if (!world.likeTipPage.modalOpen) {
throw new Error('Expected like/tip modal to be open.')
}
if (world.likeTipPage.postTxid !== txid) {
throw new Error(`Expected like/tip modal for ${txid}, but got ${world.likeTipPage.postTxid}.`)
}
}
},
{
name: 'submit like without tip',
pattern: /^I submit the like without a tip$/,
async run (m, example, world) {
world.likeTipPage.setTip('')
const result = await world.likeTipPage.submit()
if (result.ok) {
world.likedTxids.add(world.likeTipPage.postTxid)
}
}
},
{
name: 'enter tip',
pattern: /^I enter a tip of (.+)$/,
run (m, example, world) {
world.likeTipPage.setTip(resolveParam(m[1], example))
}
},
{
name: 'submit like',
pattern: /^I submit the like$/,
async run (m, example, world) {
const result = await world.likeTipPage.submit()
if (result.ok) {
world.likedTxids.add(world.likeTipPage.postTxid)
}
}
},
{
name: 'broadcasts OP_RETURN with Memo like prefix',
pattern: /^the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_LIKE_PREFIX) {
throw new Error(`Expected Memo like prefix ${MEMO_LIKE_PREFIX}, got "${last.prefix}".`)
}
if (decodeLikeTxid(last.msg) !== txid) {
throw new Error(`Broadcast liked txid did not match ${txid}.`)
}
}
},
{
name: 'wallet sends no tip',
pattern: /^the wallet sends no tip$/,
run (m, example, world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (!Array.isArray(last.bchOutput) || last.bchOutput.length !== 0) {
throw new Error('Expected no tip output, but one was present.')
}
}
},
{
name: 'wallet sends tip to author',
pattern: /^the wallet sends a tip of (.+) to the author address$/,
run (m, example, world) {
const expectedTip = parseInt(resolveParam(m[1], example), 10)
if (Number.isNaN(expectedTip)) {
throw new Error(`Invalid tip value "${m[1]}"`)
}
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (!Array.isArray(last.bchOutput) || last.bchOutput.length === 0) {
throw new Error('Expected a tip output, but none was present.')
}
const tipOutput = last.bchOutput[0]
if (tipOutput.amountSat !== expectedTip) {
throw new Error(`Expected tip ${expectedTip} sats, got ${tipOutput.amountSat}.`)
}
const post = world.feed.posts.find((p) => p.txid === world.likeTipPage.postTxid)
const expectedAddress = post ? post.addr : AUTHOR_ADDRESS
if (tipOutput.address !== expectedAddress) {
throw new Error(`Expected tip to ${expectedAddress}, got ${tipOutput.address}.`)
}
}
},
{
name: 'like count increases by one',
pattern: /^the like count on the post increases by one$/,
run (m, example, world) {
const postTxid = world.likeTipPage.postTxid
const post = world.feed.posts.find((p) => p.txid === postTxid)
if (!post) {
throw new Error(`Post ${postTxid} not found in feed.`)
}
if (post.likeCount !== 1) {
throw new Error(`Expected like count to be 1, got ${post.likeCount}.`)
}
}
},
{
name: 'heart icon shows as filled',
pattern: /^the heart icon on the post shows as filled$/,
run (m, example, world) {
const postTxid = world.likeTipPage.postTxid
if (!world.likedTxids.has(postTxid)) {
throw new Error(`Expected heart icon to be filled for ${postTxid}.`)
}
}
},
{
name: 'like/tip modal shows error containing text',
pattern: /^the like\/tip modal shows an error containing "(.+)"$/,
run (m, example, world) {
const expected = m[1]
const actual = world.likeTipPage.broadcastError || ''
if (!actual.includes(expected)) {
throw new Error(`Expected an error containing "${expected}", got "${actual}".`)
}
}
},
{
name: 'click cancel button',
pattern: /^I click the cancel button$/,
run (m, example, world) {
world.likeTipPage.close()
}
},
{
name: 'like/tip modal closes',
pattern: /^the like\/tip modal closes$/,
run (m, example, world) {
if (world.likeTipPage.modalOpen) {
throw new Error('Expected like/tip modal to be closed.')
}
}
}
]
// Route a single step to its handler. Throws on unsupported step text.
async function handleStep (step, example, world) {
for (const handler of handlers) {
const match = handler.pattern.exec(step.text)
if (match) {
await handler.run(match, example, world, step)
return
}
}
throw new Error(`Unsupported step: ${step.keyword} ${step.text}`)
}
module.exports = { createWorld, handleStep }
@@ -0,0 +1,68 @@
/*
Persistent runner adapter for the APS gherkin-mutator.
The mutator starts this process (once per worker) and sends mutation jobs
over newline-delimited JSON on stdin. Each job carries the path to a mutated
feature JSON IR; this worker evaluates it through the same acceptance runtime
and step handlers used by the normal acceptance pipeline and replies with the
runner outcome.
Protocol (mutator-spec.md):
request: { "id", "feature_json", "generated_dir", "work_dir" }
response: { "id", "outcome", "output", "error", "duration" }
outcome: test_success | test_failure | infrastructure_error
test_failure (acceptance failed) -> mutation killed
test_success (acceptance passed) -> mutation survived
*/
'use strict'
const readline = require('node:readline')
const fs = require('node:fs')
const { runFeature } = require('./runtime')
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
})
rl.on('line', async (line) => {
const started = Date.now()
const respond = (payload) => {
process.stdout.write(`${JSON.stringify(payload)}\n`)
}
let job
try {
job = JSON.parse(line)
} catch (err) {
respond({ id: 'unknown', outcome: 'infrastructure_error', output: '', error: `bad job: ${err.message}`, duration: Date.now() - started })
return
}
try {
const ir = JSON.parse(fs.readFileSync(job.feature_json, 'utf8'))
const report = await runFeature(ir)
respond({
id: job.id,
outcome: report.failures === 0 ? 'test_success' : 'test_failure',
output: report.results.map((r) => `${r.status} ${r.name}`).join('\n'),
error: '',
duration: Date.now() - started
})
} catch (err) {
respond({
id: job.id,
outcome: 'infrastructure_error',
output: '',
error: err.message,
duration: Date.now() - started
})
}
})
rl.on('close', () => {
process.exit(0)
})
+69
View File
@@ -0,0 +1,69 @@
/*
Acceptance runtime for psf-memo-client.
Expands each scenario (and each example row) from parser JSON IR into
scenario executions, prepends background steps, and dispatches every step to
the project step handlers. Unsupported steps, invalid example values, or
failed assertions fail that execution.
*/
'use strict'
const { createWorld, handleStep } = require('./handlers')
// Expand the IR scenarios into concrete executions.
// For scenario outlines with examples, one execution per example row; for
// scenarios without examples, one execution with an empty example store.
function expandScenarios (ir) {
const executions = []
const background = ir.background || []
for (const scenario of ir.scenarios) {
const examples = (scenario.examples && scenario.examples.length > 0)
? scenario.examples.map((example, i) => ({ example, suffix: `example_${i + 1}` }))
: [{ example: {}, suffix: 'example_1' }]
for (const { example, suffix } of examples) {
executions.push({
name: `${scenario.name}/${suffix}`,
steps: [...background, ...scenario.steps],
example
})
}
}
return executions
}
// Run a full feature and return a report of scenario outcomes.
async function runFeature (ir) {
const executions = expandScenarios(ir)
const results = []
let failures = 0
for (const ex of executions) {
// A fresh world/state object for each scenario execution.
const world = createWorld()
let failure = null
for (const step of ex.steps) {
try {
await handleStep(step, ex.example, world)
} catch (err) {
failure = err.message
break
}
}
if (failure) {
failures++
results.push({ name: ex.name, status: 'failed', detail: failure })
} else {
results.push({ name: ex.name, status: 'passed' })
}
}
return { feature: ir.name, results, failures, total: results.length }
}
module.exports = { expandScenarios, runFeature }
+17
View File
@@ -0,0 +1,17 @@
# Deploy
This directory contains scripts for deploying the app to different platforms and blockchains.
## App Deployment
### Blockchains
- Filecoin - The compiled app is uploaded to the Filecoin blockchain using [publish-filecoin.js](./publish-filecoin.js). Running this script requires a free API key from [web3.storage](https://web3.storage).
- IPFS - The files are also pinned by the Pinata service using [publish-pinata.js](./publish-filecoin.js). Running this script requires a free JWT token from [Pinata](https://pinata.cloud).
- Bitcoin Cash - The IPFS CID is written to the Bitcoin Cash blockchain with [publish-bch.js](/publish-bch.js) This creates an immutable, censorship-resistant, globally available, and secure pointer to the latest version of the app.
The above deployment scripts are orchestrated with [publish-main.js](`./publish-main.js`). This script is run by executing `npm run pub`.
### GitHub Pages
The app can also be deployed to GitHub pages. This requires switching to the `gh-pages` branch and running the command `npm run pub:ghp`.
## Code Deployment
The code in this repository is backed up to the [Radicle](https://radicle.network/get-started.html) network, as GitHub has been increasing its censorship of code. Find instructions for *consuming* the code in the [top-level README](../README.md). To learn how install Radicle on your own machine and collaborate on the code that way, check out [this research article](https://christroutner.github.io/trouts-blog/docs/censorship/radicle).
+52
View File
@@ -0,0 +1,52 @@
/*
This script will write the CID for the current version of the app to an
address on the BCH blockchain. This creates an immutable, censorship-resistant,
globally available, and secure pointer to the latest version of the app.
The exported function expects an IPFS CID as input and returns a TXID for a
BCH transaction.
This function expects this environtment variable to contain a WIF private key
with BCH to write to the blockchain:
- REACT_BOOTSTRAP_WEB3_SPA_WIF
*/
// Global npm libraries
// const BCHJS = require('@psf/bch-js')
const BchWallet = require('minimal-slp-wallet/index')
const BchMessageLib = require('bch-message-lib/index')
async function publishToBch (cid) {
try {
// Get the Filecoin token from the environment variable.
const wif = process.env.REACT_BOOTSTRAP_WEB3_SPA_WIF
if (!wif) {
throw new Error(
'WIF private key not detected. Get a private key from https://wallet.fullstack.cash and save it to the REACT_BOOTSTRAP_WEB3_SPA_WIF environment variable.'
)
}
// Initialize libraries for working with BCH blockchain.
// const bchjs = new BCHJS()
const wallet = new BchWallet(wif, {
interface: 'consumer-api'
})
await wallet.walletInfoPromise
await wallet.initialize()
const bchMsg = new BchMessageLib({ wallet })
// Publish the CID to the BCH blockchain.
const hex = await bchMsg.memo.memoPush(cid, 'IPFS UPDATE')
// Broadcast the transaction to the network.
const txid = await wallet.ar.sendTx(hex)
// console.log(`BCH blockchain updated with new CID. TXID: ${txid}`)
// console.log(`https://blockchair.com/bitcoin-cash/transaction/${txid}`)
return txid
} catch (err) {
console.error(err)
}
}
module.exports = publishToBch
@@ -0,0 +1,82 @@
/*
This library is used to publish the compiled app to Filecoin.
The publishToFilecoin() function will upload the 'build' folder to Filecoin
via the web3.storage API.
The function will return an object that contains the CID of the uploaded
directory, and a URL for loading the app in a browser.
In order to run this script, you must obtain an API key from web3.storage.
That key should be saved to an environment variable named FILECOIN_TOKEN.
*/
const { Web3Storage, getFilesFromPath } = require('web3.storage')
const fs = require('fs')
async function publish () {
try {
const currentDir = `${__dirname}`
// console.log(`Current directory: ${dir}`)
const buildDir = `${currentDir}/../build`
// Get the Filecoin token from the environment variable.
const filecoinToken = process.env.FILECOIN_TOKEN
if (!filecoinToken) {
throw new Error(
'Filecoin token not detected. Get a token from https://web3.storage and save it to the FILECOIN_TOKEN environment variable.'
)
}
// Get a list of all the files to be uploaded.
const fileAry = await getFileList(buildDir)
// console.log(`fileAry: ${JSON.stringify(fileAry, null, 2)}`)
// Upload the files to Filecoin.
const cid = await uploadToFilecoin(fileAry, filecoinToken)
// console.log('Content added to Filecoin with CID:', cid)
// console.log(`https://${cid}.ipfs.dweb.link/`)
return cid
} catch (err) {
console.error(err)
}
}
function getFileList (buildDir) {
const fileAry = []
return new Promise((resolve, reject) => {
fs.readdir(buildDir, (err, files) => {
if (err) return reject(err)
files.forEach(file => {
// console.log(file)
fileAry.push(`${buildDir}/${file}`)
})
return resolve(fileAry)
})
})
}
async function uploadToFilecoin (fileAry, token) {
const storage = new Web3Storage({ token })
const files = []
for (let i = 0; i < fileAry.length; i++) {
const thisPath = fileAry[i]
// console.log('thisPath: ', thisPath)
const pathFiles = await getFilesFromPath(thisPath)
// console.log('pathFiles: ', pathFiles)
files.push(...pathFiles)
}
console.log(`Uploading ${files.length} files. Please wait...`)
const cid = await storage.put(files)
return cid
}
module.exports = publish
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Bash shell script to publish the app to GitHub pages.
# Ensure you are in the gh-pages branch.
#pwd
#git checkout gh-pages
#git merge master
npm run build
cp -r build docs
git add -A
git commit -m "Updating GitHub page"
git push
+29
View File
@@ -0,0 +1,29 @@
/*
This is the main publish file that aggregates the other publish libraries
and orchestrates them, so that one command can publish to different platforms.
*/
// Local libraries
const publishToFilecoin = require('./publish-filecoin')
const publishToPinata = require('./publish-pinata')
const publishToBch = require('./publish-bch')
async function publish () {
try {
// Publish to Filecoin
const cid = await publishToFilecoin()
console.log('Content added to Filecoin with CID:', cid)
console.log(`https://${cid}.ipfs.dweb.link/`)
// Publish to Pinata
await publishToPinata(cid)
// Public to BCH
const txid = await publishToBch(cid)
console.log(`\nBCH blockchain updated with new CID. TXID: ${txid}`)
console.log(`https://blockchair.com/bitcoin-cash/transaction/${txid}`)
} catch (err) {
console.error('Error while trying to publish app: ', err)
}
}
publish()
+52
View File
@@ -0,0 +1,52 @@
/*
This library will pin the app to Pinata. It expects a CID
as input, which is the output of publish-filecoin.js.
Filecoin should be though of as cold-storage for data. It's very slow to
retrieve. Pinata can be though of as RAM. It keeps content at the ready and
fast to deliver. They are complimentary services.
In order to run this script, you must obtain an API key from pinata.cloud.
That key should be saved to an environment variable named PINATA_JWT.
*/
const axios = require('axios')
async function publishToPinata (cid) {
// Get the Pinata token from the environment variable.
const pinataToken = process.env.PINATA_JWT
if (!pinataToken) {
throw new Error(
'Pinata JWT token not detected. Get a token from https://pinata.cloud and save it to the PINATA_JWT environment variable.'
)
}
const now = new Date()
const data = JSON.stringify({
hashToPin: cid,
pinataMetadata: {
name: 'react-bootstrap-web3-spa',
keyvalues: {
timestamp: now.toISOString()
}
}
})
const config = {
method: 'post',
url: 'https://api.pinata.cloud/pinning/pinByHash',
headers: {
Authorization: `Bearer ${pinataToken}`,
'Content-Type': 'application/json'
},
data
}
const res = await axios(config)
console.log('\nCID pinned using Pinata:')
console.log(res.data)
}
module.exports = publishToPinata
+28
View File
@@ -0,0 +1,28 @@
# Developer Docs
This file contains notes taken during software development. These notes may eventually be edited into informaiton that goes into the top-level README, or other documentation.
## Main Features of this App
- [react-bootstrap](https://react-bootstrap.github.io/) is used for general style and layout control.
- An easily customizable waiting modal component can be invoked while waiting for network calls to complete.
- [minimal-slp-wallet](https://www.npmjs.com/package/minimal-slp-wallet) is used to access tokens and BCH on the Bitcoin Cash blockchain.
- A 'server selection' dropdown allows the user to select from an array of redundent back end servers.
- This site is statically compiled, uploaded to Filecoin, and served over IPFS for censorship resistance and version control.
## File Layout
The top-level file layout of this app looks like this:
- App.js - the main application orchestates these child components:
- GetRestUrl - retrieves the REST URL for the selected back-end web3 server from query paramenters in the URL.
- LoadScripts - Loads the modal with a waiting spinner animation until the external script files are loaded.
- NavMenu - the collapsible navigation menu
- InitializedView & UnitializedView - the default Views that are displayed depending on the state of the app.
- ServerSelect - allows the user to select a different web3 back end server.
- Footer - Footer links
After initialization, the InitailizedView is displayed. This loads the AppBody, which is a wrapper for each View. Views are selected using the navigation menu. When one View is selected, the others are hidden.
## Loading of Wallet
The wallet library [minimal-slp-wallet](https://www.npmjs.com/package/minimal-slp-wallet) is loaded at startup, and initialized with a web3 back end server. By default, the back-end server is free-bch.fullstack.cash. However, a list of back end servers provided by the [PSF](https://psfoundation.cash) are loaded into a drop-down from a GitHub
@@ -0,0 +1,63 @@
# psf-memo-db changes to support Like counts
**Status**: DRAFT — notes for a future session. The current Like/Tip feature
(`0x6d04`) focuses on the **UI to broadcast a like** (and an optional tip). The
read-side changes below are **not** implemented now; they are recorded here so the
like-count read path can be developed later.
Owner: specifier.
Last updated: 2026-08-26.
---
## Goal
Expose like counts (and, later, liked-state and a likers list) so the psf-memo-client
UI can show a real like count on each post and whether the viewing user already liked
it. Today `/posts/*` responses omit likes entirely.
## What the indexer already provides
The Memo **indexer** (`psf-memo-indexer`) already parses `0x6d04` like/tip actions into
the DB as social references: a **liker address** → a **liked post txid** (with an
optional tip value). This feature does not require indexer changes to record likes; it
requires the **DB query/API** layer to aggregate and expose them.
## Required psf-memo-db changes
1. **`likeCount` on post responses.**
Add a `likeCount` field (number of distinct `0x6d04` references whose liked txid
equals the post txid) to the objects returned by:
- `/posts/recent` (feed items)
- `/post/:txid` (thread root)
- thread reply nodes (when replies are also likeable / shown with counts)
Aggregate the count in the query rather than an N+1 per-post lookup.
2. **Liked-state for the viewing user (optional, later).**
To render a filled heart when the current wallet has already liked a post, the
read endpoints need to know the viewer. Add an optional `viewer=<address>` query
param (or equivalent) to the relevant post endpoints and return
`liked: true|false` per post based on whether `viewer` has a `0x6d04` reference to
that txid. Until this exists, the client can track "liked" locally/optimistically
for the current session only.
3. **Likers list endpoint (later).**
memo.cash shows a modal listing who liked a post (its `post/likes`). Add an endpoint
e.g. `GET /post/:txid/likes` returning `[{ address, name, profilePicUrl, tip }...]`
for the addresses that liked the post, ordered by time/tip, joined with the profile
store to avoid N+1 lookups. Used by a future "likes" modal.
4. **Join efficiency.**
Like counts must be aggregated server-side (e.g. a counter derived from the
reference index or a materialized count) and included in the same response as the
post text, author, name, and avatar — avoid N+1 per-item like lookups in feed and
thread responses.
## Out of scope (this session)
- The like-count **read surface** (count badge, liked-state, likers modal).
- `viewer` liked-state param.
- Likers list endpoint.
All of the above are future work; the UI spec for this session only broadcasts the
like/tip and increments a count **optimistically**.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

File diff suppressed because it is too large Load Diff
+26749
View File
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
{
"name": "psf-memo-client",
"version": "1.0.0",
"dependencies": {
"@chris.troutner/react-jdenticon": "1.0.1",
"@fortawesome/fontawesome-svg-core": "6.7.2",
"@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.2",
"axios": "0.27.2",
"bch-message-lib": "2.2.1",
"bch-token-sweep": "2.2.1",
"bootstrap": "5.2.0",
"qrcode.react": "4.2.0",
"query-string": "7.1.1",
"react": "19.0.0",
"react-bootstrap": "2.10.7",
"react-dom": "19.0.0",
"react-markdown": "10.1.0",
"react-router-dom": "7.1.3",
"react-scripts": "5.0.1",
"use-local-storage-state": "19.5.0",
"use-query-params": "1.2.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "node --test \"test/unit/*.test.js\"",
"test:property": "node --test \"test/property/*.test.js\"",
"test:acceptance": "node acceptance/acceptance.js",
"eject": "react-scripts eject",
"lint": "standard --env mocha --fix",
"pub": "node deploy/publish-main.js",
"pub:ghp": "./deploy/publish-gh-pages.sh"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"crap4javascript": "github:FullStack-Agents/crap4javascript",
"dry4javascript": "github:FullStack-Agents/dry4javascript",
"husky": "9.1.7",
"minimal-slp-wallet": "5.13.1",
"mutate4javascript": "github:FullStack-Agents/mutate4javascript",
"semantic-release": "24.2.3",
"standard": "17.0.0",
"web3.storage": "4.3.0"
},
"release": {
"publish": [
{
"path": "@semantic-release/npm",
"npmPublish": false
}
]
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BCH Web3 Template</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
File diff suppressed because one or more lines are too long
+285
View File
@@ -0,0 +1,285 @@
# Specifier Prompt — psf-memo-client
You are the **specifier** for the `psf-memo-client` SwarmForge swarm. This file is your
standing briefing. You have no memory of prior sessions; this prompt (plus the
repo state) is how you pick up the work. Read it fully, follow it, and update it at
the end of each session when asked.
---
## 1. Role & startup (do these first)
1. Read `swarmforge/constitution.prompt`, then read every file it refers to
recursively and obey them. Then read `swarmforge/roles/specifier.prompt` and
follow it. (The constitution lives at `swarmforge/constitution.prompt`; articles
are in `swarmforge/constitution/articles/`. Roles are in `swarmforge/roles/`.)
2. Check for work: run `ready_for_next.sh`. If it prints `TASK`/`BATCH`, process it.
If `NO_TASK`, ask the user for the next feature (from the backlog in §5).
3. You are assigned to the `master` worktree = the **main checkout**, currently on
branch **`feat1`**. That is where you commit specs and where the app the user runs
lives. You work ONLY there.
---
## 2. Project & architecture
- **psf-memo-client**: a React SPA (JavaScript) meant to be an open-source clone of
memo.cash (https://memo.cash), a Twitter-like social network on Bitcoin Cash (BCH).
- Every social action is a BCH `OP_RETURN` transaction: Memo protocol prefix `0x6d` +
action byte + payload. It is **broadcast** to the chain, then crawled by
`psf-memo-indexer` and stored in `psf-memo-db`.
- **Write path**: use `minimal-slp-wallet.sendOpReturn()`. See §9 for the critical
signature gotcha.
- **Read path**: `psf-memo-db` LevelDB + REST API (default `http://localhost:5021`,
prod live: `https://memo-api.fullstackcash.net`). Overridable via
`REACT_APP_MEMO_DB_URL`.
- **Identity/auth**: the React app auto-generates an HD wallet (12-word mnemonic)
persisted to browser Local Storage on first load; the first derived key pair is the
Memo identity. Posting broadcasts from that wallet.
- Backends (separate repos, read-only reference): `psf-memo-indexer`
(`/home/trout/work/psf-memo-indexer`), `psf-memo-db`
(`/home/trout/work/psf-memo-db`). They MAY be changed to make the API more
efficient/scalable; API changes are in scope for specs.
- LLM wiki for BCH reference: `/home/trout/work/psf-llm-wiki` (read `AGENTS.md` and
`wiki/index.md`).
---
## 3. The SwarmForge pipeline — READ THIS (gotchas)
This is the most important operational section.
- The swarm has 4 agents: **specifier** (you), **coder**, **refactorer**, **architect**.
Each works in its own **git worktree on its own branch**:
- specifier: main checkout, branch **`feat1`**
- coder: `.worktrees/coder` on branch `swarmforge-coder`
- refactorer: `.worktrees/refactorer` on `swarmforge-refactorer`
- architect: `.worktrees/architect` on `swarmforge-architect`
- Work flows: specifier → coder → refactorer → architect → back to specifier to merge.
### GOTCHA: the coder does NOT commit to `feat1`.
The coder commits to its own `swarmforge-coder` branch. The finalized work is
reviewed/merged through refactorer and architect and ends up on the
`swarmforge-architect` branch. **The running app and your `feat1` branch do NOT see
it until YOU merge the architect branch into `feat1`.** Do that when:
- the architect completes a job (you may need to check, or the user asks), or
- the user explicitly asks to see the feature.
Then **verify** with `npm run build` (must print `Compiled successfully.`). Remember
the user runs `feat1` — a feature is "done" for them only after this merge.
### GOTCHA #2: the handoff daemon does not auto-start
- Sending a handoff only queues it into the sender's `outbox`. A daemon
(`handoffd.bb`) must be running to deliver it to the recipient's `inbox/new` and
wake the agent. If the outbox file stays put after you send, start the daemon:
```bash
nohup bb swarmforge/scripts/handoffd.bb /home/trout/work/psf-memo-client >/dev/null 2>&1 &
```
- A harmless `Failed to inhibit: Access denied` line appears at startup; the daemon
still works.
---
## 4. Specifier workflow (five phases) — from roles/specifier.prompt
For each feature:
1. Write the Gherkin that specifies the feature (see §6/§7 for format & tooling).
2. Prune: keep only parameters germane to acceptance mutation; drop identical
example-table columns that don't improve mutation.
3. Run `bb gherkin-ir-dry-checker` to normalize/prune.
4. Move repeated scenario setup into a Gherkin `Background` when it preserves
meaning.
5. **Ask the user for approval** before handing off to the coder. After approval:
commit with your byline (`By specifier.`), invent a short stable task name, and
send the file-based `git_handoff` (see §8).
Also: do not run Gherkin acceptance mutation; run tests only when verification is
needed.
---
## 5. Goal & feature backlog (memo.cash parity) with current status
Saved (and updated) at `specs/feature-backlog.md`. memo protocol action bytes below.
**Completed ✓ (merged to `feat1`):**
- Post a Memo (`0x6d02`) — service + `/posts/new` page + broadcast fix. FULLY DONE.
- Set display name (`0x6d01`) — `/account` + `/memo/set-name` pages, byte counter (77 bytes). DONE.
**Tier P1 — Core social verbs (write + read) — do these next, in order:**
1. ✅ Post a Memo (`0x6d02`) — DONE
2. ✅ Set display name (`0x6d01`) — DONE
3. ✅ Reply to a Memo (`0x6d03`) — DONE (merged to `display-name` @ `93e96e7`)
- **User-approved decisions (2026-08-26, from memo.cash UI review):**
- Reply max = **184 bytes** (UTF-8 byte count, memo.cash `MaxSize.Reply`).
- Reply form lives **inside the thread modal** (not inline in the feed).
- Keep the existing comment-icon behavior (opens the thread modal); put the reply
form in the modal.
- Replicate the live `[remaining]` byte counter (turns red when over limit).
- Update the thread **optimistically** after broadcast (no refresh).
- Users can **reply to a reply** (nested), not just the root post.
- Implemented as `src/services/memo-reply.js` (prefix `6d03`) + `reply-thread-page.js`;
spec `specs/reply-memo.feature`; all unit + acceptance tests pass; build OK.
4. Like / tip a Memo (`0x6d04`)
5. Set profile text / bio (`0x6d05`)
6. Set profile picture (`0x6d0a`)
7. Follow a user (`0x6d06`)
8. Unfollow a user (`0x6d07`)
**P2 — Topics:** topic post (`0x6d0c`), topic follow/unfollow (`0x6d0d`/`0x6d0e`),
topic feed.
**P3 — Polls:** create (`0x6d10`), add option (`0x6d13`), vote (`0x6d14`).
**P4 — Moderation:** mute/unmute (`0x6d16`/`0x6d17`).
**P5 — Money & tokens:** send money (`0x6d24`), token sell/buy/pin (MIP-0009).
**P6 — Discovery/UX:** search, tags, notifications, ranked feed, repost (`0x6d0b`).
**Decisions to carry forward:**
- Assume a broadcast succeeds and update the UI immediately (no "pending" state; no
"my pending posts" concept).
- Post memo length limit = **217 bytes** (memo.sv protocol), even though the indexer
allows `MAX_POST_SIZE = 65000`. Use 217.
- Keep the specs read-only-first for now, but always include the `sendOpReturn` write
code paths.
---
## 6. Memo protocol reference (action bytes)
`OP_RETURN 6d<action><payload>`, UTF-8 payload. Table (from memo.sv/protocol):
| Action byte | Meaning |
|-------------|---------|
| `6d01` | Set name |
| `6d02` | Post memo (msg max 217 bytes) |
| `6d03` | Reply to memo (parent txid 32 bytes + msg) |
| `6d04` | Like/tip memo (txid 32 bytes) |
| `6d05` | Set profile text |
| `6d06` / `6d07` | Follow / unfollow (address 20 bytes) |
| `6d0a` | Set profile picture (url) |
| `6d0b` | Repost (planned) |
| `6d0c`/`6d0d`/`6d0e` | Topic post / follow / unfollow |
| `6d10`/`6d13`/`6d14` | Create poll / add option / vote |
| `6d16`/`6d17` | Mute / unmute |
| `6d24` | Send money |
| `6d30``6d35` | MIP-0009 token sell/buy/attach/pin |
Binary payloads (txid, address hash) are NOT plain UTF-8; keep encoding in mind when
specing reply/like/follow.
---
## 7. Gherkin & acceptance tooling
- Clone the Acceptance Pipeline Spec fresh (do NOT rely on cached/stale copies):
```bash
cd /home/trout/work/psf-memo-client
mkdir -p tmp && cd tmp
git clone https://github.com/unclebob/Acceptance-Pipeline-Specification.git aps
```
Temp files go in the worktree's `./tmp/`, never `/tmp`.
- Commands (run from `tmp/aps`):
```bash
bb gherkin-parser <feature-file> <json-ir>
bb gherkin-ir-dry-checker [--include-exact] <json-ir> <report>
# optional: bb gherkin-mutator (you do not run acceptance mutation)
```
- Read `aps/parser-spec.md` and `aps/ir-dry-checker-spec.md` for the supported
Gherkin subset and report format.
- Rules: `Feature:`, one `Background:`, `Scenario Outline:` with `Examples:`. Name each
scenario `Feature Name - N`. Put a `#` comment listing the scenario names immediately
before the `Feature:` line. Use `<parameter>` placeholders for values that vary.
- Store feature files under `specs/*.feature`; backlog under `specs/`.
---
## 8. Handoff mechanics
- Commit message must end with `By specifier.`
- To hand off, write a draft file, then run the helper (it removes the draft on success):
```text
type: git_handoff
to: coder
priority: 10
task: <short-stable-task-name>
commit: <10-char-commit-abbrev>
```
```bash
SWARMFORGE_ROLE=specifier swarm_handoff.sh tmp/<draft>
```
- After sending, check the handoff was delivered (daemon). If not, start the daemon
(GOTCHA #2).
- Do NOT commit/notify the coder until the user explicitly approves the handoff.
- When the architect completes a job, **merge its branch into `feat1`** and verify
the build (see §10).
---
## 9. Known gotchas & lessons learned (keep adding)
1. **Coder commits to its own branch, not `feat1`** — you must merge the architect's
finalized branch into `feat1` for the running app to reflect changes.
2. **Handoff daemon must be started** if the outbox file stays put after `swarm_handoff.sh`.
3. **`sendOpReturn` public signature gotcha (real bug found):**
- `minimal-slp-wallet` wallet instance exposes
`sendOpReturn(msg='', prefix='6d02', bchOutput=[], satsPerByte=1.0)` — it resolves
`walletInfo` and its own spendable UTXOs internally.
- The low-level `lib/op-return.js` method has a different signature
`sendOpReturn(wallet, bchUtxos, msg, prefix, ...)`.
- Calling the wallet's public one with the low-level args makes `Buffer.from(msg)`
receive an object → `"The first argument must be one of type string, Buffer..."`
- **Correct usage:** `await this.wallet.getUtxos()` then
`await this.wallet.sendOpReturn(message, MEMO_POST_PREFIX)`.
4. **Unit/acceptance mocks can mask real API bugs** — the coder's tests mocked the buggy
call signature, so the test suite passed while the live app broke. Live e2e (real BCH
+ a live server) is what catches these. When adding/editing behavior, sanity-check the
real `minimal-slp-wallet` API.
5. **Error-masking bug fixed:** the New Post page once mapped every non-length error to
"Memo must not be empty." Now broadcast failures surface the real error
(`Failed to broadcast: <msg>`). Keep that behavior in specs.
6. **memo.cash pages are behind Cloudflare** — `/memo/new` etc. are hard to scrape; rely
on user-provided behavior details and the protocol spec.
7. **Byte vs char:** the 217 post limit and its counter count characters (`input.length`,
UTF-16), not bytes. The user is aware; multi-byte unicode may diverge. **Set Name
(`0x6d01`) uses BYTE counting (77 bytes) for memo.cash parity** — its byte counter and
length check use UTF-8 byte length. Ask/decide per feature.
8. **Live backend for e2e:** `https://memo-api.fullstackcash.net/` (prod memo-db). The
user can provide BCH for real broadcasts.
9. **memo.cash login is Cloudflare-blocked for automation:** the `/login` page shows a
hard Turnstile challenge that does not auto-resolve, even with a persistent Playwright
profile. The public pages (home, `/all` feed, `/post/<txid>`) DO resolve with a
persistent profile (`launchPersistentContext` + `--headless=new` +
`--disable-blink-features=AutomationControlled` + realistic UA). To explore the
logged-in UI, either solve Turnstile (real session / captcha service) or get
screenshots/HTML from the user. The reply UI was captured from public feed/post pages
plus reverse-engineering `https://memo.cash/js/min.js`:
- login flow `POST /login/submit {username,password,rid,loginToken}` → `SessionKey`;
- reply submit `memo/reply-submit` with `{txHash,message}`;
- `MaxSize.Reply = 184`; reply form = Message label + `[remaining]` byte counter +
textarea + "Post Reply"/"Cancel" + "Creating..."/"Processing..." states.
---
## 10. Run / verify the app
```bash
cd /home/trout/work/psf-memo-client
npm start # dev server (CRA)
npm run build # production build — verify after merges
npm test # node --test "test/unit/*.test.js"
npm run lint # standard --fix
```
Backend default `http://localhost:5021`; live prod `https://memo-api.fullstackcash.net/`.
---
## 11. Handoff to next session
At the end of each session, update this file:
- Mark features completed in the backlog (§5).
- Add any new gotchas to §10.
- Note the current `feat1` HEAD commit.
- State the next feature to work on (currently: **Like / tip a Memo, `0x6d04`**).
Current `display-name` HEAD: `93e96e7` (Reply to a Memo merged).
Next feature: **Like / tip a Memo, `0x6d04`** — not yet specced; awaiting user direction.
+156
View File
@@ -0,0 +1,156 @@
# psf-memo-client — Prioritized Feature Backlog
**Status**: DRAFT — saved for future development cycles.
**Owner**: specifier
**Last updated**: 2026-05-25
---
## Goal
Make psf-memo-client feature-equivalent to [memo.cash](https://memo.cash). Memo is a
Bitcoin Cash (BCH) social network built on `OP_RETURN` transactions. Every social
action is a BCH transaction carrying a Memo protocol payload (`0x6d` + action byte)
that is broadcast to the chain and later indexed by psf-memo-indexer into psf-memo-db.
## Architecture constraints
- **Identity/auth**: the auto-generated HD wallet (12-word mnemonic) already
persisted in browser Local Storage by the existing React app is the Memo identity.
The wallet's first derived key pair is the posting/identification key.
- **Write path**: broadcasting is done via `minimal-slp-wallet.sendOpReturn(wallet, bchUtxos, msg, prefix, bchOutput, satsPerByte)`.
- Default `prefix = '6d02'` posts a Memo.
- `msg` carries the Memo payload for the selected action.
- Reference tutorial: https://fullstack-agents.github.io/block-blog/#/education/11-write-text-blockchain
- **Read path**: psf-memo-db REST API (`/posts/*`, `/profile/*`, `/level/*`). API may be
refactored (in scope) to support a good UX.
- The write path (broadcast) and read path (indexed) are asynchronous: a broadcasted
action becomes visible only after confirmation + indexing.
## Memo protocol action codes
Reference: https://memo.sv/protocol
| Action byte | Meaning |
|-------------|---------|
| `0x6d01` | Set name |
| `0x6d02` | Post memo |
| `0x6d03` | Reply to memo |
| `0x6d04` | Like / tip memo |
| `0x6d05` | Set profile text |
| `0x6d06` | Follow user |
| `0x6d07` | Unfollow user |
| `0x6d0a` | Set profile picture |
| `0x6d0b` | Repost memo (planned) |
| `0x6d0c` | Post topic message |
| `0x6d0d` | Topic follow |
| `0x6d0e` | Topic unfollow |
| `0x6d10` | Create poll |
| `0x6d13` | Add poll option |
| `0x6d14` | Poll vote |
| `0x6d16` | Mute user |
| `0x6d17` | Unmute user |
| `0x6d24` | Send money |
| `0x6d30``0x6d35` | MIP-0009 token sell / buy / attach signature / pin |
---
## Tier P1 — Core social verbs (write + read)
These are the foundational posting and identity actions. Each is a broadcast
action plus its read/display surface. This is the recommended first development slice.
| # | Feature | Memo action | Write | Read surface |
|---|---------|-------------|-------|--------------|
| 1 | Post a Memo | `0x6d02` | Compose + `sendOpReturn` | Appears in recent feed & own profile after indexing |
| 2 | Set display name | `0x6d01` | Broadcast name | Name shown on posts, profiles, feed | ✅ DONE |
| 3 | Reply to a Memo | `0x6d03` | Broadcast reply to parent txid | Nested thread view |
| 4 | Like a Memo | `0x6d04` | Broadcast like for a post txid | Like count + liked state on post |
| 5 | Set profile text (bio) | `0x6d05` | Broadcast bio | Shown on profile page |
| 6 | Set profile picture | `0x6d0a` | Broadcast avatar URL | Avatar on profile + posts |
| 7 | Follow a user | `0x6d06` | Broadcast follow of address | Follow button state |
| 8 | Unfollow a user | `0x6d07` | Broadcast unfollow | Follow button state; following list |
**API/DB needs (P1):** like counts + liked-state per post; my follow status per user;
follower/following lists; name + profile + avatar joined into feed/profile responses
(avoid N+1 lookups). Current `/posts/recent` omits name/avatar/likes.
## Priority order within P1
1. **Post a Memo** — the primary verb; unblocks all others. ✅ DONE
2. **Set display name** — makes the feed readable and gives identity. ✅ DONE
3. **Reply to a Memo** — core conversation; extends the existing thread modal. ✅ DONE
- **Decisions (2026-08-26, from memo.cash UI review):** reply max = **184 bytes**
(UTF-8 byte count); reply form **inside the thread modal**; keep the existing
comment-icon behavior (opens the thread modal); replicate the live `[remaining]`
byte counter (turns red when over); update the thread **optimistically** after
broadcast; users can **reply to a reply** (nested).
4. **Like a Memo** — social signal; needs like-count API.
5. **Set profile text** — bio for the profile page.
6. **Set profile picture** — avatar for posts/profiles.
7. **Follow a user**.
8. **Unfollow a user**.
## P2 — Topics
| # | Feature | Memo action |
|---|---------|-------------|
| 9 | Post a topic message | `0x6d0c` |
| 10 | Follow a topic | `0x6d0d` |
| 11 | Unfollow a topic | `0x6d0e` |
| 12 | Topic feed page | read |
Needs: topics index in psf-memo-db, topic feed endpoint, topic follow state.
## P3 — Polls (later)
| # | Feature | Memo action |
|---|---------|-------------|
| 13 | Create a poll | `0x6d10` |
| 14 | Add a poll option | `0x6d13` |
| 15 | Vote in a poll | `0x6d14` |
Needs: poll data model + rendering + vote aggregation in psf-memo-db.
## P4 — Moderation (later)
| # | Feature | Memo action |
|---|---------|-------------|
| 16 | Mute a user | `0x6d16` |
| 17 | Unmute a user | `0x6d17` |
Needs: per-wallet mute list applied to feed filtering.
## P5 — Money & tokens (later)
| # | Feature | Memo action |
|---|---------|-------------|
| 18 | Send money | `0x6d24` |
| 19 | Token sell / buy / pin | `0x6d30``0x6d35` (MIP-0009) |
## P6 — Discovery & UX (later)
| # | Feature | Notes |
|---|---------|-------|
| 20 | Search (posts / profiles / topics / tags) | needs DB search index |
| 21 | Tags / hashtags | link + filter by tag |
| 22 | Notifications | replies / likes / follows to my posts |
| 23 | Ranked feed | memo.cash "ranked" post ordering |
| 24 | Repost | `0x6d0b` (planned in protocol) |
---
## Read-only vs write capability by cycle
- **Cycle 0 (current)**: read-only display of recent posts, profiles, post threads.
- **Cycle 1 (P1)**: add write code paths (broadcast via `sendOpReturn`). UI is
read-only until a broadcasted action is confirmed + indexed; then the feed/profile
refresh.
- **Later cycles**: topics, polls, moderation, money/tokens, discovery.
## Notes for future cycles
- Broadcast result (txid) is returned immediately; the action appears in the feed
only after block confirmation + indexing. Specs must reflect this async visibility.
- Mutations/specs are Gherkin feature files under `specs/` in the format defined by
github.com/unclebob/Acceptance-Pipeline-Specification.
+117
View File
@@ -0,0 +1,117 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-26T13:30:06.787347982Z","feature_name":"Like / Tip a Memo","feature_path":"/home/trout/work/psf-memo-client/.worktrees/architect/specs/like-tip-memo.feature","background_hash":"2cd08f817665556cd20cb9a69b0d96a8ad871a1a9c1929c9d7a56b41bb3eff64","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Like / Tip a Memo - 2 a pure like broadcasts the Memo like action","scenario_hash":"95a91e98202ff0980db0f249b801ed10b6e885ce24756116f90ef5d35289aa9d","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-08-26T13:30:06.787347982Z"},{"index":8,"name":"Like / Tip a Memo - 9 a user can like their own post","scenario_hash":"eec77e112d8aa86f9e8ff6b7e38634e0c252be14cb60c35974f71b07fead8fb5","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-08-26T13:30:06.787347982Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Like / Tip a Memo - 1, Like / Tip a Memo - 2, Like / Tip a Memo - 3, Like / Tip a Memo - 4, Like / Tip a Memo - 5, Like / Tip a Memo - 6, Like / Tip a Memo - 7, Like / Tip a Memo - 8, Like / Tip a Memo - 9, Like / Tip a Memo - 10
Feature: Like / Tip a Memo
Background:
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
Given the wallet has a spendable balance of 100000 sats
Given a post with the txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa authored by the author address
Scenario: Like / Tip a Memo - 1 the heart icon opens the like/tip modal
When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Then a like/tip modal opens for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Scenario Outline: Like / Tip a Memo - 2 a pure like broadcasts the Memo like action
Given a post with the txid <txid> authored by the author address
When I click the heart icon on the post with txid <txid>
When I submit the like without a tip
Then the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid <txid>
Then the wallet sends no tip
Then the like count on the post increases by one
Then the heart icon on the post shows as filled
Examples:
| txid |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
| bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb |
Scenario Outline: Like / Tip a Memo - 3 a like with a tip broadcasts and pays the author
When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
When I enter a tip of <tip>
When I submit the like
Then the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Then the wallet sends a tip of <tip> to the author address
Then the like count on the post increases by one
Then the heart icon on the post shows as filled
Examples:
| tip |
| 600 |
| 3000 |
| 25000 |
Scenario Outline: Like / Tip a Memo - 4 an invalid tip is rejected
When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
When I enter a tip of <tip>
When I submit the like
Then the like/tip modal shows an error containing "valid number"
Then the wallet does not broadcast any transaction
Examples:
| tip |
| 1.5 |
| abc |
Scenario Outline: Like / Tip a Memo - 5 a tip below the dust limit is rejected
When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
When I enter a tip of <tip>
When I submit the like
Then the like/tip modal shows an error containing "dust limit"
Then the wallet does not broadcast any transaction
Examples:
| tip |
| 1 |
| 599 |
Scenario: Like / Tip a Memo - 6 a tip above the maximum is rejected
Given the wallet has a spendable balance of 150000000 sats
When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
When I enter a tip of 100000001
When I submit the like
Then the like/tip modal shows an error containing "maximum"
Then the wallet does not broadcast any transaction
Scenario Outline: Like / Tip a Memo - 7 a tip above the spendable balance is rejected
Given the wallet has a spendable balance of <balance> sats
When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
When I enter a tip of <tip>
When I submit the like
Then the like/tip modal shows an error containing "spendable"
Then the wallet does not broadcast any transaction
Examples:
| balance | tip |
| 30000 | 35000 |
| 500000 | 550000 |
Scenario Outline: Like / Tip a Memo - 8 a user without spendable balance cannot like
Given the wallet has a spendable balance of <balance> sats
When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Then the like/tip modal shows an error containing "add BCH"
Then the wallet does not broadcast any transaction
Examples:
| balance |
| 0 |
| 2999 |
Scenario Outline: Like / Tip a Memo - 9 a user can like their own post
Given a post with the txid <txid> authored by my address
When I click the heart icon on the post with txid <txid>
When I submit the like without a tip
Then the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid <txid>
Then the like count on the post increases by one
Examples:
| txid |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
| bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb |
Scenario: Like / Tip a Memo - 10 the cancel button closes the like/tip modal
When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
When I click the cancel button
Then the like/tip modal closes
+75
View File
@@ -0,0 +1,75 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-26T04:22:55.346096718Z","feature_name":"New Post Page","feature_path":"../../specs/memo-new.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"New Post Page - 2 an empty memo is rejected on the new post page","scenario_hash":"ac70dcf123f435da2b8a6c4953b0b22b8e7a5a8a74291547b954cb562cf9f339","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T03:34:47.703957423Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: New Post Page - 1, New Post Page - 2, New Post Page - 3, New Post Page - 4, New Post Page - 5, New Post Page - 6
Feature: New Post Page
Background:
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
Given the wallet has spendable output to pay the transaction fee
Scenario Outline: New Post Page - 1 a valid memo is posted and the user lands on the feed
Given I navigate to the path /posts/new
When I type a memo with the text "<message>"
When I click the post button
Then the app broadcasts an OP_RETURN transaction with the Memo post prefix
Then I navigate to the path /posts/recent
Then the feed shows a new post from my address with the text "<message>"
Examples:
| message |
| hello memo |
| a longer memo with several words. |
Scenario Outline: New Post Page - 2 an empty memo is rejected on the new post page
Given I navigate to the path /posts/new
When I type a memo with the text "<message>"
When I click the post button
Then the new post page shows a validation error
Then the app does not broadcast any transaction
Examples:
| message |
| |
Scenario Outline: New Post Page - 3 an over-long memo is rejected on the new post page
Given I navigate to the path /posts/new
When I type a memo with the text "<message>"
When I click the post button
Then the new post page shows a length error
Then the app does not broadcast any transaction
Examples:
| message |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
| bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb |
Scenario Outline: New Post Page - 4 the character counter counts down from the memo limit
Given I navigate to the path /posts/new
When I type a memo with the text "<message>"
Then the new post page shows a remaining character count of <count>
Examples:
| message | count |
| | 217 |
| hello | 212 |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 0 |
Scenario: New Post Page - 5 the navigation menu links to the new post page
Given I open the navigation menu
Then the menu shows a link to the path /posts/new
Scenario Outline: New Post Page - 6 a failed broadcast surfaces the real error and the user stays on the page
Given I navigate to the path /posts/new
And the wallet fails to broadcast with the error "<broadcast_error>"
When I type a memo with the text "<message>"
When I click the post button
Then the app attempts to broadcast an OP_RETURN transaction with the Memo post prefix
Then the new post page shows an error containing "<broadcast_error>"
Then I remain on the path /posts/new
Examples:
| message | broadcast_error |
| hello memo | BCH UTXO list is empty |
| hello memo | Insufficient balance |
+44
View File
@@ -0,0 +1,44 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-26T04:22:56.345139893Z","feature_name":"Post a Memo","feature_path":"../../specs/post-memo.feature","background_hash":"d7f1a31b7651301ec01bdea9c4c990f9a032ce179aa84f8d6a78595f8a476474","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Post a Memo - 2 an empty memo is rejected","scenario_hash":"4e6fea6fa5adbf7fe0eefdd9bc21a7027fe7312d2937a4fd68f1a1eb68d33a8d","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T03:34:49.113519078Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Post a Memo - 1, Post a Memo - 2, Post a Memo - 3
Feature: Post a Memo
Background:
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
Given the wallet has a spendable output to pay the transaction fee
Scenario Outline: Post a Memo - 1 a valid memo is broadcast and shown in the feed
Given I am viewing the recent posts feed
When I compose a memo with the text "<message>"
When I submit the memo
Then the wallet broadcasts an OP_RETURN transaction with the Memo post prefix
Then the feed shows a new post from my address with the text "<message>"
Examples:
| message |
| hello memo |
| a longer memo with several words and punctuation. |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
Scenario Outline: Post a Memo - 2 an empty memo is rejected
When I compose a memo with the text "<message>"
When I submit the memo
Then the app shows a validation error
Then the wallet does not broadcast any transaction
Examples:
| message |
| |
Scenario Outline: Post a Memo - 3 an over-long memo is rejected
When I compose a memo with the text "<message>"
When I submit the memo
Then the app shows a length error
Then the wallet does not broadcast any transaction
Examples:
| message |
| bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb |
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
+90
View File
@@ -0,0 +1,90 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-26T12:20:44.218305772Z","feature_name":"Reply to a Memo","feature_path":"../../specs/reply-memo.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Reply to a Memo - 2 an empty reply is rejected","scenario_hash":"573c450bfab01f83d24545535cdaa4b2bb4c5617c092ff4f1532afa8dc0c20d2","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T12:20:44.218305772Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Reply to a Memo - 1, Reply to a Memo - 2, Reply to a Memo - 3, Reply to a Memo - 4, Reply to a Memo - 5, Reply to a Memo - 6, Reply to a Memo - 7, Reply to a Memo - 8
Feature: Reply to a Memo
Background:
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
Given the wallet has spendable output to pay the transaction fee
Scenario Outline: Reply to a Memo - 1 a valid reply to a post is broadcast and shown in the thread
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
When I type a reply with the text "<message>"
When I submit the reply
Then the wallet broadcasts an OP_RETURN transaction with the Memo reply prefix
Then the thread shows a new reply from my address with the text "<message>"
Examples:
| message |
| hello memo |
| a longer reply with several words. |
Scenario Outline: Reply to a Memo - 2 an empty reply is rejected
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
When I type a reply with the text "<message>"
When I submit the reply
Then the thread shows a validation error
Then the wallet does not broadcast any transaction
Examples:
| message |
| |
Scenario Outline: Reply to a Memo - 3 an over-long reply is rejected
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
When I type a reply with the text "<message>"
When I submit the reply
Then the thread shows a length error
Then the wallet does not broadcast any transaction
Examples:
| message |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
| bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb |
Scenario Outline: Reply to a Memo - 4 the byte counter counts down from the reply limit
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
When I type a reply with the text "<message>"
Then the thread shows a remaining byte count of <count>
Examples:
| message | count |
| | 184 |
| hello | 179 |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 0 |
Scenario Outline: Reply to a Memo - 5 a reply to a nested reply is broadcast
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
And the thread shows a nested reply with the txid bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
When I type a reply to the nested reply with the text "<message>"
When I submit the reply
Then the wallet broadcasts an OP_RETURN transaction with the Memo reply prefix
Then the thread shows a new reply from my address with the text "<message>"
Examples:
| message |
| hello nested |
| a longer nested reply. |
Scenario: Reply to a Memo - 6 the comment icon opens the thread even when a post has zero replies
Given a post with the txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa has no replies
When I click the comment icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Then the thread modal opens for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Scenario: Reply to a Memo - 7 the thread modal shows a reply form
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Then the thread modal shows a reply form
Scenario Outline: Reply to a Memo - 8 a reply with the authenticated wallet is broadcast from the feed thread modal
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
When I type a reply with the text "<message>"
When I submit the reply
Then the wallet broadcasts an OP_RETURN transaction with the Memo reply prefix
Then the thread shows a new reply from my address with the text "<message>"
Examples:
| message |
| hello memo |
| a wallet-backed reply |
+65
View File
@@ -0,0 +1,65 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-26T04:22:57.360768955Z","feature_name":"Set Name","feature_path":"../../specs/set-name.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Set Name - 2 an empty name is rejected on the set name page","scenario_hash":"a4fbf28bd1afbffeff2f24f685299663df57e3cc4332a115fea84c08db634670","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T03:34:50.405131105Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Set Name - 1, Set Name - 2, Set Name - 3, Set Name - 4, Set Name - 5
Feature: Set Name
Background:
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
Given the wallet has spendable output to pay the transaction fee
Scenario Outline: Set Name - 1 a valid name is broadcast and the user lands on the account page
Given I navigate to the path /memo/set-name
When I type a name with the text "<name>"
When I submit the name
Then the app broadcasts an OP_RETURN transaction with the Memo set-name prefix
Then I navigate to the path /account
Then the account page shows my name as "<name>"
Examples:
| name |
| trout |
| a longer name with spaces |
Scenario Outline: Set Name - 2 an empty name is rejected on the set name page
Given I navigate to the path /memo/set-name
When I type a name with the text "<name>"
When I submit the name
Then the set name page shows a validation error
Then the app does not broadcast any transaction
Examples:
| name |
| |
Scenario Outline: Set Name - 3 an over-long name is rejected on the set name page
Given I navigate to the path /memo/set-name
When I type a name with the text "<name>"
When I submit the name
Then the set name page shows a length error
Then the app does not broadcast any transaction
Examples:
| name |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
| bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb |
| 😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀 |
Scenario Outline: Set Name - 4 the byte counter counts down from the name limit
Given I navigate to the path /memo/set-name
When I type a name with the text "<name>"
Then the set name page shows a remaining byte count of <count>
Examples:
| name | count |
| | 77 |
| trout | 72 |
| é | 75 |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 0 |
Scenario: Set Name - 5 the account page links to the set name page
Given I navigate to the path /account
Then the account page shows a Set Name button
When I click the Set Name button
Then I navigate to the path /memo/set-name
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
/*
This is an SPA that creates a template for future BCH web3 apps.
*/
// Global npm libraries
import React, { useEffect, useCallback } from 'react'
// Local libraries
import './App.css'
import LoadScripts from './components/load-scripts'
import AsyncLoad from './services/async-load'
import Footer from './components/footer'
import NavMenu from './components/nav-menu'
import useAppState from './hooks/state'
import { UninitializedView, InitializedView } from './components/starter-views'
const sigleViewPaths = ['/profile', 'user-data']
function App (props) {
// Load all the app state into a single object that can be passed to child
// components.
const appData = useAppState()
// Add a new line to the waiting modal.
const addToModal = useCallback((inStr, appData) => {
// console.log('addToModal() inStr: ', inStr)
appData.setModalBody(prevBody => {
// console.log('prevBody: ', prevBody)
prevBody.push(inStr)
return prevBody
})
}, [])
const isSignleView = useCallback(async () => {
// Get current path
const currentPath = window.location.pathname
// Get hash from url
const hash = window.location.hash
const allowedPath = sigleViewPaths.find((val) => { return currentPath.match(val) })
if (hash === '#single-view' && allowedPath) {
addToModal('Loading minimal-slp-wallet', appData)
const asyncLoad = new AsyncLoad()
if (!appData.wallet) {
await asyncLoad.loadWalletLib()
const walletTemp = await asyncLoad.initStarterWallet(appData.serverUrl, appData.lsState.mnemonic, appData)
appData.setWallet(walletTemp)
// Get the BCH spot price
addToModal('Getting BCH spot price in USD', appData)
await asyncLoad.getUSDExchangeRate(walletTemp, appData.updateBchWalletState, appData)
}
// Update Modal State
appData.setIsSingleView(true)
appData.setHideSpinner(true)
appData.setShowStartModal(false)
appData.setDenyClose(false)
/* // Update the startup state.
appData.setAsyncInitFinished(true)
appData.setAsyncInitSucceeded(false) */
return true
}
return false
}, [appData, addToModal])
/**
* Run background process to get bch and slp balance.
* Also update the background state process.
* On error this function should trigger a info modal notifying the errors.
*/
const backgroundAsync = useCallback(async (asyncLoad, walletTemp) => {
try {
appData.setModalBody(['Getting BCH balance in background!.'])
// Get Wallet Balance
await asyncLoad.getWalletBchBalance(walletTemp, appData.updateBchWalletState, appData)
// Update Background state
appData.updateBackGroundInitState({ bchInitLoaded: true })
// Get SLP Balance
appData.setModalBody(['Getting SLP tokens in background!.'])
await asyncLoad.getSlpTokenBalances(walletTemp, appData.updateBchWalletState, appData)
// Update Background state
appData.updateBackGroundInitState({ slpInitLoaded: true, asyncBackgroundFinished: true })
} catch (err) {
console.log('App.js backgroundAsync() error!', err)
appData.updateBackGroundInitState({ asyncBackgroundFinished: true })
addToModal(`Error: ${err.message}`, appData)
addToModal('Try selecting a different back end server using the drop-down menu at the bottom of the app.\'', appData)
// Update Modal State
appData.setHideSpinner(true)
appData.setShowStartModal(true)
appData.setDenyClose(false)
// Update the startup state.
appData.setAsyncInitFinished(true)
appData.setAsyncInitSucceeded(false)
}
}, [appData, addToModal])
/** Load all required data before component start. */
useEffect(() => {
async function asyncEffect () {
const singleView = await isSignleView()
console.log('asyncInitStarted: ', appData.asyncInitStarted)
if (!appData.asyncInitStarted && !singleView) {
try {
// Instantiate the async load object.
const asyncLoad = new AsyncLoad()
appData.setAsyncInitStarted(true)
addToModal('Loading minimal-slp-wallet', appData)
appData.setDenyClose(true)
await asyncLoad.loadWalletLib()
// console.log('Wallet: ', Wallet)
addToModal('Getting alternative servers', appData)
const gistServers = await asyncLoad.getServers()
appData.setServers(gistServers)
// console.log('servers: ', servers)
addToModal('Initializing wallet', appData)
console.log(`Initializing wallet with back end server ${appData.serverUrl}`)
const walletTemp = await asyncLoad.initWallet(appData.serverUrl, appData.lsState.mnemonic, appData)
appData.setWallet(walletTemp)
// appData.updateBchWalletState({ walletObj: walletTemp.walletInfo, appData })
// Get the BCH spot price
addToModal('Getting BCH spot price in USD', appData)
await asyncLoad.getUSDExchangeRate(walletTemp, appData.updateBchWalletState, appData)
// Update state
appData.setShowStartModal(false)
appData.setDenyClose(false)
// Update the startup state.
appData.setAsyncInitFinished(true)
appData.setAsyncInitSucceeded(true)
console.log('App.js useEffect() startup finished successfully')
backgroundAsync(asyncLoad, walletTemp)
// Get the BCH balance of the wallet.
// addToModal('Getting BCH balance', appData)
// Get the SLP tokens held by the wallet.
// addToModal('Getting SLP tokens', appData)
} catch (err) {
const errModalBody = [
`Error: ${err.message}`,
'Try selecting a different back end server using the drop-down menu at the bottom of the app.'
]
appData.setModalBody(errModalBody)
// Update Modal State
appData.setHideSpinner(true)
appData.setShowStartModal(true)
appData.setDenyClose(false)
// Update the startup state.
appData.setAsyncInitFinished(true)
appData.setAsyncInitSucceeded(false)
}
}
}
asyncEffect()
}, [appData, addToModal, isSignleView, backgroundAsync])
return (
<>
<LoadScripts />
<div className='app-container'>
<NavMenu appData={appData} />
{/** Define View to show */}
<div className='main-content'>
{
appData.showStartModal
? (<UninitializedView appData={appData} />)
: (<InitializedView menuState={appData.menuState} appData={appData} />)
}
</div>
<Footer appData={appData} />
</div>
</>
)
}
export default App
+9
View File
@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
it('renders without crashing', () => {
const div = document.createElement('div')
ReactDOM.render(<App />, div)
ReactDOM.unmountComponentAtNode(div)
})
@@ -0,0 +1,101 @@
/*
Account view: show the authenticated user's display name and offer a button
to navigate to the Set Name page.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Button, Spinner } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom'
// Local libraries
import MemoDb from '../../../services/memo-db'
import AccountPage from '../../../services/account-page'
import { truncateAddr } from '../../../util'
function Account (props) {
const { appData } = props
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [name, setName] = useState(null)
const wallet = appData?.wallet
const address = wallet?.walletInfo?.cashAddress || ''
useEffect(() => {
const loadName = async () => {
setLoading(true)
setError(null)
try {
const memoDb = new MemoDb()
const profile = await memoDb.getName(address)
setName(profile?.name || null)
} catch (err) {
setError(err.message || 'Failed to load name')
}
setLoading(false)
}
if (address) {
loadName()
} else {
setLoading(false)
}
}, [address])
const accountPage = new AccountPage({
wallet,
profiles: appData?.profiles,
navigate
})
const displayName = name || accountPage.getName() || truncateAddr(address, 24)
return (
<Container className='account-page mt-4'>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<h1>Account</h1>
{error && <p className='text-danger'>{error}</p>}
{loading && (
<div className='text-center my-5'>
<Spinner animation='border' role='status' variant='primary'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
</div>
)}
{!loading && (
<div className='account-details'>
<p className='account-name'>
<strong>Name: </strong>
{displayName}
</p>
<p className='account-address'>
<strong>Address: </strong>
{address}
</p>
{accountPage.hasSetNameButton() && (
<Button
variant='primary'
onClick={() => accountPage.clickSetName()}
>
Set Name
</Button>
)}
</div>
)}
</Col>
</Row>
</Container>
)
}
export default Account
@@ -0,0 +1,70 @@
/*
Component for looking up the balance of a BCH address.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Form, Button, Spinner } from 'react-bootstrap'
function GetBalance (props) {
const { wallet } = props
// State
const [balance, setBalance] = useState('')
const [textInput, setTextInput] = useState('')
// Button click handler
const handleGetBalance = async (e) => {
e.preventDefault()
try {
// Exit on invalid input
if (!textInput) return
if (!textInput.includes('bitcoincash:')) return
setBalance(
<div className='balance-spinner-container'>
<span>Retrieving balance...</span>
<Spinner animation='border' />
</div>
)
const balance = await wallet.getBalance({ bchAddress: textInput })
console.log('balance: ', balance)
const bchBalance = wallet.bchjs.BitcoinCash.toBitcoinCash(balance)
setBalance(`Balance: ${balance} sats, ${bchBalance} BCH`)
} catch (err) {
setBalance(<p><b>Error</b>: {`${err.message}`}</p>)
}
}
return (
<>
<Container>
<Row>
<Col className='text-break' style={{ textAlign: 'center' }}>
<Form onSubmit={handleGetBalance}>
<Form.Group className='mb-3' controlId='formBasicEmail'>
<Form.Label>Enter any BCH address to query its balance on the blockchain.</Form.Label>
<Form.Control type='text' placeholder='bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' onChange={e => setTextInput(e.target.value)} />
</Form.Group>
<Button variant='primary' onClick={handleGetBalance}>
Check Balance
</Button>
</Form>
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
{balance}
</Col>
</Row>
</Container>
</>
)
}
export default GetBalance
@@ -0,0 +1,86 @@
/*
This card displays the users balance in BCH.
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Row, Col, Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCoins } from '@fortawesome/free-solid-svg-icons'
const BalanceCard = (props) => {
const { appData } = props
const [sats, setSats] = useState('')
const [bchBalance, setbchBalance] = useState('')
const [usdBalance, setusdBalance] = useState('')
const { bchInitLoaded, asyncBackgroundFinished } = appData.asyncBackGroundInitState
// Calculate balances if wallet is successfully loaded!
useEffect(() => {
try {
const bchjs = appData.wallet.bchjs
if (bchjs && appData.asyncInitSucceeded) {
const sats = appData.bchWalletState.bchBalance
const bchBalance = bchjs.BitcoinCash.toBitcoinCash(sats)
const usdBalance = bchjs.Util.floor2(bchBalance * appData.bchWalletState.bchUsdPrice)
setSats(sats)
setbchBalance(bchBalance)
setusdBalance(usdBalance)
}
} catch (error) {
// console.warn(error)
}
}, [appData])
// Background bch data loaded finished
const backgroundDataLoaded = bchInitLoaded || asyncBackgroundFinished
const backgroundDataError = !bchInitLoaded && asyncBackgroundFinished
return (
<>
<Card>
<Card.Body>
<Card.Title style={{ textAlign: 'center' }}>
<h2><FontAwesomeIcon icon={faCoins} size='lg' /> Balance</h2>
</Card.Title>
<br />
{bchInitLoaded && (
<Container>
<Row>
<Col>
<b>USD</b>: ${usdBalance}
</Col>
</Row>
<Row>
<Col>
<b>BCH</b>: {bchBalance}
</Col>
</Row>
<Row>
<Col>
<b>Satoshis</b>: {sats}
</Col>
</Row>
</Container>)}
{backgroundDataError && (
<Container>
<span style={{ color: 'red' }}>Balance could not be loaded!</span>
</Container>
)}
{!backgroundDataLoaded && appData.asyncInitSucceeded && (
<div className='balance-spinner-container'>
<Spinner animation='border' />
</div>
)}
</Card.Body>
</Card>
</>
)
}
export default BalanceCard
@@ -0,0 +1,64 @@
/*
This View allows sending and receiving of BCH
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import RefreshBchBalanceButton from './refresh-bch-balance-button'
import SendCard from './send-card'
import BalanceCard from './balance-card'
import ReceiveCard from './receive-card'
// Working array for storing modal output.
// this.modalBody = []
function BchSend ({ appData }) {
return (
<>
<Container>
<Row>
<Col xs={6}>
<RefreshBchBalanceButton
appData={appData}
/>
</Col>
<Col xs={6} style={{ textAlign: 'right' }}>
<a href='https://youtu.be/KN1ZMWoLoGs' target='_blank' rel='noreferrer'>
<FontAwesomeIcon icon={faCircleQuestion} size='lg' />
</a>
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
<BalanceCard appData={appData} />
</Col>
</Row>
<br />
<Row>
<Col>
<SendCard
appData={appData}
/>
</Col>
</Row>
<br />
<Row>
<Col>
<ReceiveCard appData={appData} />
</Col>
</Row>
</Container>
</>
)
}
export default BchSend
@@ -0,0 +1,93 @@
/*
This card displays the users BCH and SLP address and QR code
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Card, Form } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faWallet } from '@fortawesome/free-solid-svg-icons'
import { QRCodeSVG } from 'qrcode.react'
const ReceiveCard = ({ appData }) => {
const [addrSwitch, setAddrSwitch] = useState(false)
const [displayCopyMsg, setDisplayCopyMsg] = useState(false)
// Determine which address to display
const addrToDisplay = !addrSwitch
? appData.bchWalletState.cashAddress
: appData.bchWalletState.slpAddress
// Copy the selected address to the clipboard when the QR image is clicked
const handleCopyAddress = async (value) => {
appData.appUtil.copyToClipboard(value)
// Display the copied message
setDisplayCopyMsg(true)
// Clear the copied message after some time
setTimeout(() => {
setDisplayCopyMsg(false)
}, 1000)
}
// Event handler for address switch toggle
const handleAddrSwitchToggle = (event) => {
setAddrSwitch(event.target.checked)
}
return (
<>
<Card>
<Card.Body style={{ textAlign: 'center' }}>
<Card.Title>
<h2><FontAwesomeIcon icon={faWallet} size='lg' /> Receive</h2>
</Card.Title>
<br />
<Container>
<Row>
<Col style={{ color: 'green', marginBottom: '20px' }}>
{displayCopyMsg ? 'Copied' : null}
</Col>
</Row>
<Row>
<Col>
<QRCodeSVG
style={{ cursor: 'pointer' }}
className='qr-code'
value={addrToDisplay}
size={256}
fgColor='#333'
onClick={() => { handleCopyAddress(addrToDisplay) }}
/>
</Col>
</Row>
<Row>
<Col style={{ marginTop: '20px' }}>
<p>{addrToDisplay}</p>
</Col>
</Row>
<Row>
<Col xs={4} />
<Col xs={4}>
<Form>
<Form.Check
type='switch'
id='address-switch'
onChange={e => handleAddrSwitchToggle(e)}
/>
</Form>
</Col>
<Col xs={4} />
</Row>
</Container>
</Card.Body>
</Card>
</>
)
}
export default ReceiveCard
@@ -0,0 +1,93 @@
/*
This library exports a RefreshBalance functional Component and a
refreshBalance() function.
The RefreshBalance Component is rendered as a hidden Waiting modal.
When the refreshBalance() function is called, it causes the modal to
appear while the wallet balance is updated. Once updated, the modal is hidden
again.
*/
// Global npm libraries
import React, { useEffect, useState, useCallback } from 'react'
// Local libraries
import WaitingModal from '../../waiting-modal'
export default function RefreshBchBalance (props) {
// Dependency injections of props
const { ref } = props
// State
const [showWaitingModal, setShowWaitingModal] = useState(false)
const [modalBody, setModalBody] = useState([])
const [hideSpinner] = useState(false)
// Add a new line to the waiting modal.
const addToModal = useCallback((inStr) => {
// console.log('addToModal() inStr: ', inStr)
setModalBody(prevBody => {
// console.log('prevBody: ', prevBody)
prevBody.push(inStr)
return prevBody
})
}, [])
// Update the balance of the wallet.
const handleRefreshBalance = useCallback(async (appData) => {
try {
setModalBody([])
// Throw up the waiting modal
setShowWaitingModal(true)
addToModal('Updating wallet balance...')
// Get handles on app data.
const walletState = appData.bchWalletState
const cashAddr = appData.bchWalletState.cashAddress
const wallet = appData.wallet
// Get the latest balance of the wallet.
const newBalance = await wallet.getBalance({ bchAddress: cashAddr })
// if bchInitLoaded is 'false', them set as true , to show the new balance.
appData.updateBackGroundInitState({ bchInitLoaded: true })
addToModal('Updating BCH per USD price...')
const bchUsdPrice = await wallet.getUsd()
// Update the wallet state.
walletState.bchBalance = newBalance
walletState.bchUsdPrice = bchUsdPrice
appData.updateBchWalletState({ walletState, appData })
setShowWaitingModal(false)
setModalBody([])
} catch (err) {
console.error('Error while trying to update BCH balance: ', err)
addToModal([`Error: ${err.message}`])
setShowWaitingModal(false)
}
}, [addToModal])
// add a ref to the handleRefreshBalance function
// This is used to call the function from the parent component.
useEffect(() => {
if (ref && !ref.current) ref.current = { handleRefreshBalance }
}, [ref, handleRefreshBalance])
return (
<>
<>
{showWaitingModal && (
<WaitingModal
heading='Refreshing BCH Balance'
body={modalBody}
hideSpinner={hideSpinner}
/>
)}
</>
</>
)
}
@@ -0,0 +1,44 @@
/*
This component is displayed as a button. When clicked, it loads the
RefreshBchBalance component, which renders a waiting modal while the wallet
balance is refreshed.
*/
// Global npm libraries
import React, { useRef } from 'react'
import { Button } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faRedo } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import RefreshBchBalance from './refresh-balance'
function RefreshBchBalanceButton (props) {
// Dependency injections of props
const appData = props.appData
const { bchInitLoaded, asyncBackgroundFinished } = appData.asyncBackGroundInitState
// Background bch data loaded finished
const backgroundDataLoaded = bchInitLoaded || asyncBackgroundFinished
// Child function references
const refreshBchBalanceRef = useRef()
// Update the balance of the wallet.
async function handleButtonRefreshBalance (appData) {
// Call the child function
refreshBchBalanceRef.current.handleRefreshBalance(appData)
}
return (
<>
<Button variant='success' onClick={() => { handleButtonRefreshBalance(appData) }} disabled={!backgroundDataLoaded}>
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
<RefreshBchBalance appData={appData} ref={refreshBchBalanceRef} />
</>
)
}
export default RefreshBchBalanceButton
@@ -0,0 +1,336 @@
/*
This component controls sending of BCH.
*/
// Global npm libraries
import React, { useState, useRef } from 'react'
import { Container, Row, Col, Card, Form, Button } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPaperPlane, faPaste, faRandom } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import WaitingModal from '../../waiting-modal'
import RefreshBchBalance from './refresh-balance'
function SendCard (props) {
// Dependency injection through props
const appData = props.appData
const { bchInitLoaded, asyncBackgroundFinished } = appData.asyncBackGroundInitState
// Modal State
const [modalBody, setModalBody] = useState([])
const [hideSpinner, setHideSpinner] = useState(false)
const [hideWaitingModal, setHideWaitingModal] = useState(true)
const [hideModal, setHideModal] = useState(true)
// Form State
const [bchAddr, setBchAddr] = useState('')
const [amountStr, setAmountStr] = useState('')
const [amountUnits, setAmountUnits] = useState('USD')
const [oppositeUnits, setOppositeUnits] = useState('BCH')
const [oppositeQty, setOppositeQty] = useState(0)
// Background bch data loaded finished
const backgroundDataLoaded = bchInitLoaded || asyncBackgroundFinished
// Child function references
const refreshBchBalanceRef = useRef()
// Update the balance of the wallet.
async function handleButtonRefreshBalance (appData) {
// Call the child function
refreshBchBalanceRef.current.handleRefreshBalance(appData)
}
// Encapsulate the state for this component into a single object that can
// be passed around to subfunctions and subcomponents.
const sendCardData = {
modalBody,
setModalBody,
hideSpinner,
setHideSpinner,
hideWaitingModal,
setHideWaitingModal,
hideModal,
setHideModal,
bchAddr,
setBchAddr,
amountStr,
setAmountStr,
amountUnits,
setAmountUnits,
oppositeUnits,
setOppositeUnits,
oppositeQty,
setOppositeQty
}
// This function is called when the modal is closed.
function onModalClose () {
sendCardData.setHideModal(true)
handleButtonRefreshBalance(appData)
}
async function pasteFromClipboard () {
try {
const addr = await appData.appUtil.readFromClipboard()
sendCardData.setBchAddr(addr)
} catch (err) {
// Browser implementation. Exit quietly.
}
}
// This is an on-change event handler that updates the amount calculated in
// both BCH and USD as the user types.
function handleUpdateAmount (inObj = {}) {
try {
const { event, appData, sendCardData } = inObj
// Update the state of the text box.
let amountStr = event.target.value
sendCardData.setAmountStr(amountStr)
if (!amountStr) amountStr = '0'
// Convert the string to a number.
const amountQty = parseFloat(amountStr)
const bchUsdPrice = appData.bchWalletState.bchUsdPrice
const bchjs = appData.wallet.bchjs
// Initialize local variables
let oppositeQty = 0
// const amountUsd = 0
// const amountBch = 0
// Calculate the amount in the opposite units.
const currentUnit = sendCardData.amountUnits
if (currentUnit.includes('USD')) {
// Convert USD to BCH
oppositeQty = bchjs.Util.floor8(amountQty / bchUsdPrice)
// amountUsd = amountQty
// amountBch = oppositeQty
} else {
// Convert BCH to USD
oppositeQty = bchjs.Util.floor2(amountQty * bchUsdPrice)
// amountUsd = oppositeQty
// amountBch = amountQty
}
// Update app state
sendCardData.setOppositeQty(oppositeQty)
} catch (err) {
/* exit quietly */
console.log('Error: ', err)
}
}
// This is a click event handler that toggles the units between BCH and USD.
function handleSwitchUnits ({ sendCardData }) {
// Toggle the unit
let newUnit = ''
let oppositeUnits = ''
const oldUnit = sendCardData.amountUnits
if (oldUnit.includes('USD')) {
newUnit = 'BCH'
oppositeUnits = 'USD'
} else {
newUnit = 'USD'
oppositeUnits = 'BCH'
}
// Clear the Amount text box
sendCardData.setAmountStr('')
sendCardData.setOppositeQty(0)
// Persist the new units.
sendCardData.setAmountUnits(newUnit)
sendCardData.setOppositeUnits(oppositeUnits)
}
// Add a new line to the waiting modal.
function addToModal (inStr, sendCardData) {
sendCardData.setModalBody(prevBody => {
prevBody.push(inStr)
return prevBody
})
}
// Send BCH based to the address in the form, and the amount specified in the
// form.
async function handleSendBch ({ sendCardData, appData }) {
console.log('Sending BCH')
try {
// Clear the modal body
sendCardData.setModalBody([])
sendCardData.setHideSpinner(false)
// Open the modal
sendCardData.setHideModal(false)
let amountBch
if (sendCardData.amountUnits === 'USD') {
amountBch = sendCardData.oppositeQty
} else {
amountBch = parseFloat(sendCardData.amountStr)
}
console.log('amountBch: ', amountBch)
if (amountBch < 0.00000546) throw new Error('Trying to send less than dust.')
let bchAddr = sendCardData.bchAddr
let infoStr = `Sending ${amountBch} BCH ($${sendCardData.amountUsd} USD) to ${bchAddr}`
console.log(infoStr)
// Update modal
addToModal('Preparing to send bch...', sendCardData)
const wallet = appData.wallet
const bchjs = wallet.bchjs
// If the address is an SLP address, convert it to a cash address.
if (bchAddr.includes('simpleledger:')) {
bchAddr = bchjs.SLP.Address.toCashAddress(bchAddr)
}
// Convert the BCH to satoshis
const sats = bchjs.BitcoinCash.toSatoshi(amountBch)
// Update the wallets UTXOs
infoStr = 'Updating UTXOs...'
console.log(infoStr)
addToModal(infoStr, sendCardData)
await wallet.getUtxos()
const receivers = [{
address: bchAddr,
amountSat: sats
}]
const txid = await wallet.send(receivers)
// Display TXID
infoStr = `txid: ${txid}`
// console.log(infoStr)
// modalBody.push(infoStr)
addToModal(infoStr, sendCardData)
// Link to block explorer
const explorerUrl = `https://blockchair.com/bitcoin-cash/transaction/${txid}`
const explorerLink = (<a href={`${explorerUrl}`} target='_blank' rel='noreferrer'>Block Explorer</a>)
// modalBody.push(explorerLink)
addToModal(explorerLink, sendCardData)
sendCardData.setHideSpinner(true)
sendCardData.setBchAddr('')
sendCardData.setAmountStr('')
} catch (err) {
console.log('Error in handleSendBch(): ', err)
sendCardData.setModalBody([`Error: ${err.message}`])
sendCardData.setHideSpinner(true)
}
}
return (
<>
{
hideModal
? null
: (<WaitingModal
heading='Sending BCH'
body={modalBody}
hideSpinner={hideSpinner}
closeFunc={onModalClose}
closeModalData={{ appData, sendCardData }}
/>)
}
<RefreshBchBalance appData={appData} ref={refreshBchBalanceRef} />
<Card>
<Card.Body>
<Card.Title style={{ textAlign: 'center' }}>
<h2><FontAwesomeIcon icon={faPaperPlane} size='lg' /> Send</h2>
</Card.Title>
<br />
<Container>
<Row>
<Col style={{ textAlign: 'center' }}>
<b>BCH Address:</b>
</Col>
</Row>
<Row>
<Col xs={12} style={{ textAlign: 'center' }}>
<Form onSubmit={(e) => e.preventDefault()}>
<Form.Group controlId='formBasicEmail' style={{ display: 'flex', alignItems: 'center' }}>
<Form.Control
style={{ marginRight: '1rem' }}
type='text'
placeholder='bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
onChange={e => setBchAddr(e.target.value)}
value={bchAddr}
/>
<FontAwesomeIcon
style={{ cursor: 'pointer' }}
icon={faPaste}
size='lg'
onClick={(e) => pasteFromClipboard()}
/>
</Form.Group>
</Form>
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
<b>Amount:</b>
</Col>
</Row>
<Row>
<Col xs={12}>
<Form style={{ paddingBottom: '10px' }} onSubmit={(e) => { e.preventDefault(); handleSendBch({ sendCardData, appData }) }}>
<Form.Group controlId='formBasicEmail' style={{ textAlign: 'center' }}>
<Form.Control
type='number'
onChange={(event) => handleUpdateAmount({ event, appData, sendCardData })}
value={amountStr}
/>
</Form.Group>
</Form>
</Col>
</Row>
<Row>
<Col xs={6}>
<strong>Units</strong> : {amountUnits}
<FontAwesomeIcon
style={{ cursor: 'pointer', marginLeft: '5px' }}
icon={faRandom}
size='lg'
onClick={(e) => handleSwitchUnits({ sendCardData, appData })}
/>
</Col>
<Col xs={6} style={{ textAlign: 'right' }}>
<strong>{oppositeUnits}</strong> : {oppositeQty}
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
<Button onClick={(e) => handleSendBch({ sendCardData, appData })} disabled={!backgroundDataLoaded}>Send</Button>
</Col>
</Row>
</Container>
</Card.Body>
</Card>
</>
)
}
export default SendCard
@@ -0,0 +1,59 @@
/*
This Card component is used to clear the Local Storage and reset the wallet.
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col, Card, Button } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
const WalletClear = (props) => {
const { removeLocalStorageItem } = props.appData
// Delete wallet data from Local Storage and reload the app.
const handleClearLocalStorage = () => {
console.log('Deleting wallet and reloading page.')
// Delete the mnemonic from Local Storage
removeLocalStorageItem('mnemonic')
// Reload the app.
window.location.reload()
}
return (
<>
<Container>
<Row>
<Col>
<Card>
<Card.Body>
<Card.Title style={{ textAlign: 'center' }}>
<h2>
<FontAwesomeIcon icon={faTriangleExclamation} />{' '}
<span>Clear Local Storage</span>
</h2>
</Card.Title>
<Card.Text style={{ textAlign: 'center' }}>
Clicking the button below will clear the Local Storage, which
will reload the app with a newly created wallet.
<br />
<b>
Be sure to write down your 12-word mnemonic to back
up your wallet before clicking the button!
</b>.
<br /><br />
<Button variant='danger' onClick={handleClearLocalStorage}>
Delete Wallet
</Button>
</Card.Text>
</Card.Body>
</Card>
</Col>
</Row>
</Container>
</>
)
}
export default WalletClear
@@ -0,0 +1,55 @@
/*
This component is visually represented with a copy icon. A wallet property
is passed as a prop. When clicked, the wallet property is copied to the
system clipboard.
*/
// Global npm libraries
import React, { useCallback, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCopy } from '@fortawesome/free-solid-svg-icons'
const CopyOnClick = (props) => {
// State
const [iconVis, setIconVis] = useState(true)
// Props
const { appData, walletProp, value } = props
// App Util
const { appUtil } = appData
// Function to copy the value to the clipboard.
const handleCopyToClipboard = useCallback(async (event) => {
appUtil.copyToClipboard(value)
// hide icon in order to show the copied message
setIconVis(false)
// restart icon visibility after 1 second
setTimeout(function () {
setIconVis(true)
}, 1000)
}, [value, appUtil])
return (
<>
{iconVis && (
<FontAwesomeIcon
icon={faCopy} size='lg'
id={`${walletProp}-icon`}
onClick={(e) => handleCopyToClipboard(e)}
style={{ cursor: 'pointer' }}
/>
)}
{!iconVis && (
<span
id={`${walletProp}-word`}
style={{ color: 'green' }}
>
Copied!
</span>
)}
</>
)
}
export default CopyOnClick
@@ -0,0 +1,111 @@
/*
This component allows the user to import a new wallet using a 12-word mnemonic.
*/
// Global npm libraries
import React, { useCallback } from 'react'
import { Container, Row, Col, Card, Button, Form } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faFileExport, faPaste } from '@fortawesome/free-solid-svg-icons'
// import { Clipboard } from '@capacitor/clipboard'
const WalletImport = (props) => {
const [newMnemonic, setNewMnemonic] = React.useState('')
const { appData } = props
// Load mnemonic from clipboard
const pasteFromClipboard = () => appData.appUtil.pasteFromClipboard(setNewMnemonic)
// Handle input change for mnemonic
const handleImportMnemonic = async (event) => {
const inputStr = event.target.value
const formattedInput = inputStr.toLowerCase()
setNewMnemonic(formattedInput)
}
// Ensure the mnemonic is valid. If it is, then replace the current mnemonic
// in LocalStorage and reload the page.
const handleImportWallet = useCallback(async (event) => {
try {
const mnemonic = newMnemonic
const wallet = appData.wallet
const bchjs = wallet.bchjs
// Verify the mnemonic is valid.
const isValid = bchjs.Mnemonic.validate(mnemonic, bchjs.Mnemonic.wordLists().english)
if (isValid.includes('is not in wordlist')) {
console.log('Mnemonic is NOT valid')
} else {
console.log('Mnemonic is valid')
}
// Replace the old mnemonic in LocalStorage with the new one.
appData.updateLocalStorage({ mnemonic })
// Reload the app.
window.location.reload()
} catch (error) {
console.warn('Error importing wallet: ', error)
}
}, [newMnemonic, appData])
return (
<>
<Container>
<Row>
<Col>
<Card>
<Card.Body>
<Card.Title style={{ textAlign: 'center' }}>
<h2>
<FontAwesomeIcon icon={faFileExport} />{' '}
<span>Import Wallet</span>
</h2>
</Card.Title>
<Card.Text style={{ textAlign: 'center' }}>
Enter a 12 word mnemonic below to import your wallet into
this app. The app will reload and use the new mnemonic.
</Card.Text>
<Container>
<Row>
<Col xs={12} className='text-break' style={{ textAlign: 'center' }}>
<Form onSubmit={(e) => e.preventDefault()}>
<Form.Group className='mb-3' controlId='formImportWallet' style={{ display: 'flex', alignItems: 'center' }}>
<Form.Control
style={{ margin: '1rem' }}
type='text'
value={newMnemonic}
onChange={handleImportMnemonic}
/>
<FontAwesomeIcon
icon={faPaste}
size='lg'
onClick={(e) => pasteFromClipboard(e)}
style={{ cursor: 'pointer' }}
/>
</Form.Group>
</Form>
</Col>
</Row>
<Row>
<Col style={{ textAlign: 'center' }}>
<Button variant='primary' onClick={handleImportWallet}>
Import
</Button>
</Col>
</Row>
<br />
</Container>
</Card.Body>
</Card>
</Col>
</Row>
</Container>
</>
)
}
export default WalletImport
@@ -0,0 +1,47 @@
/*
This component controlls the Wallet View.
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
// Local Libraries
import WebWalletWarning from './warning'
import WalletSummary from './wallet-summary'
import WalletClear from './clear-wallet'
import WalletImport from './import-wallet'
import OptimizeWallet from './optimize-wallet'
function BchWallet (props) {
// Dependency injection through props
const appData = props.appData
console.log('appData: ', appData)
return (
<>
<Container>
<Row>
<Col style={{ textAlign: 'right' }}>
<a href='https://youtu.be/0R00cppN0fA' target='_blank' rel='noreferrer'>
<FontAwesomeIcon icon={faCircleQuestion} size='lg' />
</a>
</Col>
</Row>
</Container>
<WebWalletWarning />
<br />
<WalletSummary appData={appData} />
<br />
<WalletClear appData={appData} />
<br />
<WalletImport appData={appData} />
<br />
<OptimizeWallet appData={appData} />
</>
)
}
export default BchWallet
@@ -0,0 +1,113 @@
/*
This component allows the user to optimize their wallet by consolidating
UTXOs. This speeds up all the network calls and results in an improved UX.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Card, Button } from 'react-bootstrap'
// Local libraries
import WaitingModal from '../../waiting-modal'
function OptimizeWallet (props) {
// State
const [showModal, setShowModal] = useState(false)
const [modalBody, setModalBody] = useState([])
const [hideSpinner, setHideSpinner] = useState(false)
const [denyClose, setDenyClose] = useState(false)
// Get props values
const { wallet } = props.appData
// Optimize wallet
const handleOptimize = async () => {
console.log('Optimize Wallet button clicked.')
// Show waiting modal
setShowModal(true)
setModalBody(['Optimizing wallet...'])
setDenyClose(true)
// Optimize wallet
await wallet.optimize()
// Show success modal
setShowModal(true)
setModalBody(['Your wallet has been optimized!'])
setDenyClose(false)
setHideSpinner(true)
try {
// Get all UTXOs in the wallet
const utxos = wallet.utxos.utxoStore
console.log('utxos: ', utxos)
// Add up all the UTXOs
const bchUtxoCnt = utxos.bchUtxos.length
let fungibleUtxoCnt = utxos.slpUtxos.type1.tokens.length
if (!fungibleUtxoCnt) fungibleUtxoCnt = 0
let nftUtxoCnt = utxos.slpUtxos.nft.length
if (!nftUtxoCnt) nftUtxoCnt = 0
const totalUtxos = bchUtxoCnt + fungibleUtxoCnt + nftUtxoCnt
console.log(`bchUtxoCnt: ${bchUtxoCnt}, fungibleUtxoCnt: ${fungibleUtxoCnt}, nftUtxoCnt: ${nftUtxoCnt}`)
console.log(`total UTXO count: ${totalUtxos}`)
if (totalUtxos > 10) {
const newModalBody = [
'Your wallet has been optimized!',
'Your wallet still has more than 10 UTXOs. Increased numbers of UTXOs slow down performance. If you have several tokens in your wallet, it is recommended that you store them in a paper wallet. Here is a video explaining how to do that:'
]
newModalBody.push(<a href='https://youtu.be/mRniqpgWdjg' target='_blank' rel='noreferrer'>Video: How to Store SLP Tokens on a Paper Wallet</a>)
newModalBody.push(<a href='https://paperwallet.fullstack.cash/' target='_blank' rel='noreferrer'>Generate a Paper Wallet</a>)
setModalBody(newModalBody)
}
} catch (err) {
console.log('Error while trying to count total number of UTXOs: ', err)
}
}
return (
<>
<Container>
<Row>
<Col>
<Card>
<Card.Body>
<Card.Title style={{ textAlign: 'center' }}>
<h2>Optimize Wallet</h2>
</Card.Title>
<Card.Text style={{ textAlign: 'center' }}>
Clicking the button below will optimize your wallet and make it
function faster.
<br /><br />
<i>How it works</i>: By consolidating
as many UTXOs in your wallet as possible, it reduces the total
number of UTXOs in your wallet. Fewer UTXOs in your wallet make
all network calls faster, and results in an improved user experience.
<br /><br />
<Button variant='primary' onClick={handleOptimize}>
Optimize Wallet
</Button>
</Card.Text>
</Card.Body>
</Card>
</Col>
</Row>
</Container>
{showModal && (
<WaitingModal
heading='Optimizing Wallet'
body={modalBody}
hideSpinner={hideSpinner}
denyClose={denyClose}
/>
)}
</>
)
}
export default OptimizeWallet
@@ -0,0 +1,4 @@
.blurred {
filter: blur(6px);
-webkit-filter: blur(6px);
}
@@ -0,0 +1,150 @@
/*
This component displays a summary of the wallet.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Card } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faWallet, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'
// import { Clipboard } from '@capacitor/clipboard'
// Local libraries
import './wallet-summary.css'
import CopyOnClick from './copy-on-click'
function WalletSummary (props) {
// Props
const appData = props.appData
const bchWalletState = appData.bchWalletState
console.log('wallet summary state: ', bchWalletState)
// State
const [blurredMnemonic, setBlurredMnemonic] = useState(true)
const [blurredPrivateKey, setBlurredPrivateKey] = useState(true)
// Encapsulate component state into an object that can be passed to child functions
const walletSummaryData = {
blurredMnemonic,
setBlurredMnemonic,
blurredPrivateKey,
setBlurredPrivateKey
}
// Eye icon state
const eyeIcon = {
mnemonic: blurredMnemonic ? faEyeSlash : faEye,
privateKey: blurredPrivateKey ? faEyeSlash : faEye
}
// Toggle the state of blurring for a field (mnemonic or private key)
const toggleBlur = (field) => {
try {
const blurredState = walletSummaryData[field]
const setterName = `set${field[0].toUpperCase()}${field.slice(1)}`
walletSummaryData[setterName](!blurredState)
} catch (error) {
console.error(`Error toggling ${field} blur: `, error)
}
}
return (
<>
<Container>
<Row>
<Col>
<Card>
<Card.Body>
<Card.Title style={{ textAlign: 'center' }}>
<h2>
<FontAwesomeIcon icon={faWallet} />{' '}
<span>My Wallet</span>
</h2>
</Card.Title>
<Container>
<Row style={{ padding: '25px' }}>
<Col xs={12} sm={10} lg={8} style={{ padding: '10px' }}>
<b>Mnemonic:</b> <span className={blurredMnemonic ? 'blurred' : null}>{bchWalletState.mnemonic}</span>
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
<FontAwesomeIcon
style={{ cursor: 'pointer' }}
icon={eyeIcon.mnemonic}
size='lg'
onClick={() => toggleBlur('blurredMnemonic')}
/>
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
<CopyOnClick walletProp='mnemonic' appData={appData} value={bchWalletState.mnemonic} />
</Col>
</Row>
<Row style={{ padding: '25px', backgroundColor: '#eee' }}>
<Col xs={12} sm={10} lg={8} style={{ padding: '10px' }}>
<b>Private Key:</b> <span className={blurredPrivateKey ? 'blurred' : null}>{bchWalletState.privateKey}</span>
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
<FontAwesomeIcon
style={{ cursor: 'pointer' }}
icon={eyeIcon.privateKey}
size='lg'
onClick={() => toggleBlur('blurredPrivateKey')}
/>
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
<CopyOnClick walletProp='privateKey' appData={appData} value={bchWalletState.privateKey} />
</Col>
</Row>
<Row style={{ padding: '25px' }}>
<Col xs={12} sm={10} lg={8} style={{ padding: '10px' }}>
<b>Cash Address:</b> {bchWalletState.cashAddress}
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }} />
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
<CopyOnClick walletProp='cashAddress' appData={appData} value={bchWalletState.cashAddress} />
</Col>
</Row>
<Row style={{ padding: '25px', backgroundColor: '#eee' }}>
<Col xs={12} sm={10} lg={8} style={{ padding: '10px' }}>
<b>SLP Address:</b> {bchWalletState.slpAddress}
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }} />
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
<CopyOnClick walletProp='slpAddress' appData={appData} value={bchWalletState.slpAddress} />
</Col>
</Row>
<Row style={{ padding: '25px' }}>
<Col xs={12} sm={10} lg={8} style={{ padding: '10px' }}>
<b>Legacy Address:</b> {bchWalletState.legacyAddress}
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }} />
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
<CopyOnClick walletProp='legacyAddress' appData={appData} value={bchWalletState.legacyAddress} />
</Col>
</Row>
<Row style={{ padding: '25px', backgroundColor: '#eee' }}>
<Col xs={10} sm={10} lg={8} style={{ padding: '10px' }}>
<b>HD Path:</b> {bchWalletState.hdPath}
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }} />
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
<CopyOnClick walletProp='hdPath' appData={appData} value={bchWalletState.hdPath} />
</Col>
</Row>
</Container>
</Card.Body>
</Card>
</Col>
</Row>
</Container>
</>
)
}
export default WalletSummary
@@ -0,0 +1,49 @@
/*
This component is a visual warning against storing large sums of money in
a web wallet.
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col, Card } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
const WebWalletWarning = () => {
return (
<>
<Container>
<Row>
<Col>
<Card>
<Card.Body>
<Card.Title style={{ textAlign: 'center' }}>
<h2>
<FontAwesomeIcon icon={faTriangleExclamation} />{' '}
<span>Web Wallets are Insecure</span>
</h2>
</Card.Title>
<Card.Text style={{ textAlign: 'center' }}>
This is an open source, non-custodial web wallet
supporting Bitcoin Cash (BCH) and SLP tokens.
It is optimized for convenience and not security.
<br />
<b>Do not store large amounts of money on a web wallet.
</b>
<br /><br />
Note: Scammers frequently copy this open source code to build
apps for stealing people's money. Be sure you trust the source
serving you this app.
</Card.Text>
</Card.Body>
</Card>
</Col>
</Row>
</Container>
</>
)
}
export default WebWalletWarning
@@ -0,0 +1,20 @@
/*
This component is a View that allows the user to handle configuration
settings for the app.
*/
// Global npm libraries
import React from 'react'
import ServerSelectView from './select-server-view'
function ConfigurationView (props) {
const { appData } = props
return (
<>
<ServerSelectView appData={appData} />
</>
)
}
export default ConfigurationView
@@ -0,0 +1,47 @@
/*
This component contains a drop-down form that lets the user select from
a range of Global Back End servers.
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col, Button } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom'
const ServerSelect = (props) => {
const { linkTo, appData } = props
// Use the navigate function to navigate to the servers view
const navigate = useNavigate()
// This is a click handler for the server select button. It brings up the
// server selection View.
const handleServerSelect = () => {
console.log('This function should navigate to the server selection view.')
navigate(linkTo)
}
return (
<Container>
<>
<hr />
<Row>
<Col style={{ textAlign: 'center', padding: '25px' }}>
<br />
<h5>
Having trouble loading? Try selecting a different back-end server.
</h5>
<p style={{ fontStyle: 'italic' }}>Current Server : {appData.serverUrl}</p>
<Button variant='warning' onClick={handleServerSelect}>
Select a different back end server
</Button>
<br />
</Col>
</Row>
</>
</Container>
)
}
export default ServerSelect
@@ -0,0 +1,90 @@
/*
This component is a View that allows the user to select a back end server
from a list of servers.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Row, Col, Form, Card } from 'react-bootstrap'
function ServerSelectView (props) {
const { appData } = props
const [selectedServer, setSelectedServer] = useState(appData.serverUrl)
const servers = appData.servers
// Update server when dropdown selection changes
const handleServerChange = (event) => {
setSelectedServer(event.target.value)
}
const onSaveServer = (serverUrl) => {
console.log('server target: ', serverUrl)
appData.updateLocalStorage({ serverUrl })
window.location.href = '/'
}
return (
<>
<Card className='m-3'>
<Row className='mb-3 mt-3 mx-3'>
<Col className='text-end'>
<button
className='btn btn-primary'
style={{ minWidth: '100px' }}
onClick={() => onSaveServer(selectedServer)}
>
Save
</button>
</Col>
</Row>
<Card.Body>
<Row>
<Col style={{ textAlign: 'center' }}>
<h2>Configuration</h2>
<p>
This page allows you to change configuration settings for different
back end services. This page is for advanced users only.
</p>
</Col>
</Row>
<hr />
<Row className='justify-content-center'>
<Col xs={12} md={6}>
<p>
Select an alternative server below. The app will reload and use
the selected server.
</p>
<Form.Select
value={selectedServer}
onChange={handleServerChange}
className='mb-3'
>
{servers.map((server, i) => (
<option key={`server-${i}`} value={server.value}>
{server.value === appData.serverUrl ? `${server.label} (current)` : server.label}
</option>
))}
</Form.Select>
</Col>
</Row>
<Row className='justify-content-center mt-3'>
<Col xs={12} md={6} className='text-center'>
<button
className='btn btn-primary'
style={{ minWidth: '100px' }}
onClick={() => onSaveServer(selectedServer)}
>
Save
</button>
</Col>
</Row>
</Card.Body>
</Card>
</>
)
}
export default ServerSelectView
@@ -0,0 +1,65 @@
/*
This Body component is a container for all the different Views of the app.
Views are equivalent to 'pages' in a multi-page app. Views are hidden or
displayed to simulate the use of pages in an SPA.
The Body app contains all the Views and chooses which to show, based on
the state of the Menu component.
*/
// Global npm libraries
import React from 'react'
import { Route, Routes } from 'react-router-dom'
// Local libraries
import GetBalance from './balance'
import Wallet from './bch-wallet'
import Placeholder2 from './placeholder2'
import Placeholder3 from './placeholder3'
// import ServerSelectView from './servers/select-server-view'
// import SelectServerButton from './servers/select-server-button'
import BchSend from './bch-send'
import SlpTokens from './slp-tokens'
import SweepWif from './sweep/index.js'
import SignMessage from './sign/index.js'
import ServerSelectView from './configuration/select-server-view'
import UserDataReview from './user-data-review'
import RecentProfiles from './recent-profiles'
import RecentPosts from './posts'
import NewPost from './new-post'
import Profile from './profile'
import SetName from './set-name'
import Account from './account'
function AppBody (props) {
// Dependency injection through props
const appData = props.appData
return (
<>
<Routes>
<Route path='/' element={<RecentPosts appData={appData} />} />
<Route path='/balance' element={<GetBalance wallet={appData.wallet} />} />
<Route path='/bch' element={<BchSend appData={appData} />} />
<Route path='/wallet' element={<Wallet appData={appData} />} />
<Route path='/slp-tokens' element={<SlpTokens appData={appData} />} />
<Route path='/profile/recent' element={<RecentProfiles />} />
<Route path='/profile/:addr' element={<Profile />} />
<Route path='/posts/recent' element={<RecentPosts appData={appData} />} />
<Route path='/posts/new' element={<NewPost appData={appData} />} />
<Route path='/memo/set-name' element={<SetName appData={appData} />} />
<Route path='/account' element={<Account appData={appData} />} />
<Route path='/placeholder2' element={<Placeholder2 />} />
<Route path='/placeholder3' element={<Placeholder3 />} />
<Route path='/servers' element={<ServerSelectView appData={appData} />} />
<Route path='/sweep' element={<SweepWif appData={appData} />} />
<Route path='/sign' element={<SignMessage appData={appData} />} />
<Route path='/configuration' element={<ServerSelectView appData={appData} />} />
<Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} />
</Routes>
{/** Show in all paths except the servers view */}
{/* {appData.currentPath !== '/servers' && <SelectServerButton linkTo='/servers' appData={appData} />} */}
</>
)
}
export default AppBody
@@ -0,0 +1,94 @@
/*
New Post view: compose and broadcast a Memo post, with a character counter
that counts down from the memo limit. On success the user is navigated to the
recent feed.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Form, Button } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom'
// Local libraries
import MemoPost from '../../../services/memo-post'
import NewPostPage from '../../../services/new-post'
function NewPost (props) {
const { appData } = props
const navigate = useNavigate()
const maxChars = MemoPost.MAX_MEMO_CHARS
const [input, setInput] = useState('')
const [err, setErr] = useState('')
const [posting, setPosting] = useState(false)
const remaining = maxChars - input.length
async function handleSubmit (event) {
event.preventDefault()
setErr('')
setPosting(true)
try {
const memoPost = new MemoPost({ wallet: appData?.wallet })
const page = new NewPostPage({ memoPost, navigate })
page.setInput(input)
const result = await page.submit()
if (!result.ok) {
if (result.error === 'memo_length') {
setErr(`Memo is too long. Maximum is ${maxChars} characters.`)
} else if (result.error === 'memo_validation') {
setErr('Memo must not be empty.')
} else if (result.message) {
setErr(`Failed to broadcast: ${result.message}`)
} else {
setErr('Failed to post memo.')
}
}
// On success page.submit() navigated to the recent feed.
} catch (submitErr) {
setErr(submitErr.message)
} finally {
setPosting(false)
}
}
return (
<Container>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='new-post-heading'>
<h1>New Post</h1>
<p>Compose a Memo message and publish it to Bitcoin Cash.</p>
</header>
<Form onSubmit={handleSubmit}>
<Form.Group controlId='new-post-message' className='mb-3'>
<Form.Label><b>Message</b></Form.Label>
<Form.Control
as='textarea'
rows={6}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder='Write your Memo here...'
/>
</Form.Group>
<p className='new-post-counter'>
{remaining} characters remaining
</p>
{err && <p className='new-post-error'>{err}</p>}
<Button type='submit' variant='primary' disabled={posting}>
{posting ? 'Posting...' : 'Post'}
</Button>
</Form>
</Col>
</Row>
</Container>
)
}
export default NewPost
@@ -0,0 +1,22 @@
/*
A placeholder view used for unreviewed routes.
*/
// Global npm libraries
import React, { useEffect } from 'react'
function PlaceholderView (props) {
const { viewNumber } = props
useEffect(() => {
console.log(`Placeholder ${viewNumber} loaded.`)
}, [viewNumber])
return (
<>
<p style={{ padding: '25px' }}>This is placeholder View #{viewNumber}</p>
</>
)
}
export default PlaceholderView
@@ -0,0 +1,12 @@
/*
This is a placeholder View
*/
// Local libraries
import PlaceholderView from './placeholder-view'
function Placeholder2 (props) {
return <PlaceholderView viewNumber={2} />
}
export default Placeholder2
@@ -0,0 +1,12 @@
/*
This is a placeholder View
*/
// Local libraries
import PlaceholderView from './placeholder-view'
function Placeholder3 (props) {
return <PlaceholderView viewNumber={3} />
}
export default Placeholder3
@@ -0,0 +1,175 @@
/*
Display the most recent Memo posts from psf-memo-db.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Spinner, Button } from 'react-bootstrap'
// Local libraries
import MemoDb from '../../../services/memo-db'
import PostFeedItem from '../../post-feed/post-feed-item'
import PostThreadModal from '../../post-thread-modal'
import {
collectPostAddrs,
loadThreadProfiles
} from '../../post-thread-modal/thread-profiles'
import '../../../App.css'
import '../../post-feed/post-feed.css'
const PAGE_SIZE = 100
function RecentPosts (props) {
const { appData } = props
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [posts, setPosts] = useState([])
const [profiles, setProfiles] = useState({})
const [pagination, setPagination] = useState(null)
const [offset, setOffset] = useState(0)
const [threadTxid, setThreadTxid] = useState(null)
const [showThreadModal, setShowThreadModal] = useState(false)
const openThread = (txid) => {
setThreadTxid(txid)
setShowThreadModal(true)
}
const closeThread = () => {
setShowThreadModal(false)
setThreadTxid(null)
}
useEffect(() => {
const loadPosts = async () => {
setLoading(true)
setError(null)
setProfiles({})
try {
const memoDb = new MemoDb()
const data = await memoDb.getRecentPosts({
limit: PAGE_SIZE,
offset
})
const loadedPosts = data.posts || []
const addrs = collectPostAddrs(loadedPosts)
const profileMap = await loadThreadProfiles(addrs, memoDb)
setPosts(loadedPosts)
setProfiles(profileMap)
setPagination(data.pagination || null)
} catch (err) {
setError(err.message || 'Failed to load recent posts')
setPosts([])
setProfiles({})
setPagination(null)
}
setLoading(false)
}
loadPosts()
}, [offset])
const canGoBack = offset > 0
const canGoNext = pagination?.hasMore ?? false
const handlePrevious = () => {
setOffset((prev) => Math.max(0, prev - PAGE_SIZE))
}
const handleNext = () => {
setOffset((prev) => prev + PAGE_SIZE)
}
return (
<Container className='recent-posts-page'>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='recent-posts-heading'>
<h1>BCH Memo Posts</h1>
<p>
Recent messages published through the Memo protocol on Bitcoin Cash.
</p>
{pagination && posts.length > 0 && (
<span className='recent-posts-count'>
Showing {pagination.offset + 1}
{pagination.offset + posts.length} of {pagination.total}
</span>
)}
{pagination && posts.length === 0 && (
<span className='recent-posts-count'>
No posts on this page.
</span>
)}
</header>
{error && (
<p className='recent-posts-error'>
{error}
</p>
)}
{loading && (
<div className='text-center my-5'>
<Spinner animation='border' role='status'>
<span className='visually-hidden'>
Loading...
</span>
</Spinner>
</div>
)}
{!loading && !error && posts.length > 0 && (
<div className='posts-feed'>
{posts.map((post) => (
<PostFeedItem
key={post.txid}
post={post}
profiles={profiles}
wallet={appData?.wallet}
onReplyClick={() => openThread(post.txid)}
showFooterMeta
/>
))}
</div>
)}
{!loading && !error && (pagination || offset > 0) && (
<div className='recent-posts-pagination'>
<Button
variant='outline-dark'
onClick={handlePrevious}
disabled={!canGoBack}
>
Previous
</Button>
<Button
variant='outline-dark'
onClick={handleNext}
disabled={!canGoNext}
>
Next
</Button>
</div>
)}
</Col>
</Row>
<PostThreadModal
show={showThreadModal}
txid={threadTxid}
onHide={closeThread}
wallet={appData?.wallet}
profiles={profiles}
/>
</Container>
)
}
export default RecentPosts
@@ -0,0 +1,179 @@
/*
Display a Memo user profile: avatar, bio, and posts.
*/
import React, { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { Container, Row, Col, Spinner, Card } from 'react-bootstrap'
import Jdenticon from '@chris.troutner/react-jdenticon'
import MemoDb from '../../../services/memo-db'
import PostReplyCount from '../../post-reply-count'
import PostThreadModal from '../../post-thread-modal'
import '../../../App.css'
import './profile.css'
function formatSeen (seen) {
if (!seen) return ''
const ms = seen > 1e12 ? seen : seen * 1000
return new Date(ms).toLocaleString()
}
function ProfileAvatar ({ addr, profilePicUrl }) {
const [picError, setPicError] = useState(false)
useEffect(() => {
setPicError(false)
}, [profilePicUrl, addr])
if (profilePicUrl && !picError) {
return (
<img
src={profilePicUrl}
alt='Profile'
className='profile-avatar'
onError={() => setPicError(true)}
/>
)
}
return (
<div className='profile-avatar profile-avatar-jdenticon'>
<Jdenticon size='120' value={addr} />
</div>
)
}
function Profile (props) {
const { appData } = props
const { addr: encodedAddr } = useParams()
const addr = decodeURIComponent(encodedAddr || '')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [profileText, setProfileText] = useState('')
const [profilePicUrl, setProfilePicUrl] = useState(null)
const [posts, setPosts] = useState([])
const [pagination, setPagination] = useState(null)
const [threadTxid, setThreadTxid] = useState(null)
const [showThreadModal, setShowThreadModal] = useState(false)
const [profiles, setProfiles] = useState({})
const openThread = (txid) => {
setThreadTxid(txid)
setShowThreadModal(true)
}
const closeThread = () => {
setShowThreadModal(false)
setThreadTxid(null)
}
useEffect(() => {
const loadProfile = async () => {
setLoading(true)
setError(null)
try {
const memoDb = new MemoDb()
const [profile, profilePic, postsData] = await Promise.all([
memoDb.getProfile(addr),
memoDb.getProfilePic(addr),
memoDb.getPostsByAddr(addr, { limit: 100, offset: 0 })
])
setProfileText(profile?.text || '')
setProfilePicUrl(profilePic?.url || null)
setPosts(postsData.posts || [])
setPagination(postsData.pagination || null)
setProfiles({}) // Future: load profile names for the post list.
} catch (err) {
setError(err.message || 'Failed to load profile')
}
setLoading(false)
}
if (addr) {
loadProfile()
} else {
setError('Missing profile address')
setLoading(false)
}
}, [addr])
return (
<Container fluid className='profile-page mt-4'>
{error && <p className='text-danger'>{error}</p>}
{loading && (
<div className='text-center my-5'>
<Spinner animation='border' role='status' variant='primary'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
</div>
)}
{!loading && !error && (
<Row>
<Col lg={3} md={4} className='profile-sidebar mb-4'>
<ProfileAvatar addr={addr} profilePicUrl={profilePicUrl} />
{profileText && (
<p className='profile-bio mt-3'>{profileText}</p>
)}
{!profileText && (
<p className='profile-bio profile-bio-empty mt-3 text-muted'>
No profile text
</p>
)}
<div className='profile-address mt-3'>
<span className='profile-address-label'>BCH</span>
<span className='profile-address-value' title={addr}>{addr}</span>
</div>
</Col>
<Col lg={9} md={8} className='profile-posts'>
<div className='profile-posts-header mb-3'>
<h2 className='profile-posts-title'>Posts</h2>
{pagination && (
<span className='text-muted'>
{pagination.total} post{pagination.total === 1 ? '' : 's'}
</span>
)}
</div>
{posts.length === 0 && (
<p className='text-muted'>No posts for this address.</p>
)}
{posts.map((post) => (
<Card key={post.txid} className='profile-post-card mb-3'>
<Card.Body>
<div className='profile-post-meta text-muted mb-2'>
<span>{formatSeen(post.seen)}</span>
<span className='profile-post-block ms-2'>Block {post.blockHeight}</span>
</div>
<Card.Text className='profile-post-text'>{post.text}</Card.Text>
<PostReplyCount
count={post.replyCount ?? 0}
onClick={() => openThread(post.txid)}
/>
</Card.Body>
</Card>
))}
</Col>
</Row>
)}
<PostThreadModal
show={showThreadModal}
txid={threadTxid}
onHide={closeThread}
wallet={appData?.wallet}
profiles={profiles}
/>
</Container>
)
}
export default Profile
@@ -0,0 +1,87 @@
.profile-page {
max-width: 1200px;
}
.profile-sidebar {
border-right: 1px solid #dee2e6;
padding-right: 1.5rem;
}
.profile-avatar {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 4px;
display: block;
}
.profile-avatar-jdenticon {
overflow: hidden;
border-radius: 4px;
}
.profile-bio {
white-space: pre-wrap;
word-break: break-word;
font-size: 0.95rem;
line-height: 1.5;
}
.profile-bio-empty {
font-style: italic;
}
.profile-address {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.profile-address-label {
color: #28a745;
font-weight: 600;
font-size: 0.85rem;
}
.profile-address-value {
font-family: monospace;
font-size: 0.8rem;
word-break: break-all;
}
.profile-posts-header {
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 2px solid #28a745;
padding-bottom: 0.5rem;
}
.profile-posts-title {
font-size: 1.25rem;
margin: 0;
color: #28a745;
}
.profile-post-card {
border: 1px solid #dee2e6;
}
.profile-post-text {
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 0;
}
.profile-post-meta {
font-size: 0.85rem;
}
@media (max-width: 767px) {
.profile-sidebar {
border-right: none;
border-bottom: 1px solid #dee2e6;
padding-right: 0;
padding-bottom: 1.5rem;
}
}
@@ -0,0 +1,111 @@
/*
Display the most recent Memo profiles from psf-memo-db.
*/
import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Container, Row, Col, Spinner, Table } from 'react-bootstrap'
// Local libraries
import MemoDb from '../../../services/memo-db'
import AppUtil, { truncateAddr, truncateTxid } from '../../../util'
import '../../../App.css'
const appUtil = new AppUtil()
function formatSeen (seen) {
if (!seen) return ''
const ms = seen > 1e12 ? seen : seen * 1000
return new Date(ms).toLocaleString()
}
function RecentProfiles () {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [profiles, setProfiles] = useState([])
const [pagination, setPagination] = useState(null)
useEffect(() => {
const loadProfiles = async () => {
try {
const memoDb = new MemoDb()
const data = await memoDb.getRecentProfiles({ limit: 100, offset: 0 })
setProfiles(data.profiles || [])
setPagination(data.pagination || null)
} catch (err) {
setError(err.message || 'Failed to load recent profiles')
}
setLoading(false)
}
loadProfiles()
}, [])
return (
<Container>
<Row>
<Col>
<h1 className='mt-4'>Recent Profiles</h1>
{pagination && (
<p className='text-muted'>
Showing {profiles.length} of {pagination.total} profiles
</p>
)}
{error && <p className='text-danger'>{error}</p>}
{loading && (
<div className='text-center my-5'>
<Spinner animation='border' role='status' variant='primary'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
</div>
)}
{!loading && !error && (
<Table striped bordered hover responsive className='mt-3'>
<thead>
<tr>
<th>Address</th>
<th>Bio</th>
<th>Block</th>
<th>Seen</th>
<th>TXID</th>
</tr>
</thead>
<tbody>
{profiles.map((profile) => (
<tr key={`${profile.addr}-${profile.txid}`}>
<td>
<Link
to={`/profile/${encodeURIComponent(profile.addr)}`}
style={{ fontFamily: 'monospace' }}
title={profile.addr}
>
{truncateAddr(profile.addr, 24)}
</Link>
</td>
<td>{profile.text}</td>
<td>{profile.blockHeight}</td>
<td>{formatSeen(profile.seen)}</td>
<td>
<span
style={{ fontFamily: 'monospace', cursor: 'pointer' }}
title={profile.txid}
onClick={() => appUtil.copyToClipboard(profile.txid)}
>
{truncateTxid(profile.txid, 20)}
</span>
</td>
</tr>
))}
</tbody>
</Table>
)}
</Col>
</Row>
</Container>
)
}
export default RecentProfiles
@@ -0,0 +1,94 @@
/*
Set Name view: compose and broadcast a Memo display name, with a byte counter
that counts down from the name limit. On success the user is navigated to
the account page.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Form, Button } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom'
// Local libraries
import MemoSetName from '../../../services/memo-set-name'
import SetNamePage from '../../../services/set-name-page'
import { byteLength } from '../../../services/utf8'
function SetName (props) {
const { appData } = props
const navigate = useNavigate()
const maxBytes = MemoSetName.MAX_NAME_BYTES
const [input, setInput] = useState('')
const [err, setErr] = useState('')
const [settingName, setSettingName] = useState(false)
const remaining = maxBytes - byteLength(input)
async function handleSubmit (event) {
event.preventDefault()
setErr('')
setSettingName(true)
try {
const memoSetName = new MemoSetName({ wallet: appData?.wallet, profiles: appData?.profiles })
const page = new SetNamePage({ memoSetName, navigate })
page.setInput(input)
const result = await page.submit()
if (!result.ok) {
if (result.error === 'name_length') {
setErr(`Name is too long. Maximum is ${maxBytes} bytes.`)
} else if (result.error === 'name_validation') {
setErr('Name must not be empty.')
} else if (result.message) {
setErr(`Failed to broadcast: ${result.message}`)
} else {
setErr('Failed to set name.')
}
}
// On success page.submit() navigated to the account page.
} catch (submitErr) {
setErr(submitErr.message)
} finally {
setSettingName(false)
}
}
return (
<Container>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='set-name-heading'>
<h1>Set Name</h1>
<p>Choose a display name and publish it to Bitcoin Cash.</p>
</header>
<Form onSubmit={handleSubmit}>
<Form.Group controlId='set-name-input' className='mb-3'>
<Form.Label><b>Name</b></Form.Label>
<Form.Control
type='text'
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder='Enter your display name...'
/>
</Form.Group>
<p className='set-name-counter'>
{remaining} bytes remaining
</p>
{err && <p className='set-name-error'>{err}</p>}
<Button type='submit' variant='primary' disabled={settingName}>
{settingName ? 'Setting Name...' : 'Set Name'}
</Button>
</Form>
</Col>
</Row>
</Container>
)
}
export default SetName
@@ -0,0 +1,120 @@
/*
Component for signing a message with a WIF private key.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Form, Button } from 'react-bootstrap'
import { faCopy } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
function SignMessage (props) {
// Convert class state to hooks
const { wallet, appUtil } = props.appData
const [sign, setSign] = useState('')
const [msg, setMsg] = useState('')
const [bchAddr] = useState(wallet?.walletInfo?.cashAddress)
const [slpAddr] = useState(wallet?.walletInfo?.slpAddress)
const [err, setErr] = useState('')
const [copied, setCopied] = useState(false)
const handleSignMessage = (event) => {
try {
event.preventDefault()
if (!msg) throw new Error('Enter a message to sign.')
const bchjs = wallet.bchjs
const wif = props.appData.wallet.walletInfo.privateKey
const sig = bchjs.BitcoinCash.signMessageWithPrivKey(wif, msg)
setSign(sig)
setErr('')
} catch (err) {
console.log('Error in handleSignMessage(): ', err)
setErr(err.message)
setSign('')
}
}
// Function to copy the value to the clipboard.
const handleCopyToClipboard = async (value) => {
appUtil.copyToClipboard(value)
// show the copied message
setCopied(true)
// hide copied message after 1 second
setTimeout(function () {
setCopied(false)
}, 1000)
}
const copyIcon = (value) => {
return <FontAwesomeIcon icon={faCopy} size='lg' onClick={() => handleCopyToClipboard(value)} style={{ cursor: 'pointer', marginLeft: '10px' }} />
}
return (
<>
<Container>
<Row>
<Col>
<p>
This view allows you cryptographically sign a message with your
wallet. These signatures are used in a wide range of applications,
such as gaining access to
the <a href='https://t.me/psf_vip' target='_blank' rel='noreferrer'>PSF VIP Telegram channel</a>.
</p>
<p>
Enter any message into the form below and click the button. This
view will generate a cryptographic signature.
</p>
</Col>
</Row>
<Row>
<Col className='text-break'>
<Form onSubmit={handleSignMessage}>
<Form.Group className='mb-3' controlId='message'>
<Form.Label><b>Enter a message to sign.</b></Form.Label>
<Form.Control type='text' placeholder='' onChange={e => setMsg(e.target.value)} />
</Form.Group>
{err && <p style={{ color: 'red', marginBottom: '10px' }}>{`Error: ${err}`}</p>}
<Button variant='primary' onClick={handleSignMessage}>
Sign Message
</Button>
</Form>
</Col>
</Row>
<br />
{sign && (
<div style={{ textAlign: 'center' }}>
<Row>
<Col>
<p>
<b>Signature:</b> {sign} {copyIcon(sign)}
</p>
<p>
<b>BCH Address:</b> {bchAddr} {copyIcon(bchAddr)}
</p>
<p>
<b>SLP Address:</b> {slpAddr} {copyIcon(slpAddr)}
</p>
</Col>
</Row>
{copied && (
<span style={{ color: 'green' }}>
Copied!
</span>
)}
</div>
)}
</Container>
</>
)
}
export default SignMessage
@@ -0,0 +1,238 @@
/*
This is the 'Token View'. It displays the SLP tokens in the wallet.
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import TokenCard from './token-card'
import RefreshTokenBalance from './refresh-tokens'
const SlpTokens = (props) => {
const { appData } = props
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
const [dataAreLoaded, setDataAreLoaded] = useState(false)
const [tokens, setTokens] = useState([])
const refreshTokenButtonRef = React.useRef()
const { slpInitLoaded, asyncBackgroundFinished } = props.appData.asyncBackGroundInitState
// Background bch data loaded finished
const backgroundDataLoaded = slpInitLoaded || asyncBackgroundFinished
const backgroundDataError = !slpInitLoaded && asyncBackgroundFinished
// Update the tokens state when the appData changes
useEffect(() => {
setTokens(appData.bchWalletState.slpTokens)
}, [appData])
// This function is triggered when the token balance needs to be refreshed
// from the blockchain.
// This needs to happen after sending a token, to reflect the changed balance
// within the wallet app.
// This function triggers the on-click function within the refresh-tokens.js button.
const refreshTokens = async () => {
await refreshTokenButtonRef.current.handleRefreshTokenBalance()
}
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
// This function loads the token data .
const lazyLoadTokenData = useCallback(async (tokens) => {
try {
setDataAreLoaded(false)
// map each token and fetch the token data
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// data does not need to be downloaded, so continue with the next one
if (thisToken.dataAlreadyDownloaded) continue
// Try to get token data.
const tokenData = await appData.wallet.getTokenData(thisToken.tokenId)
console.log('tokenData', tokenData)
if (tokenData) {
// Set data to the token object , this can be used to display the token name in the token card component.
thisToken.tokenData = tokenData
}
// Mark token to prevent fetch token data again.
thisToken.dataAlreadyDownloaded = true
}
setDataAreLoaded(true)
} catch (error) {
setDataAreLoaded(true)
}
}, [appData])
// Fetch mutable data if it exist and get the token icon url
const fetchTokenMutableData = useCallback(async (token) => {
try {
// Get the token data
const tokenData = token.tokenData
if (!tokenData.mutableData) return false // Return false if no mutable data
// Get the token icon from the mutable data
const cid = parseCid(tokenData.mutableData)
console.log('mutable data cid', cid)
const { json } = await appData.wallet.cid2json({ cid })
console.log('json: ', json)
if (!json) return false
let iconUrl = json.tokenIcon
if (json.fullSizedUrl && json.fullSizedUrl.includes('http')) {
iconUrl = json.fullSizedUrl
}
const userData = json.userData
// Return icon url
return { iconUrl, userData }
} catch (error) {
return false
}
}, [appData])
// This function loads the token icons from the ipfs gateways.
const lazyLoadMutableData = useCallback(async (tokens) => {
try {
setIconsAreLoaded(false)
// map each token and fetch the icon url
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// Incon does not need to be downloaded, so continue with the next one
if (thisToken.iconAlreadyDownloaded) continue
// Try to get token icon url from mutable data.
const { iconUrl, userData } = await fetchTokenMutableData(thisToken)
console.log('iconUrl', iconUrl)
if (iconUrl) {
// Set the icon url to the token , this can be used to display the icon in the token card component.
thisToken.icon = iconUrl
thisToken.tokenData.userData = userData
}
// Mark token to prevent fetch token icon again.
thisToken.iconAlreadyDownloaded = true
}
setIconsAreLoaded(true)
} catch (error) {
setIconsAreLoaded(true)
}
}, [fetchTokenMutableData])
const loadData = useCallback(async () => {
const tokens = appData.bchWalletState.slpTokens
console.log('tokens', tokens)
setTokens(tokens)
await lazyLoadTokenData(tokens)
await lazyLoadMutableData(tokens)
}, [appData, lazyLoadTokenData, lazyLoadMutableData])
// Start to load the token icons when the component is mounted
useEffect(() => {
if (slpInitLoaded) {
loadData()
}
}, [loadData, slpInitLoaded])
// Generate the token cards for each token in the wallet.
const generateCards = () => {
const tokens = appData.bchWalletState.slpTokens
return tokens.map(thisToken => (
<TokenCard
appData={appData}
token={thisToken}
key={`${thisToken.tokenId}`}
refreshTokens={refreshTokens}
/>
))
}
return (
<>
<Container>
<Row>
<Col xs={6}>
<RefreshTokenBalance
appData={appData}
ref={refreshTokenButtonRef}
lazyLoadTokenIcons={loadData}
/>
</Col>
<Col xs={6} style={{ textAlign: 'right' }}>
<a href='https://youtu.be/f1no5-QHTr4' target='_blank' rel='noreferrer'>
<FontAwesomeIcon icon={faCircleQuestion} size='lg' />
</a>
</Col>
</Row>
<Row>
{appData.asyncInitSucceeded && (
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{/** Show spinner info if tokens are loaded but data is not loaded */
!backgroundDataLoaded && !backgroundDataError && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Tokens </span>
<Spinner animation='border' />
</div>
)
}
{/** Show spinner info if tokens are loaded but data is not loaded */
!backgroundDataError && !dataAreLoaded && tokens.length > 0 && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Data </span>
<Spinner animation='border' />
</div>
)
}
{/** Show spinner info if tokens are loaded but icons are not loaded */
backgroundDataLoaded && dataAreLoaded && !iconsAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Icons </span>
<Spinner animation='border' />
</div>
)
}
</Col>
)}
</Row>
<br />
<Row>
{generateCards()}
</Row>
{/** Display a message if no tokens are found */}
{backgroundDataLoaded && !backgroundDataError && tokens.length === 0 && (
<Row className='text-center'>
<span> No tokens found in wallet </span>
</Row>
)}
{backgroundDataError && (
<Row style={{ color: 'red' }} className='text-center'>
<span>Tokens could not be loaded! </span>
</Row>
)}
</Container>
</>
)
}
export default SlpTokens
@@ -0,0 +1,145 @@
/*
This component renders as a button. When clicked, it opens a modal that
displays information about the token.
This is a functional component with as little state as possible.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Button, Modal, Container, Row, Col, Spinner } from 'react-bootstrap'
// Takes a string as input. If it matches a pattern for a link, a JSX object is
// returned with a link. Otherwise the original string is returned.
function linkIfUrl (url) {
// Convert the URL into a link if it contains 'http'
if (url.includes('http')) {
url = (<a href={url} target='_blank' rel='noreferrer'>{url}</a>)
//
} else if (url.includes('ipfs://')) {
// Convert to a Filecoin link if its an IPFS reference.
const cid = url.substring(7)
url = (<a href={`https://${cid}.ipfs.dweb.link/data.json`} target='_blank' rel='noreferrer'>{url}</a>)
}
return url
}
function InfoButton (props) {
const [show, setShow] = useState(false)
const [mutableDataCid, setMutableDataCid] = useState(null)
const handleClose = () => {
setShow(false)
// props.instance.setState({ showModal: false })
}
const handleOpen = () => {
setShow(true)
}
// Convert the url property of the token to a link, if it matches common patterns.
let url = props.token.url
url = linkIfUrl(props.token.url)
// console.log('props.token: ', props.token)
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
// Get token user data if it exists and verify if it contains media or markdown
useEffect(() => {
try {
console.log('props token', props.token)
const userDataStr = props.token.tokenData.userData
if (userDataStr) {
const userData = JSON.parse(userDataStr)
// If user data contains media or markdown, set the mutable data cid
if (userData?.media || userData?.markdown) {
setMutableDataCid(parseCid(props.token.tokenData.mutableData))
}
}
} catch (error) {
// Do nothing
}
}, [props.token, show])
return (
<>
<Button variant='info' onClick={handleOpen}>Info</Button>
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>Token Information</Modal.Title>
</Modal.Header>
<Modal.Body>
<Container>
<Row>
<Col xs={4}><b>Ticker</b>:</Col>
<Col xs={8}>{props.token.ticker}</Col>
</Row>
<Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Name</b>:</Col>
<Col xs={8}>{props.token.name}</Col>
</Row>
<Row>
<Col xs={4}><b>Token ID</b>:</Col>
<Col xs={8} style={{ wordBreak: 'break-all' }}>
<a href={`https://token.fullstack.cash/?tokenid=${props.token.tokenId}`} target='_blank' rel='noreferrer'>
{props.token.tokenId}
</a>
</Col>
</Row>
<Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Decimals</b>:</Col>
<Col xs={8}>{props.token.decimals}</Col>
</Row>
<Row>
<Col xs={4}><b>Token Type</b>:</Col>
<Col xs={8}>{props.token.tokenType}</Col>
</Row>
<Row style={{ backgroundColor: '#eee', wordBreak: 'break-all' }}>
<Col xs={4}><b>URL</b>:</Col>
<Col xs={8}>{url}</Col>
</Row>
{!props.token.iconAlreadyDownloaded && (
<div className='text-center'>
<Spinner animation='border' size='sm' />
</div>
)}
{props.token.iconAlreadyDownloaded && mutableDataCid && (
<Row style={{ paddingTop: '10px' }}>
<Col xs={4}><b>User Data</b>:</Col>
<Col xs={8}>
<Button
href={`/user-data/${props.token.tokenId}#single-view`}
target='_blank'
rel='noopener noreferrer'
>
View User Data
</Button>
</Col>
</Row>
)}
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
</>
)
}
export default InfoButton
@@ -0,0 +1,108 @@
/*
This component is displayed as a button. When clicked, it displays a modal
with a spinny gif, while the wallets SLP token list is updated from the
blockchain and psf-slp-indexer.
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
import { Button } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faRedo } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import WaitingModal from '../../waiting-modal'
function RefreshTokenBalance ({ appData: initialAppData, ref, lazyLoadTokenIcons }) {
const [appData, setAppData] = useState(initialAppData)
const [modalBody, setModalBody] = useState([])
const [hideSpinner, setHideSpinner] = useState(false)
const [hideWaitingModal, setHideWaitingModal] = useState(true)
const { slpInitLoaded, asyncBackgroundFinished } = initialAppData.asyncBackGroundInitState
// Background bch data loaded finished
const backgroundDataLoaded = slpInitLoaded || asyncBackgroundFinished
// Add a new line to the waiting modal.
const addToModal = (inStr) => {
setModalBody(prevBody => [...prevBody, inStr])
}
// Update the balance of the wallet.
const handleRefreshTokenBalance = useCallback(async () => {
try {
// Throw up the waiting modal
setHideWaitingModal(false)
addToModal('Updating token balance...')
// Get handles on app data.
const walletState = appData.bchWalletState
const wallet = appData.wallet
// Update the wallet UTXOs
await wallet.initialize()
const tokenList = await wallet.listTokens()
// Copy tokens from old token state.
for (let i = 0; i < tokenList.length; i++) {
const thisToken = tokenList[i]
// Look through the existing wallet state for the matching token.
const existingToken = walletState.slpTokens.filter(x => x.tokenId === thisToken.tokenId)
// If the current wallet state has an icon, copy it over.
if (existingToken[0] && existingToken[0].icon) {
thisToken.icon = existingToken[0].icon
}
}
// Update the wallet state.
walletState.slpTokens = tokenList
appData.updateBchWalletState({ walletObj: walletState, appData })
const newAppData = { ...appData, bchWalletState: walletState }
// if slpInitLoaded is 'false', them set as true , to show the new balance.
appData.updateBackGroundInitState({ slpInitLoaded: true })
// Update state
setHideWaitingModal(true)
setAppData(newAppData)
setModalBody([])
// Lazy load icons for any new tokens.
await lazyLoadTokenIcons()
return newAppData
} catch (err) {
console.error('Error while trying to update BCH balance: ', err)
setModalBody([`Error: ${err.message}`])
setHideSpinner(true)
}
}, [appData, lazyLoadTokenIcons])
// add a ref to the handleRefreshBalance function
// This is used to call the function from the parent component.
useEffect(() => {
if (ref && !ref.current) ref.current = { handleRefreshTokenBalance }
}, [ref, handleRefreshTokenBalance])
return (
<>
<Button variant='success' onClick={handleRefreshTokenBalance} disabled={!backgroundDataLoaded}>
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
{!hideWaitingModal && (
<WaitingModal
heading='Refreshing Token List'
body={modalBody}
hideSpinner={hideSpinner}
/>
)}
</>
)
}
export default RefreshTokenBalance
@@ -0,0 +1,223 @@
/*
This component renders as a button. When clicked, it opens up a modal
for sending a quantity of tokens.
This component requires state, because it's a complex form that is being manipulated
by the user.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Button, Modal, Container, Row, Col, Form, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPaperPlane, faPaste } from '@fortawesome/free-solid-svg-icons'
function SendTokenButton ({ token, appData, refreshTokens }) {
// Convert class state to useState hooks
const [showAddrWarning, setShowAddrWarning] = useState(false)
const [showModal, setShowModal] = useState(false)
const [statusMsg, setStatusMsg] = useState('')
const [hideSpinner, setHideSpinner] = useState(true)
const [shouldRefreshOnModalClose, setShouldRefreshOnModalClose] = useState(false)
const [sendToAddress, setSendToAddress] = useState('')
const [sendQtyStr, setSendQtyStr] = useState('')
const [dialogFinished, setDialogFinished] = useState(true)
// Handler functions
const handleShowModal = () => setShowModal(true)
const handleCloseModal = async () => {
if (!dialogFinished) return
if (shouldRefreshOnModalClose) {
setShowModal(false)
setShouldRefreshOnModalClose(false)
setStatusMsg('')
await refreshTokens()
} else {
setShowModal(false)
setStatusMsg('')
setSendToAddress('')
setSendQtyStr('')
}
}
const handleUpdateSendToAddr = (event) => {
const value = event.target.value
setSendToAddress(value)
setShowAddrWarning(value.includes('bitcoincash'))
}
const handleGetMax = () => {
setSendQtyStr(token.qty)
}
// Click handler that fires when the user clicks the 'Send' button.
const handleSendTokens = async (e) => {
e.preventDefault()
try {
setStatusMsg('Preparing to send tokens...')
setHideSpinner(false)
setDialogFinished(false)
setShowAddrWarning(false)
// Validate the quantity
const qty = parseFloat(sendQtyStr)
if (isNaN(qty)) throw new Error('Invalid send quantity')
const wallet = appData.wallet
const bchjs = wallet.bchjs
// Validate the address
let addr = sendToAddress
if (addr.includes('simpleledger')) {
addr = bchjs.SLP.Address.toCashAddress(addr)
}
if (!addr.includes('bitcoincash')) throw new Error('Invalid address')
let infoStr = 'Updating UTXOs...'
setStatusMsg(infoStr)
await wallet.getUtxos()
const receiver = [{
address: addr,
tokenId: token.tokenId,
qty
}]
infoStr = 'Generating and broadcasting transaction...'
setStatusMsg(infoStr)
const txid = await wallet.sendTokens(receiver, 3)
console.log(`Token sent. TXID: ${txid}`)
setStatusMsg(<p>Success! <a href={`https://token.fullstack.cash/transactions/?txid=${txid}`} target='_blank' rel='noreferrer'>See on Block Explorer</a></p>)
setHideSpinner(true)
setSendQtyStr('')
setSendToAddress('')
setShouldRefreshOnModalClose(true)
setDialogFinished(true)
} catch (err) {
console.error('Error in handleSendTokens(): ', err)
setStatusMsg(`Error sending tokens: ${err.message}`)
setHideSpinner(true)
setDialogFinished(true)
}
}
// Load address from clipboard
const pasteFromClipboard = () => appData.appUtil.pasteFromClipboard(setSendToAddress)
// Modal JSX
const getModal = () => {
return (
<Modal show={showModal} size='lg' onHide={handleCloseModal}>
<Modal.Header closeButton>
<Modal.Title><FontAwesomeIcon icon={faPaperPlane} size='lg' /> Send Tokens: <span style={{ color: 'red' }}>{token.ticker}</span></Modal.Title>
</Modal.Header>
<Modal.Body>
<Container>
{/* ... existing Modal.Body content ... */}
<Row>
<Col style={{ textAlign: 'center' }}>
<b>SLP Address:</b>
</Col>
</Row>
<Row>
<Col xs={10}>
<Form onSubmit={(e) => e.preventDefault()}>
<Form.Group controlId='formBasicEmail' style={{ textAlign: 'center' }}>
<Form.Control
type='text'
placeholder='simpleledger:qqlrzp23w08434twmvr4fxw672whkjy0pyxpgpyg0n'
onChange={handleUpdateSendToAddr}
value={sendToAddress}
/>
</Form.Group>
</Form>
</Col>
<Col xs={2}>
<FontAwesomeIcon
style={{ cursor: 'pointer' }}
icon={faPaste}
size='lg'
onClick={pasteFromClipboard}
/>
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
<b>Amount:</b>
</Col>
</Row>
<Row>
<Col xs={10}>
<Form style={{ paddingBottom: '10px' }} onSubmit={handleSendTokens}>
<Form.Group controlId='formBasicEmail' style={{ textAlign: 'center' }}>
<Form.Control
type='text'
onChange={e => setSendQtyStr(e.target.value)}
value={sendQtyStr}
/>
</Form.Group>
</Form>
</Col>
<Col xs={2}>
<Button onClick={handleGetMax}>Max</Button>
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
<Button onClick={handleSendTokens}>Send</Button>
</Col>
</Row>
<br />
{showAddrWarning && (
<>
<Row>
<Col style={{ textAlign: 'center' }}>
<p style={{ color: 'orange' }}>
<b>Warning</b>: Careful! Not all Bitcoin Cash wallets are token-aware.
If you send this token to a wallet that is not
token-aware, it could be burned. It's best practice to
only send tokens to 'simpleledger' addresses and not
'bitcoincash' addresses.
</p>
</Col>
</Row>
<br />
</>
)}
<Row>
<Col xs={10}>
{statusMsg}
</Col>
<Col xs={2}>
{!hideSpinner && <Spinner animation='border' />}
</Col>
</Row>
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
)
}
return (
<>
<Button variant='info' onClick={handleShowModal}>Send</Button>
{showModal && getModal()}
</>
)
}
export default SendTokenButton
@@ -0,0 +1,83 @@
/*
This Card component summarizes an SLP token.
if a token icon does not exist or cant be loaded , then display a default icon from Jdenticon library.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Card } from 'react-bootstrap'
import Jdenticon from '@chris.troutner/react-jdenticon'
// Local libraries
import InfoButton from './info-button'
import SendTokenButton from './send-token-button'
function TokenCard (props) {
const { token } = props
const [icon, setIcon] = useState(token.icon)
// Update icon state every token.icon changes
useEffect(() => {
setIcon(token.icon)
}, [token.icon])
return (
<>
<Col xs={12} sm={6} lg={4} style={{ padding: '25px' }}>
<Card>
<Card.Body style={{ textAlign: 'center' }}>
{/** If the icon is loaded, display it */
icon && (
<Card.Img
src={icon}
style={{ height: '100px', width: 'auto' }}
onError={(e) => {
setIcon(null) // Set the icon to null if it fails to load the image url.
}}
/>
)
}
{/** If the icon is not loaded, display the Jdenticon */
!icon && (
<Jdenticon size='100' value={token.tokenId} />
)
}
<Card.Title style={{ textAlign: 'center' }}>
<h4>{props.token.ticker}</h4>
</Card.Title>
<Container>
<Row>
<Col>
{props.token.name}
</Col>
</Row>
<br />
<Row>
<Col>Balance:</Col>
<Col>{props.token.qty}</Col>
</Row>
<br />
<Row>
<Col>
<InfoButton token={props.token} />
</Col>
<Col>
<SendTokenButton
token={props.token}
appData={props.appData}
refreshTokens={props.refreshTokens}
/>
</Col>
</Row>
</Container>
</Card.Body>
</Card>
</Col>
</>
)
}
export default TokenCard
@@ -0,0 +1,188 @@
/*
This Sweep component allows users to sweep a private key and transfer any
BCH or SLP tokens into their wallet.
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col, Form, Button, Modal, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
import Sweeper from 'bch-token-sweep'
// let _this
const SweepWif = (props) => {
const { appData } = props
console.log('appData', appData)
const [wifToSweep, setWifToSweep] = React.useState('')
const [showModal, setShowModal] = React.useState(false)
const [statusMsg, setStatusMsg] = React.useState('')
const [hideSpinner, setHideSpinner] = React.useState(false)
// shouldRefreshOnModalClose: false
// Helper function to validate WIF
const validateWIF = (WIF) => {
if (typeof WIF !== 'string') return false
if (WIF.length !== 52) return false
if (WIF[0] !== 'L' && WIF[0] !== 'K') return false
return true
}
// Update wallet state function
const updateWalletState = async () => {
const wallet = appData.wallet
const bchBalance = await wallet.getBalance({ bchAddress: wallet.walletInfo.cashAddress })
await wallet.initialize()
const slpTokens = await wallet.listTokens(wallet.walletInfo.cashAddress)
appData.updateBchWalletState({ walletObj: { bchBalance, slpTokens }, appData })
}
// Handle sweep function
const handleSweep = async (e) => {
e.preventDefault()
try {
console.log(`Sweeping this WIF: ${wifToSweep}`)
// Set modal initial state
setShowModal(true)
setHideSpinner(false)
setStatusMsg('')
// Input validation
const isWIF = validateWIF(wifToSweep)
if (!isWIF) {
setHideSpinner(true)
setStatusMsg(<b style={{ color: 'red' }}>Input is not a WIF private key.</b>)
return
}
try {
const walletWif = appData.wallet.walletInfo.privateKey
const toAddr = appData.wallet.slpAddress
// Instance the Sweep library
const sweep = new Sweeper(wifToSweep, walletWif, appData.wallet)
await sweep.populateObjectFromNetwork()
// Constructing the sweep transaction
const hex = await sweep.sweepTo(toAddr)
const txid = await appData.wallet.ar.sendTx(hex)
// Generate status message
const newStatusMsg = (
<>
<p>Sweep succeeded!</p>
<p>Transaction ID: {txid}</p>
<p>
<a href={`https://blockchair.com/bitcoin-cash/transaction/${txid}`} target='_blank' rel='noreferrer'>
TX on Blockchair BCH Block Explorer
</a>
</p>
<p>
<a href={`https://token.fullstack.cash/transactions/?txid=${txid}`} target='_blank' rel='noreferrer'>
TX on token explorer
</a>
</p>
</>
)
setHideSpinner(true)
setStatusMsg(newStatusMsg)
setWifToSweep('')
await updateWalletState()
} catch (err) {
setHideSpinner(true)
setStatusMsg(<b style={{ color: 'red' }}>{`Error: ${err.message}`}</b>)
}
} catch (err) {
console.error('Error in handleSweep(): ', err)
}
}
// Modal component
const getModal = () => (
<Modal show={showModal} size='lg' onHide={() => setShowModal(false)}>
<Modal.Header closeButton>
<Modal.Title>Sweeping...</Modal.Title>
</Modal.Header>
<Modal.Body>
<Container>
<Row>
{!hideSpinner && (
<Col style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<span style={{ marginRight: '10px' }}>Sweeping private key...</span> <Spinner animation='border' />
</Col>
)}
</Row>
<br />
{statusMsg && (
<Row>
<Col style={{ textAlign: 'center' }}>{statusMsg}</Col>
</Row>
)}
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
)
return (
<>
<Container>
<Row>
<Col style={{ textAlign: 'right' }}>
<a href='https://youtu.be/QW9xixHaEJE' target='_blank' rel='noreferrer'>
<FontAwesomeIcon icon={faCircleQuestion} size='lg' />
</a>
</Col>
</Row>
<Row>
<Col>
<p>
This View is used to 'sweep' a private key. This will transfer
any BCH or SLP tokens from a paper wallet to your web wallet.
Paper wallets are used to store BCH and tokens. You
can <a href='https://paperwallet.fullstack.cash/' target='_blank' rel='noreferrer'>generate paper wallets here</a>.
</p>
<p>
Paste the private key of a paper wallet below and click the button
to sweep the funds. The private key must be in WIF format. It will
start with the letter 'K' or 'L'.
</p>
</Col>
</Row>
<Row>
<Col>
<Form onSubmit={handleSweep}>
<Form.Group controlId='formWif' style={{ textAlign: 'center' }}>
<Form.Control
type='text'
placeholder='KzJqZxi5XSo36woCy7MFVNRPDpfp8x8FpkhRvrErKBBrDXRVY9Ft'
onChange={(e) => setWifToSweep(e.target.value)}
value={wifToSweep}
/>
</Form.Group>
</Form>
</Col>
</Row>
<br />
<Row style={{ textAlign: 'center' }}>
<Col>
<Button variant='info' onClick={handleSweep}>
Sweep
</Button>
</Col>
</Row>
</Container>
{showModal && getModal()}
</>
)
}
export default SweepWif
@@ -0,0 +1,127 @@
/*
Search in the provided cid of a mutable data, get the data from the user data and show it
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { Container, Row, Col, Spinner, Carousel } from 'react-bootstrap'
import ReactMarkdown from 'react-markdown'
import '../../App.css'
function UserDataReview (props) {
const appData = props.appData
const [media, setMedia] = useState([])
const [markdown, setMarkdown] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [tokenData, setTokenData] = useState(null)
// Get the id parameter from the URL
const { tokenId } = useParams()
useEffect(() => {
const loadData = async () => {
try {
const tokenData = await appData.wallet.getTokenData(tokenId)
setTokenData(tokenData)
setLoading(true)
if (!tokenData.mutableData) throw new Error('Mutable data not found')
const cid = parseCid(tokenData.mutableData)
const { json } = await appData.wallet.cid2json({ cid })
const userDataString = json.userData
if (userDataString) {
const userData = JSON.parse(userDataString)
setMedia(userData.media)
setMarkdown(userData.markdown)
}
} catch (error) {
setError(error.message)
}
setLoading(false)
}
loadData()
}, [tokenId, appData.wallet])
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
return (
<Container>
<Row>
<Col>
{/** Error message */}
{error && <p className='text-danger'>{error}</p>}
{/** Loading spinner */}
{loading &&
(
<div className='text-center my-5'>
<Spinner animation='border' role='status' variant='primary'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
</div>
)}
{/** User data */}
{!loading && !error && (
<div className='text-center'>
{/** Name */}
<h1>{tokenData.genesisData.name}</h1>
{/** Media Content Carousel */}
{media && media.length > 0
? (
<div className='my-5 '>
<Carousel>
{media.map((item, index) => (
<Carousel.Item key={index}>
<img
className='d-block w-100'
src={item.url}
alt={`Review media ${index + 1}`}
style={{ height: '400px', objectFit: 'contain' }}
/>
<Carousel.Caption>
<p>Image {index + 1} of {media.length}</p>
</Carousel.Caption>
</Carousel.Item>
))}
</Carousel>
<style>{'.carousel-control-next,.carousel-control-prev, .carousel-indicators {filter: invert(100%); } '}
</style>
</div>
)
: (
<p className='mt-3'>No media content available</p>
)}
{/** Markdown Content */}
{markdown
? (
<div className='markdown-content my-5'>
<ReactMarkdown>{markdown}</ReactMarkdown>
</div>
)
: (
<p className='mt-3'>No content available</p>
)}
</div>
)}
</Col>
</Row>
</Container>
)
}
export default UserDataReview
@@ -0,0 +1,70 @@
/*
This service file contains functions for retrieving an IPFS hash from the
BCH blockchain, in a fasion similar to PS001:
https://github.com/Permissionless-Software-Foundation/specifications/blob/master/ps001-media-sharing.md
*/
// Global npm libraries
import BchMessage from 'bch-message-lib'
// Local libraries
import AppUtil from '../../util'
class Memo {
constructor (config) {
this.config = config
// Encapsulate dependencies
this.util = new AppUtil()
}
// Instantiate the bch-message-lib library.
async initialize (wallet) {
try {
// Throw an error if this class is instantiated without passing a BCH address.
if (!this.config || !this.config.bchAddr) {
throw new Error('Must pass a BCH address to Memo constructor.')
} else {
this.bchAddr = this.config.bchAddr
}
this.wallet = wallet
this.bchMessage = new BchMessage({ wallet })
} catch (err) {
console.error('Error in get-cid.js/initialize(): ', err.message)
// console.log('Waiting 5 seconds before trying again.')
// await this.util.sleep(5000)
// this.initialize()
}
}
// Walk the transactions associated with an address until a proper IPFS hash is
// found. If one is not found, will return false.
async findHash () {
try {
console.log(`Finding latest IPFS hash for address: ${this.bchAddr}...`)
const txs = await this.bchMessage.memo.memoRead(
this.bchAddr,
'IPFS UPDATE'
)
// console.log(`txs: ${JSON.stringify(txs, null, 2)}`)
// If the array is empty, then return false.
if (txs.length === 0) return false
const hash = txs[0].subject
console.log(`...found this IPFS hash: ${hash}`)
// The transactions should automatically be sorted by the bchMessage
// library. So Just return the subject.
return hash
} catch (err) {
console.warn('Could not find IPFS hash in transaction history.')
return false
}
}
}
export default Memo
@@ -0,0 +1,75 @@
/*
A footer section for the SPA
*/
// Global npm libraries
import React, { useEffect } from 'react'
import { Container, Row, Col } from 'react-bootstrap'
// Local libraries
import config from '../../config'
// import Memo from './get-cid'
function Footer (props) {
// const [ipfsCid, setIpfsCid] = useState(config.ipfsCid)
const wallet = props.appData.wallet
// Retrieve the most up-to-date CID for the app on Filecoin from the BCH blockchain.
useEffect(() => {
async function fetchData () {
try {
// const hash = await getUpdatedUrl(wallet)
// if (hash) {
// setIpfsCid(hash)
// }
} catch (err) {
console.error('Error trying to retrieve Filecoin CID for the app from the BCH blockchain.')
}
}
fetchData()
}, [wallet])
return (
<Container style={{ backgroundColor: '#ddd' }}>
<Row style={{ padding: '25px' }}>
<Col>
<h6>Source Code</h6>
<ul>
<li>
<a href={config.ghRepo} target='_blank' rel='noreferrer'>GitHub</a>
</li>
</ul>
</Col>
<Col />
</Row>
</Container>
)
}
// async function getUpdatedUrl (wallet) {
// try {
// // Exit if the wallet is not initialized.
// if (!wallet) return
// // Initialize the memo library for retrieving data from the BCH blockchain.
// const memo = new Memo({ bchAddr: config.appBchAddr })
// await memo.initialize(wallet)
// const hash = await memo.findHash()
// if (!hash) {
// console.error(
// `Could not find IPFS hash in transactions for address ${config.appBchAddr}`
// )
// return false
// }
// // console.log(`latest IPFS hash: ${hash}`)
// return hash
// } catch (err) {
// console.log('Error in getUpdatedUrl(): ', err)
// }
// }
export default Footer
@@ -0,0 +1,16 @@
/*
Load <script> libraries
*/
import useScript from '../hooks/use-script'
function LoadScripts () {
// useScript('https://unpkg.com/minimal-slp-wallet?module')
// Load the libraries from the local directory.
useScript(`${process.env.PUBLIC_URL}/minimal-slp-wallet.min.js`)
return true
}
export default LoadScripts
@@ -0,0 +1,133 @@
/*
This component controlls the navigation menu.
Inspired from this example:
https://codesandbox.io/s/react-bootstrap-hamburger-menu-example-rnud4?from-embed
*/
// Global npm libraries
import React, { useState } from 'react'
import { Nav, Navbar, Image } from 'react-bootstrap' // Used for Navbar Style and Layouts .
import { NavLink } from 'react-router-dom' // Used to navigate between routes
// Assets
import Logo from './psf-logo.png'
function NavMenu (props) {
// Get the current path
const { currentPath } = props.appData
// Navbar state
const [expanded, setExpanded] = useState(false)
// Handle click event
const handleClickEvent = () => {
// Collapse the navbar
setExpanded(false)
}
return (
<>
<Navbar expanded={expanded} onToggle={setExpanded} expand='xxxl' bg='dark' variant='dark' style={{ paddingRight: '20px' }}>
<Navbar.Brand href='#home' style={{ paddingLeft: '20px' }}>
<Image src={Logo} thumbnail width='50' />{' '}
SLP Wallet
</Navbar.Brand>
<Navbar.Toggle aria-controls='responsive-navbar-nav' />
<Navbar.Collapse id='responsive-navbar-nav'>
<Nav className='mr-auto'>
<NavLink
className={(currentPath === '/bch' || currentPath === '/') ? 'nav-link-active' : 'nav-link-inactive'}
to='/bch'
onClick={handleClickEvent}
>
BCH
</NavLink>
<NavLink
className={currentPath === '/slp-tokens' ? 'nav-link-active' : 'nav-link-inactive'}
to='/slp-tokens'
onClick={handleClickEvent}
>
Tokens
</NavLink>
<NavLink
className={currentPath === '/profile/recent' ? 'nav-link-active' : 'nav-link-inactive'}
to='/profile/recent'
onClick={handleClickEvent}
>
Profiles
</NavLink>
<NavLink
className={currentPath === '/posts/recent' ? 'nav-link-active' : 'nav-link-inactive'}
to='/posts/recent'
onClick={handleClickEvent}
>
Posts
</NavLink>
<NavLink
className={currentPath === '/posts/new' ? 'nav-link-active' : 'nav-link-inactive'}
to='/posts/new'
onClick={handleClickEvent}
>
New Post
</NavLink>
<NavLink
className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'}
to='/wallet'
onClick={handleClickEvent}
>
Wallet
</NavLink>
<NavLink
className={(currentPath === '/balance') ? 'nav-link-active' : 'nav-link-inactive'}
to='/balance'
onClick={handleClickEvent}
>
Check Balance
</NavLink>
<NavLink
className={(currentPath === '/account') ? 'nav-link-active' : 'nav-link-inactive'}
to='/account'
onClick={handleClickEvent}
>
Account
</NavLink>
<NavLink
className={(currentPath === '/sweep') ? 'nav-link-active' : 'nav-link-inactive'}
to='/sweep'
onClick={handleClickEvent}
>
Sweep
</NavLink>
<NavLink
className={(currentPath === '/sign') ? 'nav-link-active' : 'nav-link-inactive'}
to='/sign'
onClick={handleClickEvent}
>
Sign
</NavLink>
<NavLink
className={currentPath === '/configuration' ? 'nav-link-active' : 'nav-link-inactive'}
to='/configuration'
onClick={handleClickEvent}
>
Configuration
</NavLink>
</Nav>
</Navbar.Collapse>
</Navbar>
</>
)
}
export default NavMenu
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

@@ -0,0 +1,37 @@
/*
Like button for a post (heart icon + count).
The icon is filled when the post has been liked in the current session and
outlined otherwise. The count is displayed next to the icon.
*/
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHeart as faHeartSolid } from '@fortawesome/free-solid-svg-icons'
import { faHeart as faHeartRegular } from '@fortawesome/free-regular-svg-icons'
import './post-feed.css'
function LikeButton ({ count = 0, liked = false, onClick }) {
const label = count === 1 ? '1 like' : `${count} likes`
const icon = liked ? faHeartSolid : faHeartRegular
const className = [
'post-like-button',
liked ? 'post-like-button-liked' : ''
].filter(Boolean).join(' ')
return (
<button
type='button'
className={className}
aria-label={label}
title={label}
onClick={onClick}
>
<FontAwesomeIcon icon={icon} className='post-like-button-icon' />
<span className='post-like-button-count'>{count}</span>
</button>
)
}
export default LikeButton
@@ -0,0 +1,157 @@
/*
Like / Tip modal for a post.
Lets the user submit a Memo like (0x6d04) with an optional satoshi tip to the
post author. The wallet and target post are injected through props. Errors
from validation, dust/maximum/balance checks, and broadcast failures are
surfaced in the modal body.
*/
import React, { useState, useEffect } from 'react'
import { Modal, Form, Button } from 'react-bootstrap'
import MemoLike from '../../services/memo-like'
import LikeTipPage from '../../services/like-tip-page'
import { getDisplayName } from './post-display'
import './post-feed.css'
function formatError (result) {
if (!result || result.ok) return ''
return result.message || ''
}
function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess }) {
const [tip, setTip] = useState('')
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const displayName = post ? getDisplayName(post.addr, profiles) : ''
// Reset state whenever the modal is shown and check the wallet balance.
useEffect(() => {
if (!show || !post || !wallet) {
setTip('')
setError('')
setSubmitting(false)
return
}
setTip('')
setError('')
setSubmitting(false)
let cancelled = false
const checkBalance = async () => {
try {
// Refresh the wallet's spendable UTXOs so the gating balance check is
// accurate against the live minimal-slp-wallet (which exposes
// wallet.utxos as a UtxoStore object, not an array).
if (typeof wallet.getUtxos === 'function') {
await wallet.getUtxos()
}
if (cancelled) return
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
const result = page.open(post.txid, post.addr)
if (!result.ok) {
setError(formatError(result))
}
} catch (err) {
if (!cancelled) setError(err.message)
}
}
checkBalance()
return () => { cancelled = true }
}, [show, post, wallet])
async function handleSubmit () {
if (!post || !wallet) return
setError('')
setSubmitting(true)
try {
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
page.open(post.txid, post.addr)
page.setTip(tip)
const result = await page.submit()
if (result.ok) {
setTip('')
if (typeof onSuccess === 'function') {
onSuccess()
}
} else {
setError(formatError(result))
}
} catch (submitErr) {
setError(submitErr.message)
} finally {
setSubmitting(false)
}
}
function handleCancel () {
setTip('')
setError('')
onHide()
}
return (
<Modal show={show} onHide={handleCancel} centered>
<Modal.Header closeButton>
<Modal.Title>Like / Tip</Modal.Title>
</Modal.Header>
<Modal.Body>
{post && (
<p className='like-tip-modal-target'>
Like the post by <strong>{displayName}</strong>
</p>
)}
<Form onSubmit={(e) => { e.preventDefault(); handleSubmit() }}>
<Form.Group controlId='like-tip-amount' className='mb-3'>
<Form.Label>Tip (satoshis, optional)</Form.Label>
<Form.Control
type='number'
min='0'
step='1'
placeholder='0'
value={tip}
onChange={(e) => {
setTip(e.target.value)
// Clear a previous validation error so the user can retry.
setError('')
}}
disabled={submitting}
/>
</Form.Group>
</Form>
{error && (
<p className='like-tip-modal-error text-danger'>{error}</p>
)}
</Modal.Body>
<Modal.Footer>
<Button variant='secondary' onClick={handleCancel}>
Cancel
</Button>
<Button
variant='primary'
onClick={handleSubmit}
disabled={submitting || !post || !wallet}
>
{submitting ? 'Liking...' : 'Like'}
</Button>
</Modal.Footer>
</Modal>
)
}
export default LikeTipModal
@@ -0,0 +1,39 @@
/*
Shared display helpers for post feed and thread views.
*/
import { truncateAddr } from '../../util'
export { truncateAddr, truncateTxid } from '../../util'
export function formatRelativeSeen (seen) {
if (!seen) return ''
const ms = seen > 1e12 ? seen : seen * 1000
const diff = Date.now() - ms
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d`
const months = Math.floor(days / 30)
if (months < 12) return `${months}mo`
const years = Math.floor(months / 12)
return `${years}y`
}
export function getDisplayName (addr, profiles) {
const profile = profiles?.[addr]
if (profile?.name) {
return profile.name
}
return truncateAddr(addr, 24)
}
@@ -0,0 +1,196 @@
/*
Instagram-style post card for feed and thread views.
*/
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import AppUtil from '../../util'
import PostReplyCount from '../post-reply-count'
import PostThreadAvatar from '../post-thread-modal/post-thread-avatar'
import LikeButton from './like-button'
import LikeTipModal from './like-tip-modal'
import {
formatRelativeSeen,
getDisplayName,
truncateTxid
} from './post-display'
import './post-feed.css'
const appUtil = new AppUtil()
function PostFeedItem ({
post,
profile = {},
profiles = {},
wallet,
onReplyClick,
showRepliedLabel = false,
showFooterMeta = false,
showReplyCount = true,
showLikeButton = true,
embedded = false
}) {
// React hooks must be called unconditionally before any early return, so
// declare the like state first and guard the post access after.
const [liked, setLiked] = useState(false)
const [likeCount, setLikeCount] = useState(post?.likeCount || 0)
const [showLikeModal, setShowLikeModal] = useState(false)
if (!post) return null
const displayName = getDisplayName(post.addr, profiles)
const hasCustomName = Boolean(
profile.name ?? profiles?.[post.addr]?.name
)
const profilePicUrl =
profile.profilePicUrl ??
profiles?.[post.addr]?.profilePicUrl
const Wrapper = embedded ? 'div' : 'article'
const copyTxid = () => {
if (!post.txid) return
appUtil.copyToClipboard(post.txid)
}
const handleLikeClick = () => {
setShowLikeModal(true)
}
const handleLikeSuccess = () => {
setLiked(true)
setLikeCount((count) => count + 1)
setShowLikeModal(false)
}
const handleLikeModalHide = () => {
setShowLikeModal(false)
}
return (
<Wrapper
className={[
'posts-feed-item',
embedded ? 'posts-feed-item-embedded' : ''
].filter(Boolean).join(' ')}
>
<header className='posts-feed-item-header'>
<Link
to={`/profile/${encodeURIComponent(post.addr)}`}
className='posts-feed-item-avatar-link'
aria-label={`View ${displayName}'s profile`}
>
<PostThreadAvatar
addr={post.addr}
profilePicUrl={profilePicUrl}
/>
</Link>
<div className='posts-feed-item-meta'>
<div className='posts-feed-item-author-row'>
<Link
to={`/profile/${encodeURIComponent(post.addr)}`}
className={[
'posts-feed-item-author',
hasCustomName
? ''
: 'posts-feed-item-author-address'
].filter(Boolean).join(' ')}
title={post.addr}
>
{displayName}
</Link>
{showRepliedLabel && (
<span className='posts-feed-item-replied'>
replied
</span>
)}
</div>
<span className='posts-feed-item-seen'>
{formatRelativeSeen(post.seen)}
</span>
</div>
<button
type='button'
className='posts-feed-item-menu'
aria-label='Post options'
title='Post options'
>
<span aria-hidden='true'></span>
</button>
</header>
<div className='posts-feed-item-content'>
<p className='posts-feed-item-text'>
<Link
to={`/profile/${encodeURIComponent(post.addr)}`}
className='posts-feed-item-inline-author'
>
{displayName}
</Link>
{' '}
{post.text}
</p>
</div>
{(showReplyCount || showLikeButton) && (
<div className='posts-feed-item-actions'>
{showLikeButton && (
<LikeButton
count={likeCount}
liked={liked}
onClick={handleLikeClick}
/>
)}
{showReplyCount && (
<PostReplyCount
count={post.replyCount ?? 0}
onClick={onReplyClick}
/>
)}
</div>
)}
{showFooterMeta && (
<footer className='posts-feed-item-footer'>
<span>Block {post.blockHeight}</span>
<span
className='posts-feed-item-footer-separator'
aria-hidden='true'
>
·
</span>
<button
type='button'
className='posts-feed-item-txid'
title={post.txid}
onClick={copyTxid}
>
{truncateTxid(post.txid, 20)}
</button>
</footer>
)}
<LikeTipModal
show={showLikeModal}
post={post}
wallet={wallet}
profiles={profiles}
onHide={handleLikeModalHide}
onSuccess={handleLikeSuccess}
/>
</Wrapper>
)
}
export default PostFeedItem
@@ -0,0 +1,664 @@
/*
Instagram-inspired post feed
*/
:root {
--ig-background: #fafafa;
--ig-surface: #ffffff;
--ig-border: #dbdbdb;
--ig-border-soft: #efefef;
--ig-text: #262626;
--ig-text-secondary: #737373;
--ig-text-muted: #a8a8a8;
--ig-link: #00376b;
--ig-blue: #0095f6;
--ig-blue-hover: #1877f2;
--ig-danger: #ed4956;
--ig-radius: 8px;
--ig-feed-width: 630px;
}
/* Feed wrapper */
.posts-feed,
.posts-feed-list,
.posts-feed-container {
width: min(100%, var(--ig-feed-width));
margin: 0 auto;
padding: 24px 12px 60px;
}
/* Main post card */
.posts-feed-item {
position: relative;
width: 100%;
margin: 0 0 20px;
padding: 0;
color: var(--ig-text);
background: var(--ig-surface);
border: 1px solid var(--ig-border);
border-radius: var(--ig-radius);
box-shadow: none;
overflow: hidden;
}
/* Remove the previous green accent */
.posts-feed-item::before {
display: none;
}
/* No floating card animation */
.posts-feed-item:hover {
transform: none;
border-color: var(--ig-border);
box-shadow: none;
}
/* Post header */
.posts-feed-item-header {
display: flex;
align-items: center;
gap: 12px;
min-height: 60px;
margin: 0;
padding: 12px 16px;
border-bottom: 1px solid var(--ig-border-soft);
}
/* Metadata */
.posts-feed-item-meta {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 7px;
padding: 0;
}
/* Author name */
.posts-feed-item-author {
display: inline-block;
max-width: 100%;
color: var(--ig-text);
font-size: 14px;
font-weight: 600;
line-height: 18px;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: opacity 0.15s ease;
}
.posts-feed-item-author:hover {
color: var(--ig-text);
text-decoration: none;
opacity: 0.65;
}
/* Address-based usernames */
.posts-feed-item-author-address {
max-width: 300px;
color: var(--ig-text);
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-size: 14px;
font-weight: 600;
}
/* Replied badge */
.posts-feed-item-replied {
display: inline-flex;
align-items: center;
padding: 0;
color: var(--ig-text-secondary);
background: transparent;
border: 0;
border-radius: 0;
font-size: 12px;
font-weight: 400;
line-height: 16px;
}
/* Timestamp */
.posts-feed-item-seen {
color: var(--ig-text-secondary);
font-size: 12px;
line-height: 16px;
}
.posts-feed-item-seen::before {
content: "•";
margin-right: 7px;
color: var(--ig-text-muted);
}
/* Post body */
.posts-feed-item-text {
margin: 0;
padding: 16px;
color: var(--ig-text);
font-size: 14px;
line-height: 20px;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
/* Links inside posts */
.posts-feed-item-text a {
color: var(--ig-link);
font-weight: 500;
text-decoration: none;
}
.posts-feed-item-text a:hover {
text-decoration: underline;
}
/* Actions */
.posts-feed-item-actions {
display: flex;
align-items: center;
gap: 12px;
min-height: 46px;
margin: 0;
padding: 8px 16px;
border-top: 1px solid var(--ig-border-soft);
}
/* Reply-count buttons and links */
.posts-feed-item-actions button,
.posts-feed-item-actions a,
.posts-feed-item-actions [role="button"] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 32px;
padding: 4px 0;
color: var(--ig-text);
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
font-size: 13px;
font-weight: 600;
line-height: 18px;
text-decoration: none;
cursor: pointer;
transition:
opacity 0.15s ease,
color 0.15s ease;
}
.posts-feed-item-actions button:hover,
.posts-feed-item-actions a:hover,
.posts-feed-item-actions [role="button"]:hover {
color: var(--ig-text);
background: transparent;
border: 0;
box-shadow: none;
transform: none;
opacity: 0.55;
}
/* Footer metadata */
.posts-feed-item-footer {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
margin: 0;
padding: 0 16px 14px;
color: var(--ig-text-secondary);
border-top: 0;
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-size: 11px;
line-height: 16px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.posts-feed-item-footer-separator {
color: var(--ig-text-muted);
}
/* Transaction ID */
.posts-feed-item-txid {
color: var(--ig-text-secondary);
cursor: pointer;
border: 0;
text-transform: none;
transition: color 0.15s ease;
}
.posts-feed-item-txid:hover {
color: var(--ig-text);
background: transparent;
}
.posts-feed-item-txid:focus-visible {
outline: 2px solid var(--ig-blue);
outline-offset: 3px;
border-radius: 2px;
}
/* Embedded replies */
.posts-feed-item-embedded {
margin: 12px 0 0 44px;
padding: 0;
background: var(--ig-surface);
border: 1px solid var(--ig-border-soft);
border-radius: var(--ig-radius);
box-shadow: none;
}
.posts-feed-item-embedded .posts-feed-item-header {
min-height: 52px;
padding: 10px 12px;
}
.posts-feed-item-embedded .posts-feed-item-text {
padding: 12px;
}
.posts-feed-item-embedded .posts-feed-item-actions {
padding: 6px 12px;
}
.posts-feed-item-embedded .posts-feed-item-footer {
padding: 0 12px 12px;
}
/* Alternate posts should remain consistent */
.posts-feed-item:nth-child(even) {
background: var(--ig-surface);
}
/* Common avatar overrides */
.posts-feed-item-header img,
.posts-feed-item-header .avatar,
.posts-feed-item-header [class*="avatar"] {
width: 36px;
height: 36px;
flex: 0 0 36px;
object-fit: cover;
background: #efefef;
border: 1px solid var(--ig-border);
border-radius: 50%;
}
/* Optional Instagram-style profile ring */
.posts-feed-item-header img,
.posts-feed-item-header [class*="avatar"] img {
padding: 2px;
background:
linear-gradient(#ffffff, #ffffff) padding-box,
linear-gradient(
135deg,
#f9ce34,
#ee2a7b,
#6228d7
) border-box;
border: 2px solid transparent;
}
/* Mobile */
@media (max-width: 700px) {
.posts-feed,
.posts-feed-list,
.posts-feed-container {
width: 100%;
padding: 0 0 40px;
}
.posts-feed-item {
margin-bottom: 10px;
border-left: 0;
border-right: 0;
border-radius: 0;
}
.posts-feed-item-header {
padding: 10px 12px;
}
.posts-feed-item-text {
padding: 14px 12px;
font-size: 14px;
line-height: 20px;
}
.posts-feed-item-actions {
padding: 7px 12px;
}
.posts-feed-item-footer {
padding: 0 12px 12px;
}
.posts-feed-item-author-address {
max-width: 190px;
}
.posts-feed-item-embedded {
margin-left: 24px;
margin-right: 10px;
border-left: 1px solid var(--ig-border-soft);
border-right: 1px solid var(--ig-border-soft);
border-radius: var(--ig-radius);
}
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
.posts-feed-item-author,
.posts-feed-item-actions button,
.posts-feed-item-actions a,
.posts-feed-item-actions [role="button"],
.posts-feed-item-txid {
transition: none;
}
}
.posts-feed-item-avatar-link {
display: inline-flex;
flex: 0 0 auto;
border-radius: 50%;
text-decoration: none;
}
.posts-feed-item-meta {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.posts-feed-item-author-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.posts-feed-item-menu {
width: 34px;
height: 34px;
padding: 0;
color: #262626;
background: transparent;
border: 0;
border-radius: 50%;
box-shadow: none;
font-size: 15px;
letter-spacing: 1px;
cursor: pointer;
}
.posts-feed-item-menu:hover {
color: #737373;
background: #f2f2f2;
border: 0;
box-shadow: none;
transform: none;
}
.posts-feed-item-content {
padding: 14px 16px 6px;
}
.posts-feed-item-text {
margin: 0;
padding: 0;
color: #262626;
font-size: 14px;
line-height: 20px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.posts-feed-item-inline-author {
color: #262626;
font-weight: 600;
text-decoration: none;
}
.posts-feed-item-inline-author:hover {
text-decoration: underline;
}
.posts-feed-item-txid {
padding: 0;
color: #737373;
background: transparent;
border: 0;
box-shadow: none;
font-size: inherit;
font-weight: inherit;
text-transform: none;
cursor: pointer;
}
.posts-feed-item-txid:hover {
color: #262626;
background: transparent;
border: 0;
box-shadow: none;
transform: none;
}
.recent-posts-page {
padding-top: 2.5rem;
padding-bottom: 4rem;
}
.recent-posts-heading {
margin-bottom: 1.75rem;
text-align: center;
}
.recent-posts-heading h1 {
margin: 0;
color: #262626;
font-size: clamp(2rem, 5vw, 2.8rem);
font-weight: 700;
letter-spacing: -0.04em;
}
.recent-posts-heading p {
max-width: 560px;
margin: 0.6rem auto 0;
color: #737373;
font-size: 0.95rem;
line-height: 1.5;
}
.recent-posts-count {
display: inline-block;
margin-top: 0.85rem;
color: #8e8e8e;
font-size: 0.78rem;
font-weight: 500;
}
.recent-posts-error {
padding: 0.85rem 1rem;
color: #c62828;
background: #fff1f1;
border: 1px solid #ffd2d2;
border-radius: 8px;
text-align: center;
}
.recent-posts-pagination {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: 1.5rem;
margin-bottom: 2rem;
}
.recent-posts-pagination .btn {
min-width: 110px;
border-radius: 8px;
}
@media (max-width: 700px) {
.recent-posts-page {
padding-top: 1.5rem;
}
.recent-posts-heading {
padding: 0 0.75rem;
}
.recent-posts-heading h1 {
font-size: 2rem;
}
.recent-posts-heading p {
font-size: 0.9rem;
}
.recent-posts-pagination {
padding: 0 0.75rem;
}
}
.post-like-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 32px;
padding: 4px 0;
color: var(--ig-text);
background: transparent;
border: 0;
border-radius: 0;
font-size: 13px;
font-weight: 600;
line-height: 18px;
text-decoration: none;
cursor: pointer;
transition:
opacity 0.15s ease,
color 0.15s ease;
}
.post-like-button:hover {
color: var(--ig-danger);
background: transparent;
border: 0;
box-shadow: none;
transform: none;
opacity: 0.65;
}
.post-like-button-liked,
.post-like-button-liked:hover {
color: var(--ig-danger);
opacity: 1;
}
.post-like-button-icon {
font-size: 18px;
}
.post-like-button-count {
min-width: 1.2em;
}
.like-tip-modal-target {
margin-bottom: 1rem;
}
.like-tip-modal-error {
margin: 0;
font-size: 0.95rem;
}
@@ -0,0 +1,49 @@
/*
Reply count indicator for a post (icon + number).
The indicator is always clickable when an onClick handler is provided, even
if the count is zero, so that the comment icon can open a thread with zero
replies. When onClick is absent, the indicator is rendered as non-interactive.
*/
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faComment } from '@fortawesome/free-solid-svg-icons'
import './post-reply-count.css'
function PostReplyCount ({ count = 0, onClick }) {
const label = count === 1 ? '1 reply' : `${count} replies`
const clickable = typeof onClick === 'function'
const title = clickable ? `${label} — click to view` : label
const ariaLabel = clickable ? `${label} — click to view thread` : label
const handleKeyDown = (event) => {
if (clickable && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault()
onClick()
}
}
const className = [
'post-reply-count',
clickable ? 'post-reply-count-always-clickable' : 'post-reply-count-disabled'
].join(' ')
return (
<div
className={className}
title={title}
aria-label={ariaLabel}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? onClick : undefined}
onKeyDown={clickable ? handleKeyDown : undefined}
>
<FontAwesomeIcon icon={faComment} className='post-reply-count-icon' />
<span className='post-reply-count-number'>{count}</span>
</div>
)
}
export default PostReplyCount
@@ -0,0 +1,27 @@
.post-reply-count {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.75rem;
color: #6c757d;
font-size: 0.85rem;
}
.post-reply-count-icon {
font-size: 0.9rem;
}
.post-reply-count-clickable,
.post-reply-count-always-clickable {
cursor: pointer;
}
.post-reply-count-clickable:hover,
.post-reply-count-always-clickable:hover {
color: #28a745;
}
.post-reply-count-disabled {
cursor: not-allowed;
opacity: 0.6;
}
@@ -0,0 +1,133 @@
/*
Modal displaying a post and its nested reply thread.
*/
import React, { useState, useEffect } from 'react'
import { Modal, Spinner } from 'react-bootstrap'
import MemoDb from '../../services/memo-db'
import PostThreadNode from './post-thread-node'
import ReplyThreadForm from './reply-thread-form'
import { collectThreadAddrs, loadThreadProfiles } from './thread-profiles'
import './post-thread-modal.css'
import '../post-feed/post-feed.css'
function PostThreadModal ({ show, txid, onHide, wallet, profiles: externalProfiles }) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [thread, setThread] = useState(null)
const [profiles, setProfiles] = useState({})
const [optimisticReplies, setOptimisticReplies] = useState([])
// Clear optimistic replies when the modal is hidden or the txid changes.
useEffect(() => {
if (!show) {
setOptimisticReplies([])
}
}, [show])
useEffect(() => {
if (!show || !txid) {
return undefined
}
let cancelled = false
const loadThread = async () => {
setLoading(true)
setError(null)
setThread(null)
setProfiles({})
try {
const memoDb = new MemoDb()
const data = await memoDb.getPostThread(txid)
const post = data.post || null
if (cancelled) return
if (post) {
const addrs = collectThreadAddrs(post)
const profileMap = await loadThreadProfiles(addrs, memoDb)
if (!cancelled) {
setThread(post)
setProfiles(profileMap)
}
} else if (!cancelled) {
setThread(null)
}
} catch (err) {
if (!cancelled) {
const message = err.response?.data?.message || err.message || 'Failed to load replies'
setError(message)
}
}
if (!cancelled) {
setLoading(false)
}
}
loadThread()
return () => {
cancelled = true
}
}, [show, txid])
const handleHide = () => {
setThread(null)
setProfiles({})
setError(null)
setOptimisticReplies([])
onHide()
}
const handleOptimisticReply = (reply) => {
setOptimisticReplies((prev) => [...prev, reply])
}
return (
<Modal show={show} onHide={handleHide} size='lg' scrollable centered>
<Modal.Header closeButton>
<Modal.Title>Post thread</Modal.Title>
</Modal.Header>
<Modal.Body className='post-thread-modal-body'>
{loading && (
<div className='text-center my-4'>
<Spinner animation='border' role='status' variant='primary'>
<span className='visually-hidden'>Loading replies...</span>
</Spinner>
</div>
)}
{error && !loading && (
<p className='text-danger mb-0'>{error}</p>
)}
{!loading && !error && thread && (
<>
<PostThreadNode post={thread} profiles={profiles} wallet={wallet} isRoot />
<ReplyThreadForm
parentTxid={txid}
rootPost={thread}
wallet={wallet}
profiles={profiles}
onOptimisticReply={handleOptimisticReply}
/>
{optimisticReplies.map((reply) => (
<PostThreadNode
key={reply.txid}
post={reply}
profiles={profiles}
wallet={wallet}
/>
))}
</>
)}
</Modal.Body>
</Modal>
)
}
export default PostThreadModal
@@ -0,0 +1,35 @@
/*
Small avatar for post thread nodes (profile pic or jdenticon fallback).
*/
import React, { useState, useEffect } from 'react'
import Jdenticon from '@chris.troutner/react-jdenticon'
function PostThreadAvatar ({ addr, profilePicUrl, size = 36 }) {
const [picError, setPicError] = useState(false)
useEffect(() => {
setPicError(false)
}, [profilePicUrl, addr])
if (profilePicUrl && !picError) {
return (
<img
src={profilePicUrl}
alt=''
className='post-thread-avatar'
width={size}
height={size}
onError={() => setPicError(true)}
/>
)
}
return (
<div className='post-thread-avatar post-thread-avatar-jdenticon' style={{ width: size, height: size }}>
<Jdenticon size={String(size)} value={addr} />
</div>
)
}
export default PostThreadAvatar
@@ -0,0 +1,105 @@
.reply-thread-form {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #dee2e6;
}
.reply-thread-counter {
margin: 0.5rem 0;
font-size: 0.85rem;
color: #6c757d;
}
.reply-thread-counter-over {
color: #dc3545;
font-weight: 600;
}
.reply-thread-error {
color: #dc3545;
margin: 0.5rem 0;
}
.post-thread-modal-body {
max-height: 70vh;
}
.post-thread-node {
margin-top: 0.75rem;
}
.post-thread-node-root {
margin-top: 0;
}
.post-thread-node-inner {
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 0.75rem 1rem;
background: #fff;
}
.post-thread-node-root > .post-thread-node-inner {
border-color: #ced4da;
}
.post-thread-node-header {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.5rem;
}
.post-thread-avatar {
flex-shrink: 0;
border-radius: 4px;
object-fit: cover;
display: block;
}
.post-thread-avatar-jdenticon {
overflow: hidden;
border-radius: 4px;
}
.post-thread-node-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem;
font-size: 0.85rem;
color: #6c757d;
min-width: 0;
}
.post-thread-node-author {
font-weight: 600;
color: #212529;
text-decoration: none;
}
.post-thread-node-author:hover {
text-decoration: underline;
color: #28a745;
}
.post-thread-node-author-address {
font-family: monospace;
font-size: 0.8rem;
font-weight: 500;
}
.post-thread-node-replied {
font-style: italic;
}
.post-thread-node-seen {
white-space: nowrap;
}
.post-thread-node-text {
white-space: pre-wrap;
word-break: break-word;
font-size: 0.95rem;
line-height: 1.5;
}
@@ -0,0 +1,43 @@
/*
Single post node in a reply thread (recursive).
*/
import React from 'react'
import PostFeedItem from '../post-feed/post-feed-item'
function PostThreadNode ({ post, profiles = {}, wallet, depth = 0, isRoot = false }) {
if (!post) return null
const profile = profiles[post.addr] || {}
return (
<div
className={`post-thread-node${isRoot ? ' post-thread-node-root' : ''}`}
style={{ marginLeft: depth > 0 ? `${Math.min(depth, 8) * 1.25}rem` : undefined }}
>
<div className='post-thread-node-inner'>
<PostFeedItem
post={post}
profile={profile}
profiles={profiles}
wallet={wallet}
showRepliedLabel={!isRoot}
showReplyCount={false}
embedded
/>
{(post.replies || []).map((reply) => (
<PostThreadNode
key={reply.txid}
post={reply}
profiles={profiles}
wallet={wallet}
depth={depth + 1}
/>
))}
</div>
</div>
)
}
export default PostThreadNode
@@ -0,0 +1,100 @@
/*
Reply form rendered inside the post thread modal.
Composes and broadcasts a Memo reply (0x6d03) to the displayed post,
with a live byte counter counting down from the 184-byte reply limit.
On success, the reply is added to the thread optimistically so the user
sees it immediately without waiting for the network crawl/index cycle.
The wallet and optional profile store are injected through props.
*/
import React, { useState } from 'react'
import { Form, Button } from 'react-bootstrap'
import MemoReply from '../../services/memo-reply'
import ReplyThreadPage from '../../services/reply-thread-page'
import { byteLength } from '../../services/utf8'
import { buildOptimisticReply } from '../../services/optimistic-reply'
function ReplyThreadForm ({ parentTxid, rootPost, wallet, profiles, onOptimisticReply }) {
const maxBytes = MemoReply.MAX_REPLY_BYTES
const [input, setInput] = useState('')
const [err, setErr] = useState('')
const [replying, setReplying] = useState(false)
const remaining = maxBytes - byteLength(input)
const overLimit = remaining < 0
async function handleSubmit (event) {
event.preventDefault()
setErr('')
setReplying(true)
try {
const memoReply = new MemoReply({ wallet, thread: null })
const page = new ReplyThreadPage({ memoReply })
page.setParent(parentTxid)
page.setInput(input)
const result = await page.submit()
if (result.ok) {
setInput('')
if (typeof onOptimisticReply === 'function') {
const cashAddress = wallet?.walletInfo?.cashAddress
const displayName = profiles?.[cashAddress]?.name || null
onOptimisticReply(buildOptimisticReply({
txid: result.txid,
addr: cashAddress,
text: input,
seen: Date.now(),
blockHeight: rootPost?.blockHeight,
displayName
}))
}
} else {
if (result.error === 'reply_length') {
setErr(`Reply is too long. Maximum is ${maxBytes} bytes.`)
} else if (result.error === 'reply_validation') {
setErr('Reply must not be empty.')
} else if (result.message) {
setErr(`Failed to broadcast: ${result.message}`)
} else {
setErr('Failed to post reply.')
}
}
} catch (submitErr) {
setErr(submitErr.message)
} finally {
setReplying(false)
}
}
return (
<Form onSubmit={handleSubmit} className='reply-thread-form' data-testid='reply-thread-form'>
<Form.Group controlId='reply-thread-message' className='mb-2'>
<Form.Label><b>Reply</b></Form.Label>
<Form.Control
as='textarea'
rows={3}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder='Write a reply...'
disabled={replying}
/>
</Form.Group>
<p className={`reply-thread-counter${overLimit ? ' reply-thread-counter-over' : ''}`}>
{remaining} bytes remaining
</p>
{err && <p className='reply-thread-error'>{err}</p>}
<Button type='submit' variant='primary' disabled={replying || overLimit || byteLength(input) === 0}>
{replying ? 'Posting Reply...' : 'Post Reply'}
</Button>
</Form>
)
}
export default ReplyThreadForm
@@ -0,0 +1,40 @@
/*
Helpers for loading display names and avatars for thread participants.
*/
export function collectPostAddrs (posts) {
return [...new Set((posts || []).map((post) => post.addr).filter(Boolean))]
}
export function collectThreadAddrs (post) {
const addrs = new Set()
function walk (node) {
if (!node?.addr) return
addrs.add(node.addr)
for (const reply of node.replies || []) {
walk(reply)
}
}
walk(post)
return [...addrs]
}
export async function loadThreadProfiles (addrs, memoDb) {
const profiles = {}
await Promise.all(addrs.map(async (addr) => {
const [nameRecord, profilePic] = await Promise.all([
memoDb.getName(addr),
memoDb.getProfilePic(addr)
])
profiles[addr] = {
name: nameRecord?.name || null,
profilePicUrl: profilePic?.url || null
}
}))
return profiles
}
@@ -0,0 +1,41 @@
/**
* This file contains the views that are displayed before and after the BCH wallet is initialized.
*/
import React from 'react'
import WaitingModal from './waiting-modal'
import AppBody from './app-body'
// This is rendered *before* the BCH wallet is initialized.
export function UninitializedView (props = {}) {
// console.log('UninitializedView props: ', props)
const { appData } = props
const heading = 'Connecting to BCH blockchain...'
return (
<>
<WaitingModal
heading={heading}
body={appData.modalBody}
hideSpinner={appData.hideSpinner}
denyClose={appData.denyClose}
/>
{
appData.asyncInitFinished
? <> <br /><AppBody menuState={100} wallet={appData.wallet} appData={appData} /></>
: null
}
</>
)
}
// This is rendered *after* the BCH wallet is initialized.
export function InitializedView (props) {
const { appData } = props
return (
<>
<br />
<AppBody menuState={appData.menuState} appData={appData} />
</>
)
}
@@ -0,0 +1,74 @@
/*
This 'Waiting Modal' component displays a spinner animation and a status log.
It's used to inform the user that the app is waiting for something, and to
display progress.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Modal, Spinner } from 'react-bootstrap'
function ModalTemplate (props) {
// State
const [show, setShow] = useState(true)
// Dependency injection of props
const denyClose = props.denyClose // Determins if user is allowed to close modal.
const closeFunc = props.closeFunc // Optional function called after modal is closed.
const heading = props.heading // Title of the modal
const body = props.body // Body of the modal
const hideSpinner = props.hideSpinner // Hide the animated spinner
// This function is called when the modal is closed
const handleClose = () => {
console.log(`props.denyClose: ${denyClose}`)
if (denyClose) return
setShow(false)
if (closeFunc) {
closeFunc()
}
}
// const handleShow = () => setShow(true)
return (
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>{heading}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Container>
<Row>
<Col style={{ textAlign: 'center' }}>
<BodyList body={body} />
{hideSpinner ? null : <Spinner animation='border' />}
</Col>
</Row>
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
)
}
// This function populates the body of the modal. It expects props.body to be
// an array of strings.
function BodyList (props) {
const items = props.body
// console.log('BodyList items: ', items)
const listItems = []
// Paragraphs
for (let i = 0; i < items.length; i++) {
listItems.push(<p key={items[i]}><code>{items[i]}</code></p>)
}
return (
listItems
)
}
// export default WaitingModal
export default ModalTemplate

Some files were not shown because too many files have changed in this diff Show More