mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Creating mono-repo from psf-memo-client
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
build/
|
||||
docs/
|
||||
tmp/
|
||||
target/
|
||||
|
||||
.gitsigners
|
||||
@@ -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
@@ -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[@]}}"
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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 (00–99), 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 `<`/`>` 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.
|
||||
@@ -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/<role>.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/<role> (branch per role)"]
|
||||
TMUX["tmux sessions<br/>swarmforge-<role> · project-owned socket"]
|
||||
LAUNCH["send-keys: export SWARMFORGE_ROLE<br/>+ PATH helpers + cd worktree<br/>+ <agent> '$(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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
build/
|
||||
docs/
|
||||
tmp/
|
||||
target/
|
||||
|
||||
.gitsigners
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"srcDir": "",
|
||||
"destDir": "",
|
||||
"files": "**/*.js",
|
||||
"command": "npm run lint"
|
||||
}
|
||||
]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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
|
||||
Executable
+14
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
Generated
+26749
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user