Browse Source

Qt initialization

master
Silberengel 2 weeks ago
parent
commit
fd2eb19a4f
  1. 12
      .gitignore
  2. 18
      README.md
  3. 20
      flatpak/org.imwald.Imwald.yaml
  4. 33
      pyproject.toml
  5. 1
      src/imwald/__init__.py
  6. 4
      src/imwald/__main__.py
  7. 32
      src/imwald/app.py
  8. 33
      src/imwald/config.py
  9. 1
      src/imwald/core/__init__.py
  10. 97
      src/imwald/core/accounts_store.py
  11. 538
      src/imwald/core/database.py
  12. 27
      src/imwald/core/nip19.py
  13. 87
      src/imwald/core/nip49.py
  14. 57
      src/imwald/core/nostr_crypto.py
  15. 174
      src/imwald/core/nostr_engine.py
  16. 44
      src/imwald/core/nostr_publish.py
  17. 39
      src/imwald/core/nostr_types.py
  18. 109
      src/imwald/core/ranker.py
  19. 175
      src/imwald/core/relay_manager.py
  20. 48
      src/imwald/core/relay_policy.py
  21. 1
      src/imwald/ui/__init__.py
  22. 117
      src/imwald/ui/composer_dialog.py
  23. 181
      src/imwald/ui/db_admin_page.py
  24. 187
      src/imwald/ui/feed_page.py
  25. 281
      src/imwald/ui/main_window.py
  26. 92
      src/imwald/ui/notifications_page.py
  27. 227
      src/imwald/ui/onboarding_wizard.py
  28. 46
      src/imwald/ui/search_page.py
  29. 42
      tests/test_kind30000_lists.py
  30. 13
      tests/test_nip49.py

12
.gitignore vendored

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
.venv/
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.coverage
htmlcov/
.mypy_cache/
.pytest_cache/
*.sqlite
*.db

18
README.md

@ -1,9 +1,22 @@ @@ -1,9 +1,22 @@
# Imwald: Linux-native Nostr client
Qt (PySide6) + SQLite (WAL) Nostr client for Linux. Relay worker ingests into the local DB; feed, search, notifications, DB admin, and composer are wired in `src/imwald/`.
## Run (development)
```bash
cd imwald
python3 -m venv .venv
.venv/bin/pip install -e '.[dev]'
PYTHONPATH=src .venv/bin/python -m imwald
```
Optional dev extras are declared in `pyproject.toml` (`pytest`).
## Core client features
1. Handles websocket and https relays
2. Integrates all custom relay lists, optional outboxes and sensible defaults
2. Integrates all custom relay lists and sensible defaults
3. Easy onboarding for new users
4. Algorithmic feed (no follows, no recommendation lists)
5. File-style/application handling
@ -13,5 +26,4 @@ @@ -13,5 +26,4 @@
9. Grammar check, translations, and read-alouds for popular world languages
10. Full profile view
11. Discussion/Bulletin board
12. RSS and web view
13. Notifications
12. Notifications

20
flatpak/org.imwald.Imwald.yaml

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
# Flatpak manifest sketch — fill app-id, finish-args, and icon before publishing.
app-id: org.imwald.Imwald
runtime: org.kde.Platform
runtime-version: '6.7'
sdk: org.kde.Sdk
command: imwald
finish-args:
- --share=network
- --share=ipc
- --socket=fallback-x11
- --socket=wayland
- --device=dri
modules:
- name: imwald
buildsystem: simple
build-commands:
- pip3 install --prefix=/app --no-build-isolation .
sources:
- type: dir
path: ..

33
pyproject.toml

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "imwald"
version = "0.1.0"
description = "Linux-native Nostr client (Qt / PySide6)"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"PySide6>=6.6",
"cryptography>=42",
"bech32>=1.2",
"coincurve>=20",
"PyNaCl>=1.5",
"websockets>=12",
]
[project.optional-dependencies]
dev = ["pytest>=8"]
[project.scripts]
imwald = "imwald.__main__:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-dir]
"" = "src"
[tool.pytest.ini_options]
pythonpath = ["src"]

1
src/imwald/__init__.py

@ -0,0 +1 @@ @@ -0,0 +1 @@
__version__ = "0.1.0"

4
src/imwald/__main__.py

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
from imwald.app import main
if __name__ == "__main__":
main()

32
src/imwald/app.py

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
"""QApplication bootstrap."""
from __future__ import annotations
import logging
import sys
from PySide6.QtWidgets import QApplication
from imwald.config import db_path
from imwald.core.database import Database
from imwald.core.nostr_engine import NostrEngine
from imwald.ui.main_window import MainWindow
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
app = QApplication(sys.argv)
app.setApplicationName("imwald")
app.setOrganizationName("imwald")
db = Database(db_path())
db.connect()
engine = NostrEngine(db)
win = MainWindow(db=db, engine=engine)
win.show()
engine.start_relays(list30000_owner=win.list_owner_pubkey_for_relays())
rc = app.exec()
engine.stop_relays()
db.close()
raise SystemExit(rc)

33
src/imwald/config.py

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
"""XDG paths and app constants."""
from __future__ import annotations
import os
from pathlib import Path
def _xdg_data_home() -> Path:
base = os.environ.get("XDG_DATA_HOME")
if base:
return Path(base)
return Path.home() / ".local" / "share"
def data_dir() -> Path:
d = _xdg_data_home() / "imwald"
d.mkdir(parents=True, exist_ok=True)
return d
def db_path() -> Path:
return data_dir() / "imwald.sqlite"
def media_cache_dir() -> Path:
d = data_dir() / "media_cache"
d.mkdir(parents=True, exist_ok=True)
return d
def accounts_json_path() -> Path:
return data_dir() / "accounts.json"

1
src/imwald/core/__init__.py

@ -0,0 +1 @@ @@ -0,0 +1 @@
# Core: database, nostr, relays, ranking.

97
src/imwald/core/accounts_store.py

@ -0,0 +1,97 @@ @@ -0,0 +1,97 @@
"""Stored accounts — Jumble-compatible fields (nsec / ncryptsec / pubkey)."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from coincurve import PrivateKey
from imwald.config import accounts_json_path
from .nip19 import decode_nsec, encode_nsec
from .nip49 import decrypt_ncryptsec, encrypt_to_ncryptsec
from .nostr_crypto import pubkey_hex_from_secret
@dataclass
class StoredAccount:
pubkey: str
signer_type: str # 'nsec' | 'ncryptsec'
nsec: str | None = None
ncryptsec: str | None = None
label: str | None = None
def to_json(self) -> dict[str, Any]:
d: dict[str, Any] = {"pubkey": self.pubkey, "signerType": self.signer_type}
if self.nsec:
d["nsec"] = self.nsec
if self.ncryptsec:
d["ncryptsec"] = self.ncryptsec
if self.label:
d["label"] = self.label
return d
@classmethod
def from_json(cls, d: dict[str, Any]) -> StoredAccount:
return cls(
pubkey=str(d["pubkey"]).lower(),
signer_type=str(d.get("signerType") or d.get("signer_type") or "nsec"),
nsec=d.get("nsec"),
ncryptsec=d.get("ncryptsec"),
label=d.get("label"),
)
def load_accounts(path: Path | None = None) -> list[StoredAccount]:
p = path or accounts_json_path()
if not p.exists():
return []
data = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(data, list):
return []
return [StoredAccount.from_json(x) for x in data if isinstance(x, dict)]
def save_accounts(accounts: list[StoredAccount], path: Path | None = None) -> None:
p = path or accounts_json_path()
p.write_text(
json.dumps([a.to_json() for a in accounts], indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
def add_account_nsec_hex(secret_hex: str, password: str | None, label: str | None = None) -> StoredAccount:
h = secret_hex.strip()
sk = decode_nsec(h) if h.startswith("nsec") else bytes.fromhex(h)
pk_hex = pubkey_hex_from_secret(sk)
if password:
nc = encrypt_to_ncryptsec(sk, password)
acc = StoredAccount(pubkey=pk_hex, signer_type="ncryptsec", ncryptsec=nc, label=label)
else:
acc = StoredAccount(pubkey=pk_hex, signer_type="nsec", nsec=encode_nsec(sk), label=label)
return acc
def unlock_secret(account: StoredAccount, password: str | None) -> bytes:
if account.signer_type == "nsec" and account.nsec:
h = account.nsec.strip()
if h.startswith("nsec"):
return decode_nsec(h)
if len(h) == 64:
return bytes.fromhex(h)
raise ValueError("invalid stored nsec")
if account.signer_type == "ncryptsec" and account.ncryptsec:
if not password:
raise ValueError("password required for ncryptsec")
return decrypt_ncryptsec(account.ncryptsec, password)
raise ValueError("cannot unlock account")
def generate_new_identity() -> tuple[bytes, str]:
sk = PrivateKey()
sec = sk.secret
pub = pubkey_hex_from_secret(sec)
return sec, pub

538
src/imwald/core/database.py

@ -0,0 +1,538 @@ @@ -0,0 +1,538 @@
"""SQLite WAL store — single source of truth for imwald."""
from __future__ import annotations
import json
import sqlite3
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Generator, Iterable
SCHEMA_VERSION = 2
DDL = """
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
created_at INTEGER NOT NULL,
kind INTEGER NOT NULL,
content TEXT NOT NULL,
sig TEXT NOT NULL,
tags_json TEXT NOT NULL,
raw_json TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
received_at INTEGER NOT NULL,
source_relay TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey);
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at DESC);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
name TEXT NOT NULL,
value TEXT NOT NULL,
pos INTEGER NOT NULL,
UNIQUE(event_id, pos)
);
CREATE INDEX IF NOT EXISTS idx_tags_name_val ON tags(name, value);
CREATE TABLE IF NOT EXISTS event_relations (
event_id TEXT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
relation TEXT NOT NULL,
target TEXT NOT NULL,
PRIMARY KEY(event_id, relation, target)
);
CREATE TABLE IF NOT EXISTS relays (
url TEXT PRIMARY KEY,
read INTEGER NOT NULL DEFAULT 1,
write INTEGER NOT NULL DEFAULT 1,
last_connected_at INTEGER,
last_error TEXT,
state TEXT NOT NULL DEFAULT 'disconnected'
);
CREATE TABLE IF NOT EXISTS sync_cursors (
relay_url TEXT NOT NULL,
subscription_key TEXT NOT NULL,
until INTEGER,
since INTEGER,
PRIMARY KEY(relay_url, subscription_key)
);
CREATE TABLE IF NOT EXISTS media_objects (
sha256 TEXT PRIMARY KEY,
path TEXT NOT NULL,
bytes INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS media_event_links (
sha256 TEXT NOT NULL REFERENCES media_objects(sha256) ON DELETE CASCADE,
event_id TEXT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
tag_name TEXT,
PRIMARY KEY(sha256, event_id, tag_name)
);
CREATE TABLE IF NOT EXISTS user_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
voter_pubkey TEXT NOT NULL,
vote INTEGER NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(event_id, voter_pubkey)
);
CREATE TABLE IF NOT EXISTS rank_features (
event_id TEXT PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE,
score REAL NOT NULL,
why_json TEXT
);
CREATE TABLE IF NOT EXISTS accounts (
pubkey TEXT PRIMARY KEY,
label TEXT,
signer_type TEXT NOT NULL,
nsec TEXT,
ncryptsec TEXT,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS translation_cache (
key TEXT PRIMARY KEY,
lang TEXT NOT NULL,
text TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient_pubkey TEXT NOT NULL,
source_event_id TEXT NOT NULL,
kind TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
UNIQUE(recipient_pubkey, source_event_id, kind)
);
CREATE INDEX IF NOT EXISTS idx_notif_recipient ON notifications(recipient_pubkey, read);
CREATE TABLE IF NOT EXISTS feed_views (
viewer_pubkey TEXT NOT NULL,
event_id TEXT NOT NULL,
seen_at INTEGER NOT NULL,
PRIMARY KEY(viewer_pubkey, event_id)
);
CREATE INDEX IF NOT EXISTS idx_feed_views_event ON feed_views(event_id);
"""
class Database:
def __init__(self, path: Path) -> None:
self.path = path
path.parent.mkdir(parents=True, exist_ok=True)
self._conn: sqlite3.Connection | None = None
def connect(self) -> None:
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.executescript(DDL)
cur = self._conn.execute("SELECT value FROM meta WHERE key='schema_version'")
row = cur.fetchone()
if row is None:
self._conn.execute(
"INSERT INTO meta(key,value) VALUES ('schema_version', ?)",
(str(SCHEMA_VERSION),),
)
self._conn.commit()
self._migrate_schema()
def close(self) -> None:
if self._conn:
self._conn.close()
self._conn = None
@contextmanager
def write_lock(self) -> Generator[sqlite3.Connection, None, None]:
if not self._conn:
raise RuntimeError("database not connected")
try:
yield self._conn
self._conn.commit()
except Exception:
self._conn.rollback()
raise
def conn(self) -> sqlite3.Connection:
if not self._conn:
raise RuntimeError("database not connected")
return self._conn
def _migrate_schema(self) -> None:
"""Light migrations for existing DBs created before new columns/tables."""
c = self._conn
if not c:
return
cols = {str(r[1]) for r in c.execute("PRAGMA table_info(events)")}
if "source_relay" not in cols:
c.execute("ALTER TABLE events ADD COLUMN source_relay TEXT")
c.execute(
"""
CREATE TABLE IF NOT EXISTS feed_views (
viewer_pubkey TEXT NOT NULL,
event_id TEXT NOT NULL,
seen_at INTEGER NOT NULL,
PRIMARY KEY(viewer_pubkey, event_id)
)
"""
)
c.execute("CREATE INDEX IF NOT EXISTS idx_feed_views_event ON feed_views(event_id)")
c.commit()
def upsert_event(
self,
ev: dict[str, Any],
*,
deleted: bool = False,
source_relay: str | None = None,
) -> None:
"""Insert or replace event; expand tags into tags table."""
eid = ev["id"]
tags = ev.get("tags") or []
tags_json = json.dumps(tags, ensure_ascii=False)
raw = json.dumps(ev, ensure_ascii=False)
with self.write_lock() as c:
c.execute("DELETE FROM tags WHERE event_id=?", (eid,))
c.execute("DELETE FROM event_relations WHERE event_id=?", (eid,))
c.execute(
"""
INSERT INTO events(id,pubkey,created_at,kind,content,sig,tags_json,raw_json,deleted,received_at,source_relay)
VALUES(?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
pubkey=excluded.pubkey,
created_at=excluded.created_at,
kind=excluded.kind,
content=excluded.content,
sig=excluded.sig,
tags_json=excluded.tags_json,
raw_json=excluded.raw_json,
deleted=excluded.deleted,
received_at=excluded.received_at,
source_relay=COALESCE(excluded.source_relay, events.source_relay)
""",
(
eid,
ev["pubkey"],
int(ev["created_at"]),
int(ev["kind"]),
ev.get("content") or "",
ev["sig"],
tags_json,
raw,
1 if deleted else 0,
int(time.time()),
source_relay,
),
)
for pos, t in enumerate(tags):
if not t:
continue
name = str(t[0])
val = str(t[1]) if len(t) > 1 else ""
c.execute(
"INSERT INTO tags(event_id,name,value,pos) VALUES(?,?,?,?)",
(eid, name, val, pos),
)
if name in ("e", "p", "a"):
c.execute(
"INSERT OR IGNORE INTO event_relations(event_id,relation,target) VALUES(?,?,?)",
(eid, name, val),
)
def tombstone_event(self, event_id: str) -> None:
with self.write_lock() as c:
c.execute(
"UPDATE events SET deleted=1, received_at=? WHERE id=?",
(int(time.time()), event_id),
)
def purge_event_local(self, event_id: str) -> None:
with self.write_lock() as c:
c.execute("DELETE FROM events WHERE id=?", (event_id,))
def get_event(self, event_id: str) -> dict[str, Any] | None:
cur = self.conn().execute(
"SELECT id,pubkey,created_at,kind,content,sig,tags_json,deleted,source_relay FROM events WHERE id=?",
(event_id,),
)
row = cur.fetchone()
if not row:
return None
return {
"id": row["id"],
"pubkey": row["pubkey"],
"created_at": row["created_at"],
"kind": row["kind"],
"content": row["content"],
"sig": row["sig"],
"tags": json.loads(row["tags_json"] or "[]"),
"deleted": bool(row["deleted"]),
"source_relay": row["source_relay"],
}
def feed_candidates(
self,
kinds: Iterable[int],
*,
hide_nsfw: bool = True,
limit: int = 200,
viewer_pubkey: str | None = None,
exclude_viewed: bool = True,
) -> list[dict[str, Any]]:
kind_list = list(kinds)
placeholders = ",".join("?" * len(kind_list))
sql = f"""
SELECT id,pubkey,created_at,kind,content,sig,tags_json,deleted,source_relay FROM events
WHERE deleted=0 AND kind IN ({placeholders})
"""
args: list[Any] = list(kind_list)
if hide_nsfw:
sql += (
" AND id NOT IN (SELECT event_id FROM tags WHERE name='t' AND lower(value)='nsfw') "
"AND id NOT IN (SELECT event_id FROM tags WHERE name='content-warning')"
)
if exclude_viewed and viewer_pubkey:
sql += " AND id NOT IN (SELECT event_id FROM feed_views WHERE viewer_pubkey=?)"
args.append(viewer_pubkey.lower())
sql += " ORDER BY created_at DESC LIMIT ?"
args.append(limit)
out: list[dict[str, Any]] = []
for row in self.conn().execute(sql, args):
out.append(
{
"id": row["id"],
"pubkey": row["pubkey"],
"created_at": row["created_at"],
"kind": row["kind"],
"content": row["content"],
"sig": row["sig"],
"tags": json.loads(row["tags_json"] or "[]"),
"source_relay": row["source_relay"],
}
)
return out
def mark_feed_viewed(self, viewer_pubkey: str, event_id: str) -> None:
"""Remember this event was shown in the feed so we do not surface it again."""
with self.write_lock() as c:
c.execute(
"""
INSERT INTO feed_views(viewer_pubkey, event_id, seen_at)
VALUES(?,?,?)
ON CONFLICT(viewer_pubkey, event_id) DO UPDATE SET seen_at=excluded.seen_at
""",
(viewer_pubkey.lower(), event_id, int(time.time())),
)
def is_feed_viewed(self, viewer_pubkey: str, event_id: str) -> bool:
cur = self.conn().execute(
"SELECT 1 FROM feed_views WHERE viewer_pubkey=? AND event_id=? LIMIT 1",
(viewer_pubkey.lower(), event_id),
)
return cur.fetchone() is not None
def list_following_pubkeys(self, my_pubkey: str) -> set[str]:
"""Pubkeys from the latest kind-3 contact list for `my_pubkey` (NIP-02 JSON + `p` tags)."""
cur = self.conn().execute(
"""
SELECT content, tags_json FROM events
WHERE deleted=0 AND kind=3 AND pubkey=? ORDER BY created_at DESC LIMIT 1
""",
(my_pubkey.lower(),),
)
row = cur.fetchone()
if not row:
return set()
out: set[str] = set()
content = row["content"] or ""
try:
data = json.loads(content)
if isinstance(data, list):
for x in data:
if isinstance(x, str) and len(x) == 64:
out.add(x.lower())
elif isinstance(x, dict) and "pubkey" in x:
pk = str(x["pubkey"])
if len(pk) == 64:
out.add(pk.lower())
except json.JSONDecodeError:
pass
try:
tags = json.loads(row["tags_json"] or "[]")
for t in tags:
if t and t[0] == "p" and len(t) > 1 and len(t[1]) == 64:
out.add(str(t[1]).lower())
except json.JSONDecodeError:
pass
return out
def list_kind30000_list_pubkeys(self, my_pubkey: str) -> set[str]:
"""Hex pubkeys listed on this user's kind 30000 (NIP-51 people lists) via `p` tags."""
cur = self.conn().execute(
"""
SELECT DISTINCT lower(t.value) AS pk
FROM tags t
INNER JOIN events e ON e.id = t.event_id
WHERE e.deleted = 0 AND e.kind = 30000 AND lower(e.pubkey) = lower(?)
AND t.name = 'p' AND length(t.value) = 64
""",
(my_pubkey,),
)
out: set[str] = set()
for row in cur:
pk = str(row["pk"])
if len(pk) == 64 and all(c in "0123456789abcdef" for c in pk):
out.add(pk)
return out
def list_replies_to(self, event_id: str, limit: int = 80) -> list[dict[str, Any]]:
cur = self.conn().execute(
"""
SELECT e.id, e.pubkey, e.created_at, e.kind, e.content, e.sig, e.tags_json
FROM events e
JOIN tags t ON t.event_id = e.id AND t.name = 'e' AND t.value = ?
WHERE e.deleted = 0
ORDER BY e.created_at ASC
LIMIT ?
""",
(event_id, limit),
)
return [
{
"id": r["id"],
"pubkey": r["pubkey"],
"created_at": r["created_at"],
"kind": r["kind"],
"content": r["content"],
"sig": r["sig"],
"tags": json.loads(r["tags_json"] or "[]"),
}
for r in cur
]
def search_local(self, query: str, limit: int = 100) -> list[dict[str, Any]]:
q = f"%{query}%"
cur = self.conn().execute(
"""
SELECT id,pubkey,created_at,kind,content,sig,tags_json FROM events
WHERE deleted=0 AND (content LIKE ? OR id LIKE ? OR pubkey LIKE ?)
ORDER BY created_at DESC LIMIT ?
""",
(q, q, q, limit),
)
rows = []
for row in cur:
rows.append(
{
"id": row["id"],
"pubkey": row["pubkey"],
"created_at": row["created_at"],
"kind": row["kind"],
"content": row["content"],
"sig": row["sig"],
"tags": json.loads(row["tags_json"] or "[]"),
}
)
return rows
def list_my_events(self, pubkey: str, limit: int = 200) -> list[dict[str, Any]]:
cur = self.conn().execute(
"""
SELECT id,pubkey,created_at,kind,content,sig,tags_json FROM events
WHERE deleted=0 AND pubkey=? ORDER BY created_at DESC LIMIT ?
""",
(pubkey, limit),
)
return [
{
"id": r["id"],
"pubkey": r["pubkey"],
"created_at": r["created_at"],
"kind": r["kind"],
"content": r["content"],
"sig": r["sig"],
"tags": json.loads(r["tags_json"] or "[]"),
}
for r in cur
]
def set_vote(self, event_id: str, voter_pubkey: str, vote: int) -> None:
with self.write_lock() as c:
c.execute(
"""
INSERT INTO user_votes(event_id,voter_pubkey,vote,created_at)
VALUES(?,?,?,?)
ON CONFLICT(event_id,voter_pubkey) DO UPDATE SET vote=excluded.vote, created_at=excluded.created_at
""",
(event_id, voter_pubkey, vote, int(time.time())),
)
def get_vote(self, event_id: str, voter_pubkey: str) -> int | None:
cur = self.conn().execute(
"SELECT vote FROM user_votes WHERE event_id=? AND voter_pubkey=?",
(event_id, voter_pubkey),
)
row = cur.fetchone()
return int(row["vote"]) if row else None
def get_setting(self, key: str, default: str | None = None) -> str | None:
cur = self.conn().execute("SELECT value FROM settings WHERE key=?", (key,))
row = cur.fetchone()
if row:
return str(row[0])
return default
def set_setting(self, key: str, value: str) -> None:
with self.write_lock() as c:
c.execute(
"INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
def list_tables(self) -> list[str]:
cur = self.conn().execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
)
return [r[0] for r in cur.fetchall()]
def table_columns(self, table: str) -> list[str]:
cur = self.conn().execute(f'PRAGMA table_info("{table}")')
return [str(r[1]) for r in cur.fetchall()]
def select_safe(self, sql: str, params: tuple[Any, ...] = ()) -> tuple[list[str], list[sqlite3.Row]]:
s = sql.strip().lower()
if not s.startswith("select"):
raise ValueError("only SELECT queries are allowed")
forbidden = (" insert ", " update ", " delete ", " drop ", " attach ", " pragma ")
padded = f" {s} "
for f in forbidden:
if f in padded:
raise ValueError(f"forbidden token in query: {f.strip()}")
cur = self.conn().execute(sql, params)
cols = [d[0] for d in cur.description] if cur.description else []
return cols, cur.fetchall()

27
src/imwald/core/nip19.py

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
"""Minimal NIP-19: decode nsec / npub hex payload."""
from __future__ import annotations
import bech32
def decode_nsec(nsec: str) -> bytes:
hrp, data = bech32.bech32_decode(nsec.strip())
if hrp != "nsec" or data is None:
raise ValueError("invalid nsec")
return bytes(bech32.convertbits(data, 5, 8, False))
def encode_nsec(secret: bytes) -> str:
if len(secret) != 32:
raise ValueError("secret must be 32 bytes")
conv = bech32.convertbits(list(secret), 8, 5, True)
return bech32.bech32_encode("nsec", conv)
def encode_npub(pubkey_hex: str) -> str:
raw = bytes.fromhex(pubkey_hex.strip())
if len(raw) != 32:
raise ValueError("pubkey must be 32 bytes hex")
conv = bech32.convertbits(list(raw), 8, 5, True)
return bech32.bech32_encode("npub", conv)

87
src/imwald/core/nip49.py

@ -0,0 +1,87 @@ @@ -0,0 +1,87 @@
"""NIP-49 ncryptsec encrypt/decrypt — compatible with Jumble (nostr-tools nip49)."""
from __future__ import annotations
import hashlib
import os
import unicodedata
from typing import Final
import bech32
from nacl.bindings import crypto_aead_xchacha20poly1305_ietf_decrypt, crypto_aead_xchacha20poly1305_ietf_encrypt
VERSION: Final = 0x02
KEY_SECURITY_MEDIUM: Final = 0x01
_DEFAULT_LOG_N: Final = 18
_SCRYPT_R: Final = 8
_SCRYPT_P: Final = 1
def _normalize_password(password: str) -> bytes:
return unicodedata.normalize("NFKC", password).encode("utf-8")
def _decode_ncryptsec(ncryptsec: str) -> bytes:
hrp, data = bech32.bech32_decode(ncryptsec)
if hrp != "ncryptsec" or data is None:
raise ValueError("invalid ncryptsec hrp or data")
return bytes(bech32.convertbits(data, 5, 8, False))
def _encode_ncryptsec(payload: bytes) -> str:
data = bech32.convertbits(list(payload), 8, 5, True)
return bech32.bech32_encode("ncryptsec", data)
def decrypt_ncryptsec(ncryptsec: str, password: str) -> bytes:
"""Return 32-byte secret key from ncryptsec bech32 string."""
raw = _decode_ncryptsec(ncryptsec.strip())
if len(raw) != 91:
raise ValueError(f"invalid ncryptsec payload length {len(raw)}")
version = raw[0]
if version != VERSION:
raise ValueError(f"unsupported ncryptsec version {version:#x}")
log_n = raw[1]
salt = raw[2:18]
nonce = raw[18:42]
aad = raw[42:43]
ciphertext = raw[43:]
n = 1 << log_n
# OpenSSL scrypt enforces a max memory bound; raise for large log_n (e.g. 20 ~= 1 GiB).
maxmem = max(256 * 1024 * 1024, 128 * n * _SCRYPT_R)
key = hashlib.scrypt(
_normalize_password(password),
salt=salt,
n=n,
r=_SCRYPT_R,
p=_SCRYPT_P,
maxmem=maxmem,
dklen=32,
)
plain = crypto_aead_xchacha20poly1305_ietf_decrypt(ciphertext, aad, nonce, key)
if len(plain) != 32:
raise ValueError("invalid decrypted key length")
return plain
def encrypt_to_ncryptsec(secret_key: bytes, password: str, log_n: int = _DEFAULT_LOG_N) -> str:
"""Encrypt 32-byte secret key to ncryptsec (NIP-49)."""
if len(secret_key) != 32:
raise ValueError("secret key must be 32 bytes")
salt = os.urandom(16)
nonce = os.urandom(24)
aad = bytes([KEY_SECURITY_MEDIUM])
n = 1 << log_n
maxmem = max(256 * 1024 * 1024, 128 * n * _SCRYPT_R)
sym = hashlib.scrypt(
_normalize_password(password),
salt=salt,
n=n,
r=_SCRYPT_R,
p=_SCRYPT_P,
maxmem=maxmem,
dklen=32,
)
ciphertext = crypto_aead_xchacha20poly1305_ietf_encrypt(secret_key, aad, nonce, sym)
payload = bytes([VERSION, log_n]) + salt + nonce + aad + ciphertext
return _encode_ncryptsec(payload)

57
src/imwald/core/nostr_crypto.py

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
"""Nostr event ids (NIP-01) and Schnorr signatures (BIP-340) via coincurve."""
from __future__ import annotations
import json
from hashlib import sha256
from typing import Any
from coincurve import PrivateKey
def serialize_event_for_id(pubkey: str, created_at: int, kind: int, tags: list, content: str) -> str:
arr = [0, pubkey, created_at, kind, tags, content]
return json.dumps(arr, ensure_ascii=False, separators=(",", ":"))
def event_id_hex(pubkey: str, created_at: int, kind: int, tags: list, content: str) -> str:
ser = serialize_event_for_id(pubkey, created_at, kind, tags, content)
return sha256(ser.encode("utf-8")).hexdigest()
def pubkey_hex_from_secret(secret: bytes) -> str:
pk = PrivateKey(secret)
fmt = pk.public_key.format(compressed=False)
return fmt[1:33].hex()
def sign_event_id(secret: bytes, event_id_hex_str: str) -> str:
"""Schnorr sign the 32-byte event id (binary), return 128-char hex sig."""
pk = PrivateKey(secret)
digest = bytes.fromhex(event_id_hex_str)
if len(digest) != 32:
raise ValueError("event id must be 32 bytes hex")
sig = pk.sign_schnorr(digest)
return sig.hex()
def build_signed_event(
secret: bytes,
*,
created_at: int,
kind: int,
tags: list,
content: str,
) -> dict[str, Any]:
pubkey = pubkey_hex_from_secret(secret)
eid = event_id_hex(pubkey, created_at, kind, tags, content)
sig = sign_event_id(secret, eid)
return {
"id": eid,
"pubkey": pubkey,
"created_at": created_at,
"kind": kind,
"tags": tags,
"content": content,
"sig": sig,
}

174
src/imwald/core/nostr_engine.py

@ -0,0 +1,174 @@ @@ -0,0 +1,174 @@
"""Coordinates relay ingest (signals), ranking, and publish."""
from __future__ import annotations
import asyncio
import json
import logging
import threading
import time
from typing import Any
from PySide6.QtCore import QObject, Signal
from imwald.core.accounts_store import StoredAccount, unlock_secret
from imwald.core.database import Database
from imwald.core.nostr_crypto import build_signed_event
from imwald.core.nostr_publish import publish_to_relays_sync
from imwald.core.ranker import Ranker
from imwald.core.relay_manager import RelayManager
from imwald.core.relay_policy import (
AGGR_THREAD_RELAY,
DEFAULT_WRITE_RELAYS,
WISP_TRENDING_FEED_KINDS,
default_feed_read_relays,
is_wisp_trending_relay_url,
use_aggr_for_threads,
)
log = logging.getLogger(__name__)
class NostrEngine(QObject):
"""Relay worker thread emits `event_ingested` — connect on UI thread to write SQLite."""
event_ingested = Signal(str, object)
relay_status = Signal(str)
def __init__(self, db: Database) -> None:
super().__init__()
self.db = db
self.ranker = Ranker(db)
self._thread: threading.Thread | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._manager: RelayManager | None = None
self._app_stop: asyncio.Event | None = None
def start_relays(
self,
read_urls: list[str] | None = None,
*,
list30000_owner: str | None = None,
) -> None:
if self._thread and self._thread.is_alive():
return
urls = list(read_urls or default_feed_read_relays())
k3000_owner = (list30000_owner or "").strip().lower()
def runner() -> None:
async def on_ev(url: str, ev: dict[str, Any]) -> None:
self.event_ingested.emit(url, ev)
async def on_notice(url: str, text: str) -> None:
self.relay_status.emit(f"{url} NOTICE {text}")
async def amain() -> None:
app_stop = asyncio.Event()
self._app_stop = app_stop
mgr = RelayManager(on_event=on_ev, on_notice=on_notice)
self._manager = mgr
kinds_main = [1, 20, 21, 30023, 9802, 11]
for u in urls:
mgr.register(u)
kinds = list(WISP_TRENDING_FEED_KINDS) if is_wisp_trending_relay_url(u) else kinds_main
mgr.request_subscribe(
u,
f"imwald-{abs(hash(u)) % 10**8}",
[{"kinds": kinds, "limit": 220}],
)
if use_aggr_for_threads(set(DEFAULT_WRITE_RELAYS)):
mgr.register(AGGR_THREAD_RELAY)
mgr.request_subscribe(
AGGR_THREAD_RELAY,
"imwald-aggr",
[{"kinds": [1], "limit": 120}],
)
if len(k3000_owner) == 64 and all(c in "0123456789abcdef" for c in k3000_owner):
for u in urls:
sid = f"imwald-k30000-{abs(hash(f'{u}:{k3000_owner}')) % 10**9}"
mgr.request_subscribe(
u,
sid,
[{"kinds": [30000], "authors": [k3000_owner], "limit": 150}],
)
await mgr.connect_all()
await app_stop.wait()
await mgr.stop()
loop = asyncio.new_event_loop()
self._loop = loop
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(amain())
finally:
self._loop = None
self._manager = None
self._app_stop = None
loop.close()
self._thread = threading.Thread(target=runner, name="nostr-relay", daemon=True)
self._thread.start()
def stop_relays(self) -> None:
if self._loop and self._app_stop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._app_stop.set)
if self._thread:
self._thread.join(timeout=5.0)
self._thread = None
@staticmethod
def apply_ingest_to_db(db: Database, ev: dict[str, Any], source_relay: str | None = None) -> None:
if not isinstance(ev, dict) or "id" not in ev:
return
if ev.get("kind") == 5:
for t in ev.get("tags") or []:
if t and t[0] == "e" and len(t) > 1:
db.tombstone_event(t[1])
db.upsert_event(ev, source_relay=source_relay)
def publish_kind0_and_lists(
self,
account: StoredAccount,
password: str | None,
*,
name: str,
about: str,
interest_tags: list[str],
languages: list[str],
) -> None:
sec = unlock_secret(account, password)
now = int(time.time())
kind0 = build_signed_event(
sec,
created_at=now,
kind=0,
tags=[["client", "imwald"]],
content=json.dumps({"name": name, "about": about}, ensure_ascii=False),
)
tags_10002: list[list[str]] = [["client", "imwald"]]
for r in DEFAULT_READ_RELAYS:
tags_10002.extend([["r", r], ["read", "true"]])
for r in DEFAULT_WRITE_RELAYS:
tags_10002.extend([["r", r], ["write", "true"]])
ev10002 = build_signed_event(sec, created_at=now, kind=10002, tags=tags_10002, content="")
tags_10015 = [["client", "imwald"]]
for t in interest_tags[:50]:
tt = t if t.startswith("#") else f"#{t}"
tags_10015.append(["t", tt[1:] if tt.startswith("#") else tt])
for lang in languages[:3]:
tags_10015.append(["l", lang])
ev10015 = build_signed_event(sec, created_at=now, kind=10015, tags=tags_10015, content="")
write_urls = list(DEFAULT_WRITE_RELAYS)
publish_to_relays_sync(write_urls, kind0)
publish_to_relays_sync(write_urls, ev10002)
publish_to_relays_sync(write_urls, ev10015)
for ev in (kind0, ev10002, ev10015):
self.db.upsert_event(ev)
def publish_nip09_deletion(self, account: StoredAccount, password: str | None, target_event_id: str) -> None:
sec = unlock_secret(account, password)
now = int(time.time())
tags = [["e", target_event_id], ["k", "1"]]
ev = build_signed_event(sec, created_at=now, kind=5, tags=tags, content="")
publish_to_relays_sync(list(DEFAULT_WRITE_RELAYS), ev)
self.db.upsert_event(ev)

44
src/imwald/core/nostr_publish.py

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
"""Publish signed events to write relays (WebSocket)."""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
import websockets
log = logging.getLogger(__name__)
async def publish_to_relays(urls: list[str], event: dict[str, Any], timeout: float = 12.0) -> dict[str, bool]:
results: dict[str, bool] = {}
async def one(url: str) -> None:
u = url.strip()
if u.startswith("http://") or u.startswith("https://"):
log.warning("HTTP relay publish not implemented: %s", u)
results[url] = False
return
if u.startswith("ws://") or u.startswith("wss://"):
ws_url = u
else:
ws_url = "wss://" + u
try:
async with websockets.connect(ws_url, ping_interval=20, open_timeout=timeout) as ws:
await ws.send(json.dumps(["EVENT", event]))
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
msg = json.loads(raw)
ok = isinstance(msg, list) and len(msg) >= 4 and msg[0] == "OK" and bool(msg[2])
results[url] = ok
except Exception as e: # noqa: BLE001
log.info("publish fail %s: %s", url, e)
results[url] = False
await asyncio.gather(*(one(u) for u in urls))
return results
def publish_to_relays_sync(urls: list[str], event: dict[str, Any]) -> dict[str, bool]:
return asyncio.run(publish_to_relays(urls, event))

39
src/imwald/core/nostr_types.py

@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class NostrEvent:
id: str
pubkey: str
created_at: int
kind: int
tags: list[list[str]]
content: str
sig: str
@classmethod
def from_row(cls, row: dict[str, Any]) -> NostrEvent:
import json
tags = json.loads(row["tags_json"] or "[]")
return cls(
id=row["id"],
pubkey=row["pubkey"],
created_at=int(row["created_at"]),
kind=int(row["kind"]),
tags=tags,
content=row["content"] or "",
sig=row["sig"],
)
def to_dict_unsigned(self) -> dict[str, Any]:
return {
"pubkey": self.pubkey,
"created_at": self.created_at,
"kind": self.kind,
"tags": self.tags,
"content": self.content,
}

109
src/imwald/core/ranker.py

@ -0,0 +1,109 @@ @@ -0,0 +1,109 @@
"""Local feed ranking: follows first, trending slice, votes, graph stubs."""
from __future__ import annotations
import json
from typing import Any
from imwald.core.relay_policy import is_wisp_trending_relay_url
from .database import Database
# Followed authors should dominate the feed vs algorithmic/trending slices.
WEIGHT_FOLLOW_AUTHOR = 50_000.0
# Kind 30000 list members (NIP-51) — meaningful boost, but below follows.
WEIGHT_KIND30000_LIST_AUTHOR = 8_000.0
WEIGHT_TRENDING_RELAY = 120.0
WEIGHT_REPLY = 5.0
WEIGHT_QUOTE = 5.0
WEIGHT_BOOST = 5.0
WEIGHT_ZAP = 3.0
WEIGHT_LOCAL_VOTE = 1.0
def _tags_contain_repost(tags: list) -> bool:
for t in tags:
if not t:
continue
if t[0] == "kind" and len(t) > 1 and t[1] == "6":
return True
if t[0] == "q":
return True
return False
class Ranker:
def __init__(self, db: Database) -> None:
self._db = db
def score_event(
self,
ev: dict[str, Any],
*,
my_pubkey: str | None,
following: set[str],
list30000_pubkeys: set[str],
) -> tuple[float, dict[str, Any]]:
why: dict[str, Any] = {}
score = 0.0
pk = (ev.get("pubkey") or "").lower()
fol = {f.lower() for f in following}
lists = {x.lower() for x in list30000_pubkeys}
if my_pubkey:
v = self._db.get_vote(ev["id"], my_pubkey)
if v is not None:
w = WEIGHT_LOCAL_VOTE * v
score += w
why["local_vote"] = {"vote": v, "contribution": w}
if pk and pk in fol:
score += WEIGHT_FOLLOW_AUTHOR
why["follow_author"] = WEIGHT_FOLLOW_AUTHOR
elif pk and pk in lists:
score += WEIGHT_KIND30000_LIST_AUTHOR
why["kind30000_list"] = WEIGHT_KIND30000_LIST_AUTHOR
sr = ev.get("source_relay") or ""
if sr and is_wisp_trending_relay_url(sr):
score += WEIGHT_TRENDING_RELAY
why["trending_relay"] = WEIGHT_TRENDING_RELAY
tags = ev.get("tags") or []
if _tags_contain_repost(tags):
score += WEIGHT_BOOST
why["repost_or_quote_hint"] = WEIGHT_BOOST
why["note"] = "Extend with reply/boost/zap counts from DB event_relations + kind 9735."
return score, why
def persist_score(self, event_id: str, score: float, why: dict[str, Any]) -> None:
with self._db.write_lock() as c:
c.execute(
"""
INSERT INTO rank_features(event_id, score, why_json)
VALUES(?,?,?)
ON CONFLICT(event_id) DO UPDATE SET score=excluded.score, why_json=excluded.why_json
""",
(event_id, score, json.dumps(why, ensure_ascii=False)),
)
def rank_feed(
self,
events: list[dict[str, Any]],
my_pubkey: str | None,
following: set[str],
list30000_pubkeys: set[str],
) -> list[dict[str, Any]]:
following_l = {f.lower() for f in following}
lists_l = {x.lower() for x in list30000_pubkeys}
scored: list[tuple[float, dict[str, Any], dict[str, Any]]] = []
for ev in events:
s, why = self.score_event(
ev,
my_pubkey=my_pubkey,
following=following_l,
list30000_pubkeys=lists_l,
)
scored.append((s, ev, why))
# Highest score first (follow boost + trending + votes), then recency.
scored.sort(key=lambda x: (x[0], x[1]["created_at"]), reverse=True)
for s, ev, why in scored:
self.persist_score(ev["id"], s, why)
return [ev for _, ev, _ in scored]

175
src/imwald/core/relay_manager.py

@ -0,0 +1,175 @@ @@ -0,0 +1,175 @@
"""Per-relay connection state, backoff, and transparent status (Jumble-style)."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import random
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Coroutine
import websockets
from websockets.client import WebSocketClientProtocol
log = logging.getLogger(__name__)
class RelayState(str, Enum):
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
BACKOFF = "backoff"
ERROR = "error"
@dataclass
class RelayConn:
url: str
state: RelayState = RelayState.DISCONNECTED
last_error: str | None = None
last_connected_at: float | None = None
backoff_until: float = 0.0
_ws: WebSocketClientProtocol | None = field(default=None, repr=False)
_task: asyncio.Task[None] | None = field(default=None, repr=False)
def status_line(self) -> str:
err = f" ({self.last_error})" if self.last_error else ""
return f"{self.url}: {self.state.value}{err}"
def _normalize_ws_url(url: str) -> str:
u = url.strip()
if u.startswith("http://"):
return "ws://" + u[len("http://") :]
if u.startswith("https://"):
return "wss://" + u[len("https://") :]
return u
class RelayManager:
"""Owns asyncio tasks; call from a dedicated thread running `asyncio.run`."""
def __init__(
self,
on_event: Callable[[str, dict[str, Any]], Coroutine[Any, Any, None]],
on_notice: Callable[[str, str], Coroutine[Any, Any, None]] | None = None,
) -> None:
self._on_event = on_event
self._on_notice = on_notice
self._relays: dict[str, RelayConn] = {}
self._subs: dict[str, dict[str, Any]] = {}
self._shutdown = asyncio.Event()
def register(self, url: str) -> RelayConn:
url = _normalize_ws_url(url)
if url not in self._relays:
self._relays[url] = RelayConn(url=url)
return self._relays[url]
def all_relays(self) -> list[RelayConn]:
return list(self._relays.values())
async def connect_all(self) -> None:
for url in list(self._relays.keys()):
await self._ensure_connected(url)
async def stop(self) -> None:
self._shutdown.set()
for r in self._relays.values():
if r._task:
r._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await r._task
r._task = None
if r._ws:
await r._ws.close()
r._ws = None
def request_subscribe(self, relay_url: str, sub_id: str, filters: list[dict[str, Any]]) -> None:
relay_url = _normalize_ws_url(relay_url)
self._subs[f"{relay_url}:{sub_id}"] = {"relay": relay_url, "sub_id": sub_id, "filters": filters}
if relay_url in self._relays and self._relays[relay_url]._ws:
asyncio.create_task(self._send_req(relay_url, sub_id, filters))
async def _send_req(self, relay_url: str, sub_id: str, filters: list[dict[str, Any]]) -> None:
r = self._relays.get(relay_url)
if not r or not r._ws:
return
msg = json.dumps(["REQ", sub_id, *filters])
await r._ws.send(msg)
async def _ensure_connected(self, url: str) -> None:
r = self._relays[url]
now = time.monotonic()
if r.state == RelayState.BACKOFF and now < r.backoff_until:
return
if r._ws and r.state == RelayState.CONNECTED:
return
if r._task and not r._task.done():
return
r._task = asyncio.create_task(self._run_relay(url))
async def _run_relay(self, url: str) -> None:
r = self._relays[url]
attempt = 0
while not self._shutdown.is_set():
r.state = RelayState.CONNECTING
r.last_error = None
try:
log.info("connecting %s", url)
async with websockets.connect(
url,
ping_interval=20,
ping_timeout=20,
close_timeout=5,
max_size=2**22,
) as ws:
r._ws = ws
r.state = RelayState.CONNECTED
r.last_connected_at = time.time()
# re-send subscriptions for this relay
for key, sub in self._subs.items():
if sub["relay"] == url:
await self._send_req(url, sub["sub_id"], sub["filters"])
attempt = 0
async for raw in ws:
if self._shutdown.is_set():
break
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
if not isinstance(msg, list) or not msg:
continue
typ = msg[0]
if typ == "EVENT" and len(msg) >= 3:
await self._on_event(url, msg[2])
elif typ == "NOTICE" and len(msg) >= 2 and self._on_notice:
await self._on_notice(url, str(msg[1]))
elif typ == "OK":
pass
except Exception as e: # noqa: BLE001
r.last_error = repr(e)
r.state = RelayState.ERROR
log.warning("relay %s error: %s", url, e)
finally:
r._ws = None
r.state = RelayState.BACKOFF
attempt += 1
delay = min(60.0, 1.5**attempt) + random.random()
r.backoff_until = time.monotonic() + delay
try:
await asyncio.wait_for(self._shutdown.wait(), timeout=delay)
break
except TimeoutError:
continue
async def run_forever(self, urls: list[str]) -> None:
for u in urls:
self.register(u)
await self.connect_all()
await self._shutdown.wait()

48
src/imwald/core/relay_policy.py

@ -0,0 +1,48 @@ @@ -0,0 +1,48 @@
"""Default relay lists (kind 10002) and aggr.nostr.land thread rule."""
from __future__ import annotations
# Read relays (default kind 10002 `r` read=true)
DEFAULT_READ_RELAYS: tuple[str, ...] = (
"wss://theforest.nostr1.com",
"wss://nostr.land",
"wss://nostr21.com",
"wss://christpill.nostr1.com",
)
# Write relays (default kind 10002 `r` write=true)
DEFAULT_WRITE_RELAYS: tuple[str, ...] = (
"wss://thecitadel.nostr1.com",
"wss://freelay.sovbit.host",
"wss://relay.damus.io",
"wss://relay.primal.net",
)
# Thread / reply aggregation when nostr.land is a write relay in user's 10002
AGGR_THREAD_RELAY = "wss://aggr.nostr.land"
# Jumble / Wisp “trending on Nostr” slice — nostrarchives feeds (see jumble `wisp-trending-relay.ts`).
WISP_TRENDING_NOTES_RELAY = "wss://feeds.nostrarchives.com/notes/trending/reactions/today"
# Kinds Jumble subscribes on that trending WebSocket (`WISP_TRENDING_FEED_KINDS`).
WISP_TRENDING_FEED_KINDS: tuple[int, ...] = (1, 6, 1068, 6969, 30023, 20, 21, 22)
def use_aggr_for_threads(user_write_urls: set[str]) -> bool:
return "wss://nostr.land" in user_write_urls or "ws://nostr.land" in user_write_urls
def default_feed_read_relays() -> list[str]:
"""Read set for the main + trending mix (deduped)."""
seen: set[str] = set()
out: list[str] = []
for u in (*DEFAULT_READ_RELAYS, WISP_TRENDING_NOTES_RELAY):
if u not in seen:
seen.add(u)
out.append(u)
return out
def is_wisp_trending_relay_url(url: str) -> bool:
u = url.lower().replace("ws://", "https://").replace("wss://", "https://")
return "feeds.nostrarchives.com" in u and "/notes/trending/" in u

1
src/imwald/ui/__init__.py

@ -0,0 +1 @@ @@ -0,0 +1 @@
# Qt UI

117
src/imwald/ui/composer_dialog.py

@ -0,0 +1,117 @@ @@ -0,0 +1,117 @@
"""Advanced composer: kind, tags, content; Edit clones without id/sig/created_at."""
from __future__ import annotations
import json
import time
from typing import Any
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPlainTextEdit,
QSpinBox,
QVBoxLayout,
)
from imwald.core.accounts_store import StoredAccount, unlock_secret
from imwald.core.nostr_crypto import build_signed_event
from imwald.core.nostr_publish import publish_to_relays_sync
from imwald.core.relay_policy import DEFAULT_WRITE_RELAYS
FEED_KINDS = [1, 20, 21, 30023, 9802, 11]
TAG_SUGGESTIONS = ["t", "client", "e", "p", "relay", "imeta"]
class ComposerDialog(QDialog):
def __init__(
self,
parent=None,
*,
edit_from: dict[str, Any] | None = None,
account: StoredAccount,
password: str | None = None,
) -> None:
super().__init__(parent)
self.setWindowTitle("New event" if edit_from is None else "Edit event (clone)")
self._account = account
self._password = password
self._edit_from = edit_from
self.last_published: dict | None = None
self._kind = QSpinBox()
self._kind.setRange(0, 99999)
self._kind_combo = QComboBox()
self._kind_combo.addItem("Custom (use spinbox)", -1)
for k in FEED_KINDS:
self._kind_combo.addItem(f"Kind {k}", k)
self._kind_combo.currentIndexChanged.connect(self._on_kind_combo)
self._tags = QLineEdit()
self._tags.setPlaceholderText('JSON array of tags, e.g. [["t","nostr"]]')
self._content = QPlainTextEdit()
self._hint = QLabel("Suggestions: " + ", ".join(f'["{t}",""]' for t in TAG_SUGGESTIONS[:4]))
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._publish)
buttons.rejected.connect(self.reject)
form = QFormLayout()
row = QHBoxLayout()
row.addWidget(self._kind_combo)
row.addWidget(self._kind)
form.addRow("Kind", row)
form.addRow("Tags (JSON)", self._tags)
form.addRow(self._hint)
form.addRow("Content", self._content)
root = QVBoxLayout(self)
root.addLayout(form)
root.addWidget(buttons)
if edit_from:
self._kind.setValue(int(edit_from.get("kind", 1)))
self._tags.setText(json.dumps(edit_from.get("tags") or [], ensure_ascii=False))
self._content.setPlainText(edit_from.get("content") or "")
else:
self._kind.setValue(1)
self._tags.setText(json.dumps([["client", "imwald"]], ensure_ascii=False))
def _on_kind_combo(self, _idx: int) -> None:
data = self._kind_combo.currentData()
if data is not None and int(data) >= 0:
self._kind.setValue(int(data))
def _publish(self) -> None:
try:
tags = json.loads(self._tags.text() or "[]")
if not isinstance(tags, list):
raise ValueError("tags must be a JSON array")
except Exception as e: # noqa: BLE001
QMessageBox.warning(self, "Invalid tags", str(e))
return
kind = int(self._kind.value())
content = self._content.toPlainText()
try:
sec = unlock_secret(self._account, self._password)
except Exception as e: # noqa: BLE001
QMessageBox.warning(self, "Unlock failed", str(e))
return
now = int(time.time())
ev = build_signed_event(sec, created_at=now, kind=kind, tags=tags, content=content)
publish_to_relays_sync(list(DEFAULT_WRITE_RELAYS), ev)
self.last_published = ev
self.accept()
def open_composer_for_edit(parent, ev: dict[str, Any], account: StoredAccount, password: str | None) -> None:
clone = {k: ev[k] for k in ("kind", "tags", "content") if k in ev}
dlg = ComposerDialog(parent, edit_from=clone, account=account, password=password)
dlg.exec()

181
src/imwald/ui/db_admin_page.py

@ -0,0 +1,181 @@ @@ -0,0 +1,181 @@
"""Local database admin: SELECT-only SQL, CRUD on local tables, event row actions."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPlainTextEdit,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from imwald.core.accounts_store import StoredAccount
from imwald.core.database import Database
class DbAdminPage(QWidget):
open_event = Signal(str)
request_nip09 = Signal(str, str) # event_id, signing_pubkey hex
def __init__(self, db: Database, accounts: list[StoredAccount], parent=None) -> None:
super().__init__(parent)
self._db = db
self._accounts = accounts
self._table = QComboBox()
self._table.currentTextChanged.connect(self._load_table)
self._grid = QTableWidget()
self._sql = QPlainTextEdit()
self._sql.setPlaceholderText("SELECT … only")
run = QPushButton("Run SELECT")
run.clicked.connect(self._run_sql)
open_btn = QPushButton("Open in event viewer")
open_btn.clicked.connect(self._open_selected_event)
purge_btn = QPushButton("Purge from local DB")
purge_btn.clicked.connect(self._purge_selected_event)
self._nip_btn = QPushButton("Publish NIP-09 deletion")
self._nip_btn.clicked.connect(self._nip09)
self._nip_btn.setVisible(False)
row = QHBoxLayout()
row.addWidget(open_btn)
row.addWidget(purge_btn)
row.addWidget(self._nip_btn)
lay = QVBoxLayout(self)
lay.addWidget(
QLabel(
"Browse tables. Events: no SQL UPDATE/INSERT; NIP-09 only if pubkey matches a saved key; "
"purges are local-only."
)
)
lay.addWidget(self._table)
lay.addWidget(self._grid)
self._grid.itemSelectionChanged.connect(self._on_sel)
lay.addLayout(row)
lay.addWidget(QLabel("SQL (SELECT only):"))
lay.addWidget(self._sql)
lay.addWidget(run)
self._reload_table_list()
def set_accounts(self, accounts: list[StoredAccount]) -> None:
self._accounts = accounts
def _reload_table_list(self) -> None:
self._table.blockSignals(True)
self._table.clear()
for name in self._db.list_tables():
self._table.addItem(name)
self._table.blockSignals(False)
if self._table.count():
self._load_table(self._table.currentText())
def _load_table(self, name: str) -> None:
if not name:
return
cols, rows = self._db.select_safe(f'SELECT * FROM "{name}" LIMIT 500', ())
self._grid.clear()
self._grid.setColumnCount(len(cols))
self._grid.setHorizontalHeaderLabels(cols)
self._grid.setRowCount(len(rows))
for ri, row in enumerate(rows):
for ci, c in enumerate(cols):
v = row[ci]
self._grid.setItem(ri, ci, QTableWidgetItem("" if v is None else str(v)))
self._grid.setProperty("current_table", name)
self._on_sel()
def _current_event_id(self) -> str | None:
name = self._grid.property("current_table")
if name != "events":
return None
cols = [self._grid.horizontalHeaderItem(i).text() for i in range(self._grid.columnCount())]
try:
ci = cols.index("id")
except ValueError:
return None
r = self._grid.currentRow()
if r < 0:
return None
it = self._grid.item(r, ci)
return it.text() if it else None
def _current_event_pubkey(self) -> str | None:
name = self._grid.property("current_table")
if name != "events":
return None
cols = [self._grid.horizontalHeaderItem(i).text() for i in range(self._grid.columnCount())]
try:
ci = cols.index("pubkey")
except ValueError:
return None
r = self._grid.currentRow()
if r < 0:
return None
it = self._grid.item(r, ci)
return it.text() if it else None
def _run_sql(self) -> None:
q = self._sql.toPlainText().strip()
if not q:
return
try:
cols, rows = self._db.select_safe(q, ())
except Exception as e: # noqa: BLE001
QMessageBox.warning(self, "SQL", str(e))
return
self._grid.setProperty("current_table", None)
self._grid.setColumnCount(len(cols))
self._grid.setHorizontalHeaderLabels(cols)
self._grid.setRowCount(len(rows))
for ri, row in enumerate(rows):
for ci, c in enumerate(cols):
v = row[ci]
self._grid.setItem(ri, ci, QTableWidgetItem("" if v is None else str(v)))
self._nip_btn.setVisible(False)
def _nip_allowed_for_selection(self) -> bool:
pk = self._current_event_pubkey()
if not pk:
return False
return pk.lower() in {a.pubkey.lower() for a in self._accounts}
def _on_sel(self) -> None:
name = self._grid.property("current_table")
if name == "events" and self._nip_allowed_for_selection():
self._nip_btn.setVisible(True)
else:
self._nip_btn.setVisible(False)
def _open_selected_event(self) -> None:
eid = self._current_event_id()
if eid:
self.open_event.emit(eid)
else:
QMessageBox.information(self, "Open", "Select a row in the events table (id column).")
def _purge_selected_event(self) -> None:
eid = self._current_event_id()
if not eid:
QMessageBox.information(self, "Purge", "Select an events row.")
return
if QMessageBox.question(self, "Purge", f"Remove {eid[:16]}… from local DB?") != QMessageBox.StandardButton.Yes:
return
self._db.purge_event_local(eid)
self._reload_table_list()
def _nip09(self) -> None:
eid = self._current_event_id()
pk = self._current_event_pubkey()
if not eid or not pk:
QMessageBox.information(self, "NIP-09", "Select an events row with id and pubkey.")
return
saved = {a.pubkey.lower() for a in self._accounts}
if pk.lower() not in saved:
QMessageBox.information(self, "NIP-09", "This pubkey is not one of your saved keys — button stays hidden in product UI; here we block.")
return
self.request_nip09.emit(eid, pk)

187
src/imwald/ui/feed_page.py

@ -0,0 +1,187 @@ @@ -0,0 +1,187 @@
"""OP-as-document + thread panel + pager + votes + ranker 'why'."""
from __future__ import annotations
import json
from typing import Any
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QSplitter,
QTextBrowser,
QVBoxLayout,
QWidget,
)
from imwald.core.database import Database
from imwald.core.nostr_engine import NostrEngine
from imwald.core.ranker import Ranker
FEED_KINDS = (1, 20, 21, 30023, 9802, 11)
class FeedPage(QWidget):
def __init__(self, db: Database, engine: NostrEngine, parent=None) -> None:
super().__init__(parent)
self._db = db
self._engine = engine
self._ranker = Ranker(db)
self._queue: list[dict[str, Any]] = []
self._index = 0
self._my_pubkey: str | None = None
self._following: set[str] = set()
self._list30000_pubkeys: set[str] = set()
self._op = QTextBrowser()
self._op.setOpenExternalLinks(True)
self._why = QLabel("")
self._thread = QListWidget()
prev = QPushButton("◀ Previous")
next_ = QPushButton("Next ▶")
prev.clicked.connect(self._prev)
next_.clicked.connect(self._next)
up = QPushButton("▲ +1")
down = QPushButton("▼ −1")
up.clicked.connect(lambda: self._vote(1))
down.clicked.connect(lambda: self._vote(-1))
nav = QHBoxLayout()
nav.addWidget(prev)
nav.addWidget(next_)
nav.addWidget(up)
nav.addWidget(down)
nav.addStretch()
nav.addWidget(self._why)
left = QVBoxLayout()
left.addWidget(self._op, stretch=1)
left.addLayout(nav)
lw = QWidget()
lw.setLayout(left)
split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(lw)
split.addWidget(self._thread)
split.setStretchFactor(0, 3)
split.setStretchFactor(1, 1)
outer = QVBoxLayout(self)
outer.addWidget(split)
def set_context(
self,
my_pubkey: str | None,
following: set[str],
list30000_pubkeys: set[str] | None = None,
) -> None:
self._my_pubkey = my_pubkey
self._following = following
self._list30000_pubkeys = list30000_pubkeys or set()
def _feed_viewer_key(self) -> str:
"""Per-device feed history; logged-out users share `_anon`."""
return (self._my_pubkey or "_anon").lower()
def reload_queue(self) -> None:
hide = self._db.get_setting("hide_nsfw", "1") == "1"
raw = self._db.feed_candidates(
FEED_KINDS,
hide_nsfw=hide,
limit=500,
viewer_pubkey=self._feed_viewer_key(),
exclude_viewed=True,
)
ranked = self._ranker.rank_feed(raw, self._my_pubkey, self._following, self._list30000_pubkeys)
self._queue = ranked
self._index = 0
self._show_current()
def refresh_tail(self) -> None:
"""After ingest: rebuild ranked queue (picks up trending + read relays)."""
self.reload_queue()
def show_event(self, event_id: str) -> None:
ev = self._db.get_event(event_id)
if not ev:
self._op.setPlainText(f"(not in local DB yet) {event_id}")
return
self._queue = [ev]
self._index = 0
self._show_current()
if not ev.get("deleted"):
self._db.mark_feed_viewed(self._feed_viewer_key(), ev["id"])
def _show_current(self) -> None:
if not self._queue:
self._op.setPlainText("No events in local database yet — wait for relay sync.")
self._thread.clear()
self._why.setText("")
return
ev = self._queue[self._index % len(self._queue)]
if ev.get("deleted"):
self._op.setHtml(
f"<p><i>Marked deleted locally</i></p><pre>{ev.get('content','')}</pre>"
f"<p style='color:gray'>{ev['id']}</p>"
)
self._thread.clear()
self._why.setText("")
return
score, why = self._ranker.score_event(
ev,
my_pubkey=self._my_pubkey,
following=self._following,
list30000_pubkeys=self._list30000_pubkeys,
)
self._why.setText(f"score={score:.2f} {json.dumps(why, ensure_ascii=False)[:120]}")
tr = ""
sr = ev.get("source_relay") or ""
if sr and "nostrarchives.com" in sr:
tr = "<p><i>Trending slice (nostrarchives)</i></p>"
body = (
f"<h2>Kind {ev['kind']}</h2>"
f"<p><b>{ev['pubkey'][:16]}…</b> · {ev['created_at']}</p>"
f"{tr}"
f"<pre>{ev.get('content','')}</pre>"
f"<p style='color:gray'>{ev['id']}</p>"
)
self._op.setHtml(body)
self._thread.clear()
for r in self._db.list_replies_to(ev["id"]):
line = f"k{r['kind']} {r['pubkey'][:8]}{r['content'][:100]!r}"
it = QListWidgetItem(line)
it.setData(Qt.ItemDataRole.UserRole, r["id"])
self._thread.addItem(it)
def _prev(self) -> None:
if self._queue:
self._index = (self._index - 1) % len(self._queue)
self._show_current()
def _next(self) -> None:
if not self._queue:
return
cur = self._queue[self._index % len(self._queue)]
if not cur.get("deleted"):
self._db.mark_feed_viewed(self._feed_viewer_key(), cur["id"])
self._index = (self._index + 1) % len(self._queue)
self._show_current()
def current_event_id(self) -> str | None:
if not self._queue:
return None
ev = self._queue[self._index % len(self._queue)]
return str(ev["id"])
def _vote(self, v: int) -> None:
if not self._my_pubkey or not self._queue:
return
ev = self._queue[self._index % len(self._queue)]
if ev.get("deleted"):
return
self._db.set_vote(ev["id"], self._my_pubkey, v)
self._show_current()

281
src/imwald/ui/main_window.py

@ -0,0 +1,281 @@ @@ -0,0 +1,281 @@
"""Main shell: menus, stacked pages, relay ingest, accounts, composer."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QInputDialog,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMainWindow,
QMessageBox,
QStackedWidget,
QToolBar,
QVBoxLayout,
)
from imwald.core.accounts_store import StoredAccount, load_accounts
from imwald.core.database import Database
from imwald.core.nostr_engine import NostrEngine
from imwald.ui.composer_dialog import ComposerDialog
from imwald.ui.db_admin_page import DbAdminPage
from imwald.ui.feed_page import FeedPage
from imwald.ui.notifications_page import NotificationsPage
from imwald.ui.onboarding_wizard import run_onboarding_wizard
from imwald.ui.search_page import SearchPage
class MainWindow(QMainWindow):
def __init__(self, *, db: Database, engine: NostrEngine, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("imwald")
self.resize(1200, 820)
self._db = db
self._engine = engine
self._accounts: list[StoredAccount] = load_accounts()
self._session_passwords: dict[str, str] = {}
self._stack = QStackedWidget()
self._feed = FeedPage(db, engine)
self._search = SearchPage(db)
self._notif = NotificationsPage(db, self._accounts)
self._dbadm = DbAdminPage(db, self._accounts)
self._stack.addWidget(self._feed) # 0
self._stack.addWidget(self._search) # 1
self._stack.addWidget(self._notif) # 2
self._stack.addWidget(self._dbadm) # 3
self.setCentralWidget(self._stack)
self._acct_combo = QComboBox()
self._acct_combo.setMinimumWidth(220)
self._reload_account_combo()
tb = QToolBar()
tb.addWidget(QLabel("Account:"))
tb.addWidget(self._acct_combo)
self.addToolBar(tb)
self._wire_menu()
self._wire_engine()
self._wire_pages()
self._acct_combo.currentIndexChanged.connect(self._on_account_changed)
self._on_account_changed()
if not self._db.get_setting("onboarding_done") and not self._accounts:
if run_onboarding_wizard(self, db=self._db, engine=self._engine, existing_accounts=self._accounts):
self._db.set_setting("onboarding_done", "1")
self._accounts = load_accounts()
self._reload_account_combo()
self._notif.set_accounts(self._accounts)
self._dbadm.set_accounts(self._accounts)
self._feed.reload_queue()
def _reload_account_combo(self) -> None:
self._acct_combo.blockSignals(True)
self._acct_combo.clear()
self._acct_combo.addItem("Lurk (no key)", "")
for a in self._accounts:
label = a.label or a.pubkey[:12] + ""
self._acct_combo.addItem(label, a.pubkey)
self._acct_combo.blockSignals(False)
def _current_pubkey(self) -> str | None:
d = self._acct_combo.currentData()
return str(d) if d else None
def list_owner_pubkey_for_relays(self) -> str | None:
"""Pubkey used to REQ kind 30000 list metadata from relays (same as active account)."""
return self._current_pubkey()
def _password_for(self, pubkey: str) -> str | None:
acc = next((a for a in self._accounts if a.pubkey.lower() == pubkey.lower()), None)
if not acc:
return None
if acc.signer_type == "nsec":
return None
if pubkey in self._session_passwords:
return self._session_passwords[pubkey]
pw, ok = QInputDialog.getText(self, "Password", f"Password for {pubkey[:12]}… (ncryptsec)", echo=QLineEdit.EchoMode.Password)
if not ok:
return None
self._session_passwords[pubkey] = pw
return pw
def _on_account_changed(self) -> None:
pk = self._current_pubkey()
following: set[str] = set()
list300: set[str] = set()
if pk:
following = self._db.list_following_pubkeys(pk)
list300 = self._db.list_kind30000_list_pubkeys(pk)
self._feed.set_context(pk, following, list300)
self._feed.reload_queue()
def _wire_menu(self) -> None:
m_file = self.menuBar().addMenu("&File")
a_new = QAction("&New event…", self)
a_new.triggered.connect(self._new_event)
m_file.addAction(a_new)
a_mine = QAction("&Your latest events…", self)
a_mine.triggered.connect(self._my_events)
m_file.addAction(a_mine)
a_edit = QAction("&Edit current (clone)…", self)
a_edit.triggered.connect(self._edit_current)
m_file.addAction(a_edit)
a_onb = QAction("&Onboarding wizard…", self)
a_onb.triggered.connect(self._onboarding_again)
m_file.addAction(a_onb)
m_view = self.menuBar().addMenu("&View")
for i, (title, idx) in enumerate(
[
("&Feed", 0),
("&Search", 1),
("&Notifications", 2),
]
):
act = QAction(title, self)
act.setData(idx)
act.triggered.connect(lambda checked=False, x=idx: self._stack.setCurrentIndex(x))
m_view.addAction(act)
m_tools = self.menuBar().addMenu("&Tools")
a_db = QAction("&Local database…", self)
a_db.triggered.connect(lambda: self._stack.setCurrentIndex(3))
m_tools.addAction(a_db)
m_help = self.menuBar().addMenu("&Help")
a_about = QAction("&About", self)
a_about.triggered.connect(
lambda: QMessageBox.about(
self,
"About imwald",
"Linux-native Nostr client — Qt (PySide6), SQLite, relay worker.",
)
)
m_help.addAction(a_about)
def _wire_engine(self) -> None:
self._engine.event_ingested.connect(self._on_event_ingested)
self._engine.relay_status.connect(lambda s: self.statusBar().showMessage(s, 8000))
def _on_event_ingested(self, relay_url: str, ev: object) -> None:
if not isinstance(ev, dict):
return
NostrEngine.apply_ingest_to_db(self._db, ev, relay_url)
if self._stack.currentWidget() is self._feed:
self._feed.refresh_tail()
self._notif.refresh_all()
def _wire_pages(self) -> None:
self._search.open_event.connect(self._open_event)
self._notif.open_event.connect(self._open_event)
self._notif.signing_pubkey_changed.connect(self._on_notif_signing)
self._dbadm.open_event.connect(self._open_event)
self._dbadm.request_nip09.connect(self._nip09_from_db)
def _on_notif_signing(self, pubkey: str) -> None:
self.statusBar().showMessage(f"Notifications tab signing context: {pubkey[:16]}", 5000)
def _open_event(self, event_id: str) -> None:
self._stack.setCurrentIndex(0)
self._feed.show_event(event_id)
def _nip09_from_db(self, event_id: str, pubkey: str) -> None:
acc = next((a for a in self._accounts if a.pubkey.lower() == pubkey.lower()), None)
if not acc:
return
pw = self._password_for(pubkey)
if acc.signer_type == "ncryptsec" and pw is None:
return
if QMessageBox.question(self, "NIP-09", f"Publish deletion for {event_id[:16]}…?") != QMessageBox.StandardButton.Yes:
return
self._engine.publish_nip09_deletion(acc, pw, event_id)
QMessageBox.information(self, "NIP-09", "Deletion request published to default write relays.")
def _edit_current(self) -> None:
eid = self._feed.current_event_id()
if not eid:
return
ev = self._db.get_event(eid)
if not ev:
return
acc, pw = self._account_for_compose()
if not acc:
QMessageBox.information(self, "Edit", "Select an account.")
return
dlg = ComposerDialog(self, edit_from=ev, account=acc, password=pw)
if dlg.exec() == QDialog.DialogCode.Accepted and dlg.last_published:
self._db.upsert_event(dlg.last_published)
def _new_event(self) -> None:
acc, pw = self._account_for_compose()
if not acc:
QMessageBox.information(self, "Composer", "Select an account or add keys via onboarding.")
return
dlg = ComposerDialog(self, edit_from=None, account=acc, password=pw)
if dlg.exec() == QDialog.DialogCode.Accepted and dlg.last_published:
self._db.upsert_event(dlg.last_published)
def _my_events(self) -> None:
pk = self._current_pubkey()
if not pk:
QMessageBox.information(self, "Events", "Select an account first.")
return
rows = self._db.list_my_events(pk, limit=200)
d = QDialog(self)
d.setWindowTitle("Your latest events")
lw = QListWidget()
for ev in rows:
it = QListWidgetItem(f"k{ev['kind']} {ev['id'][:16]}{ev['content'][:60]!r}")
it.setData(Qt.ItemDataRole.UserRole, ev["id"])
lw.addItem(it)
def _go(it: QListWidgetItem) -> None:
eid = it.data(Qt.ItemDataRole.UserRole)
d.accept()
if eid:
self._open_event(str(eid))
lw.itemActivated.connect(_go)
v = QVBoxLayout(d)
v.addWidget(lw)
d.resize(600, 400)
d.exec()
def _onboarding_again(self) -> None:
self._accounts = load_accounts()
run_onboarding_wizard(self, db=self._db, engine=self._engine, existing_accounts=self._accounts)
self._accounts = load_accounts()
self._reload_account_combo()
self._notif.set_accounts(self._accounts)
self._dbadm.set_accounts(self._accounts)
def _account_for_compose(self) -> tuple[StoredAccount | None, str | None]:
if self._stack.currentWidget() is self._notif:
pk_tab = self._notif.active_signing_pubkey()
if pk_tab:
acc = next((a for a in self._accounts if a.pubkey.lower() == pk_tab.lower()), None)
if acc:
return acc, self._password_for(acc.pubkey)
pk = self._current_pubkey()
if not pk:
return None, None
acc = next((a for a in self._accounts if a.pubkey.lower() == pk.lower()), None)
if not acc:
return None, None
return acc, self._password_for(pk)
def closeEvent(self, event) -> None: # noqa: N802
self._engine.stop_relays()
super().closeEvent(event)

92
src/imwald/ui/notifications_page.py

@ -0,0 +1,92 @@ @@ -0,0 +1,92 @@
"""Per-account notification tabs; signing identity = active tab's pubkey."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QTabWidget, QVBoxLayout, QWidget
from imwald.core.accounts_store import StoredAccount
from imwald.core.database import Database
class NotificationsPage(QWidget):
"""v1: lists DB notifications per recipient; empty until ingest fills rows."""
open_event = Signal(str)
signing_pubkey_changed = Signal(str)
def __init__(self, db: Database, accounts: list[StoredAccount], parent=None) -> None:
super().__init__(parent)
self._db = db
self._accounts = accounts
self._tabs = QTabWidget()
self._tabs.currentChanged.connect(self._on_tab)
self._lists: dict[str, QListWidget] = {}
lay = QVBoxLayout(self)
lay.addWidget(QLabel("Notifications per saved account (signing uses the active tab)."))
lay.addWidget(self._tabs)
self._rebuild_tabs()
def set_accounts(self, accounts: list[StoredAccount]) -> None:
self._accounts = accounts
self._rebuild_tabs()
def _rebuild_tabs(self) -> None:
self._tabs.clear()
self._lists.clear()
if not self._accounts:
holder = QWidget()
hl = QVBoxLayout(holder)
hl.addWidget(QLabel("No saved accounts."))
self._tabs.addTab(holder, "")
return
for acc in self._accounts:
lw = QListWidget()
lw.itemActivated.connect(self._on_item)
self._lists[acc.pubkey] = lw
label = acc.label or acc.pubkey[:12] + ""
self._tabs.addTab(lw, label)
self._refresh_list(acc.pubkey)
self._on_tab(self._tabs.currentIndex())
def _refresh_list(self, pubkey: str) -> None:
lw = self._lists.get(pubkey)
if not lw:
return
lw.clear()
cur = self._db.conn().execute(
"""
SELECT source_event_id, kind, read, created_at FROM notifications
WHERE recipient_pubkey=? ORDER BY created_at DESC LIMIT 200
""",
(pubkey,),
)
for row in cur:
title = f"{row['kind']} {row['source_event_id'][:12]}… read={row['read']}"
it = QListWidgetItem(title)
it.setData(Qt.ItemDataRole.UserRole, row["source_event_id"])
lw.addItem(it)
if lw.count() == 0:
lw.addItem(QListWidgetItem("(no rows yet — wire mention/reply detection on ingest)"))
def refresh_all(self) -> None:
for pk in self._lists:
self._refresh_list(pk)
def _on_tab(self, index: int) -> None:
if index < 0 or not self._accounts:
return
if index >= len(self._accounts):
return
self.signing_pubkey_changed.emit(self._accounts[index].pubkey)
def _on_item(self, it: QListWidgetItem) -> None:
eid = it.data(Qt.ItemDataRole.UserRole)
if eid:
self.open_event.emit(str(eid))
def active_signing_pubkey(self) -> str | None:
idx = self._tabs.currentIndex()
if 0 <= idx < len(self._accounts):
return self._accounts[idx].pubkey
return None

227
src/imwald/ui/onboarding_wizard.py

@ -0,0 +1,227 @@ @@ -0,0 +1,227 @@
"""Onboarding: lurk vs create, interests (hashtags), languages (≤3), NSFW default."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QCheckBox,
QFormLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPlainTextEdit,
QVBoxLayout,
QWizard,
QWizardPage,
)
from imwald.core.accounts_store import StoredAccount, add_account_nsec_hex, generate_new_identity, save_accounts
from imwald.core.database import Database
from imwald.core.nostr_engine import NostrEngine
POPULAR_TAGS = (
"nostr",
"bitcoin",
"linux",
"bookstr",
"dev",
"foodstr",
"photography",
"music",
"science",
"art",
)
TRINITY_LANGS = (
("en", "English"),
("de", "Deutsch"),
("fr", "Français"),
("es", "Español"),
("ru", "Русский"),
("zh", "简体中文"),
("pl", "Polski"),
("nl", "Nederlands"),
("cs", "Čeština"),
("tr", "Türkçe"),
)
PAGE_INTRO, PAGE_PROFILE, PAGE_INTERESTS, PAGE_LANG, PAGE_SAFETY, PAGE_PASSWORD = range(6)
class PageIntro(QWizardPage):
def __init__(self) -> None:
super().__init__()
self.setTitle("Welcome to imwald")
self.setSubTitle("Read-only lurk, or create a new Nostr identity.")
lay = QVBoxLayout(self)
self._lurk = QCheckBox("Lurk only (no signing key on this device yet)")
self._lurk.stateChanged.connect(self.completeChanged.emit)
lay.addWidget(self._lurk)
lay.addWidget(QLabel("If lurk: next jumps to NSFW preference, then Finish."))
def nextId(self) -> int:
if self._lurk.isChecked():
return PAGE_SAFETY
return PAGE_PROFILE
def lurk(self) -> bool:
return self._lurk.isChecked()
class PageProfile(QWizardPage):
def __init__(self) -> None:
super().__init__()
self.setTitle("Profile")
self.setSubTitle("Shown on your kind 0 metadata.")
form = QFormLayout(self)
self._name = QLineEdit()
self._about = QPlainTextEdit()
form.addRow("Name", self._name)
form.addRow("About", self._about)
def nextId(self) -> int:
return PAGE_INTERESTS
class PageInterests(QWizardPage):
def __init__(self) -> None:
super().__init__()
self.setTitle("Interests")
self.setSubTitle("Pick hashtags for your kind 10015 list.")
lay = QVBoxLayout(self)
self._list = QListWidget()
for t in POPULAR_TAGS:
it = QListWidgetItem(f"#{t}")
it.setCheckState(Qt.CheckState.Unchecked)
self._list.addItem(it)
lay.addWidget(self._list)
def nextId(self) -> int:
return PAGE_LANG
class PageLanguages(QWizardPage):
def __init__(self) -> None:
super().__init__()
self.setTitle("Languages")
self.setSubTitle("Pick up to three — we bias feed toward these (detection / translate later).")
lay = QVBoxLayout(self)
self._boxes: list[QCheckBox] = []
for code, label in TRINITY_LANGS:
cb = QCheckBox(f"{label} ({code})")
cb.setProperty("code", code)
cb.stateChanged.connect(self._cap)
self._boxes.append(cb)
lay.addWidget(cb)
def _cap(self) -> None:
sel = [b for b in self._boxes if b.isChecked()]
if len(sel) > 3:
b = sel[-1]
b.blockSignals(True)
b.setChecked(False)
b.blockSignals(False)
self.completeChanged.emit()
def selected(self) -> list[str]:
return [str(b.property("code")) for b in self._boxes if b.isChecked()]
def nextId(self) -> int:
return PAGE_SAFETY
class PageSafety(QWizardPage):
def __init__(self) -> None:
super().__init__()
self.setTitle("Safety")
self.setSubTitle("NSFW-tagged notes are hidden by default.")
lay = QVBoxLayout(self)
self._hide = QCheckBox("Hide #nsfw and content-warning by default (recommended)")
self._hide.setChecked(True)
lay.addWidget(self._hide)
def nextId(self) -> int:
wiz = self.wizard()
intro = wiz.page(PAGE_INTRO) if wiz else None
if isinstance(intro, PageIntro) and intro.lurk():
return -1
return PAGE_PASSWORD
class PagePassword(QWizardPage):
def __init__(self) -> None:
super().__init__()
self.setTitle("Encrypt key (NIP-49)")
self.setSubTitle("Matches Jumble-style ncryptsec storage when a password is set.")
form = QFormLayout(self)
self._pw = QLineEdit()
self._pw.setEchoMode(QLineEdit.Password)
self._pw2 = QLineEdit()
self._pw2.setEchoMode(QLineEdit.Password)
form.addRow("Password", self._pw)
form.addRow("Repeat", self._pw2)
def run_onboarding_wizard(
parent,
*,
db: Database,
engine: NostrEngine,
existing_accounts: list[StoredAccount],
) -> bool:
"""Return True if wizard finished (Accepted)."""
w = QWizard(parent)
w.setWindowTitle("imwald — onboarding")
p0 = PageIntro()
p1 = PageProfile()
p2 = PageInterests()
p3 = PageLanguages()
p4 = PageSafety()
p5 = PagePassword()
w.setPage(PAGE_INTRO, p0)
w.setPage(PAGE_PROFILE, p1)
w.setPage(PAGE_INTERESTS, p2)
w.setPage(PAGE_LANG, p3)
w.setPage(PAGE_SAFETY, p4)
w.setPage(PAGE_PASSWORD, p5)
if w.exec() != QWizard.DialogCode.Accepted:
return False
hide_nsfw = "1" if p4._hide.isChecked() else "0" # noqa: SLF001
db.set_setting("hide_nsfw", hide_nsfw)
if p0.lurk(): # noqa: SLF001
return True
pw = p5._pw.text() # noqa: SLF001
if pw != p5._pw2.text(): # noqa: SLF001
QMessageBox.warning(parent, "Password mismatch", "Passwords do not match.")
return False
password = pw if pw else None
sec, _pub = generate_new_identity()
acc = add_account_nsec_hex(sec.hex(), password)
acc.label = p1._name.text().strip() or None # noqa: SLF001
existing_accounts.append(acc)
save_accounts(existing_accounts)
interests = []
for i in range(p2._list.count()): # noqa: SLF001
it = p2._list.item(i) # noqa: SLF001
if it.checkState() == Qt.CheckState.Checked:
interests.append(it.text().lstrip("#"))
langs = p3.selected() # noqa: SLF001
engine.publish_kind0_and_lists(
acc,
password,
name=p1._name.text().strip() or "imwald user", # noqa: SLF001
about=p1._about.toPlainText().strip(),
interest_tags=interests,
languages=langs,
)
QMessageBox.information(parent, "Welcome", "Profile, relay list (10002), and interests (10015) published.")
return True

46
src/imwald/ui/search_page.py

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
"""Search local SQLite corpus."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget
from imwald.core.database import Database
class SearchPage(QWidget):
open_event = Signal(str)
def __init__(self, db: Database, parent=None) -> None:
super().__init__(parent)
self._db = db
self._q = QLineEdit()
self._q.setPlaceholderText("Search content, id, pubkey…")
go = QPushButton("Search")
go.clicked.connect(self._run)
self._list = QListWidget()
self._list.itemActivated.connect(self._activate)
top = QHBoxLayout()
top.addWidget(QLabel("Query:"))
top.addWidget(self._q)
top.addWidget(go)
lay = QVBoxLayout(self)
lay.addLayout(top)
lay.addWidget(self._list)
def _run(self) -> None:
self._list.clear()
q = self._q.text().strip()
if not q:
return
for ev in self._db.search_local(q, limit=200):
title = f"{ev['kind']} {ev['id'][:12]}… — {ev['content'][:80]!r}"
it = QListWidgetItem(title)
it.setData(Qt.ItemDataRole.UserRole, ev["id"])
self._list.addItem(it)
def _activate(self, it: QListWidgetItem) -> None:
eid = it.data(Qt.ItemDataRole.UserRole)
if eid:
self.open_event.emit(str(eid))

42
tests/test_kind30000_lists.py

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
import tempfile
from pathlib import Path
from imwald.core.database import Database
from imwald.core.nostr_crypto import build_signed_event
from imwald.core.ranker import Ranker, WEIGHT_FOLLOW_AUTHOR, WEIGHT_KIND30000_LIST_AUTHOR
def _fake_sk() -> bytes:
return bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683")
def test_list30000_pubkeys_from_p_tags() -> None:
with tempfile.TemporaryDirectory() as td:
db = Database(Path(td) / "t.sqlite")
db.connect()
listed = "b" * 64
other = "c" * 64
sk = _fake_sk()
tags = [["d", "testlist"], ["p", listed], ["p", other]]
ev = build_signed_event(sk, created_at=100, kind=30000, tags=tags, content="")
me = ev["pubkey"]
db.upsert_event(ev)
got = db.list_kind30000_list_pubkeys(me)
assert listed in got and other in got
def test_ranker_follow_beats_kind30000() -> None:
with tempfile.TemporaryDirectory() as td:
db = Database(Path(td) / "t2.sqlite")
db.connect()
r = Ranker(db)
me = "f" * 64
follow_pk = "a" * 64
list_pk = "b" * 64
ev_f = {"id": "1" * 64, "pubkey": follow_pk, "created_at": 1, "kind": 1, "tags": [], "content": "x"}
ev_l = {"id": "2" * 64, "pubkey": list_pk, "created_at": 2, "kind": 1, "tags": [], "content": "y"}
sf, _ = r.score_event(ev_f, my_pubkey=me, following={follow_pk}, list30000_pubkeys={list_pk})
sl, _ = r.score_event(ev_l, my_pubkey=me, following={follow_pk}, list30000_pubkeys={list_pk})
assert sf > sl
assert sf >= WEIGHT_FOLLOW_AUTHOR
assert WEIGHT_KIND30000_LIST_AUTHOR <= sl < WEIGHT_FOLLOW_AUTHOR

13
tests/test_nip49.py

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
from imwald.core.nip49 import decrypt_ncryptsec, encrypt_to_ncryptsec
def test_nip49_vector() -> None:
nc = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"
sk = decrypt_ncryptsec(nc, "nostr")
assert sk.hex() == "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683"
def test_nip49_roundtrip() -> None:
sk = bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683")
nc = encrypt_to_ncryptsec(sk, "hunter2", log_n=16)
assert decrypt_ncryptsec(nc, "hunter2") == sk
Loading…
Cancel
Save