Browse Source

refinement

master
Silberengel 2 weeks ago
parent
commit
5baec38c80
  1. 4
      .vscode/settings.json
  2. 40
      README.md
  3. 12
      pyproject.toml
  4. 1
      src/imwald/app.py
  5. 32
      src/imwald/core/database.py
  6. 110
      src/imwald/core/md_render.py
  7. 34
      src/imwald/core/nostr_crypto.py
  8. 18
      src/imwald/core/nostr_engine.py
  9. 3
      src/imwald/core/nostr_publish.py
  10. 125
      src/imwald/core/relay_list.py
  11. 11
      src/imwald/core/relay_policy.py
  12. 6
      src/imwald/ui/assets/vendor/marked.min.js
  13. 26
      src/imwald/ui/composer_dialog.py
  14. 23
      src/imwald/ui/feed_page.py
  15. 45
      src/imwald/ui/main_window.py
  16. 57
      src/imwald/ui/markdown_editor_widget.py
  17. 11
      src/imwald/ui/notifications_page.py
  18. 4
      src/imwald/ui/search_page.py
  19. 25
      tests/test_event_verify.py
  20. 17
      tests/test_md_render.py
  21. 57
      tests/test_relay_list.py

4
.vscode/settings.json vendored

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"python.analysis.extraPaths": ["${workspaceFolder}/src"]
}

40
README.md

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
# Imwald: Linux-native Nostr client
# imwald
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/`.
Linux-focused **Nostr** desktop client: **PySide6** UI, **SQLite** (WAL) as the local store, and a background relay worker that writes events into the same database the UI reads.
## Run (development)
@ -8,22 +8,24 @@ Qt (PySide6) + SQLite (WAL) Nostr client for Linux. Relay worker ingests into th @@ -8,22 +8,24 @@ Qt (PySide6) + SQLite (WAL) Nostr client for Linux. Relay worker ingests into th
cd imwald
python3 -m venv .venv
.venv/bin/pip install -e '.[dev]'
PYTHONPATH=src .venv/bin/python -m imwald
.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 and sensible defaults
3. Easy onboarding for new users
4. Algorithmic feed (no follows, no recommendation lists)
5. File-style/application handling
6. SQL Lite data storage
7. Media caching
8. Voting and reacting on all events
9. Grammar check, translations, and read-alouds for popular world languages
10. Full profile view
11. Discussion/Bulletin board
12. Notifications
The package uses a `src/` layout; **editable install** is enough (no `PYTHONPATH` needed). For Cursor/Pylance, the repo includes `.vscode/settings.json` and `[tool.pyright]` in `pyproject.toml` so `import imwald` resolves.
## What is implemented today
- **Relays:** Read/write sets from your latest **kind 10002** (NIP-65); built-in defaults only when you have no usable list. Relay worker restarts when the active account changes. Optional **aggr.nostr.land** thread subscription when `nostr.land` is in your write set. Trending slice relay (nostrarchives) mixed into reads.
- **Ingest:** NIP-01 id check + **BIP-340** signature verification before SQLite upsert; kind **5** tombstones applied on valid deletion events.
- **Feed:** Ranked queue (follows, kind **30000** list authors, scores), per-viewer “seen” tracking, OP body rendered as **Markdown** (offline **marked** via QuickJS, **nh3** sanitize, Python `markdown` fallback). Thread list, search hits, notifications rows, and “your events” use the same pipeline for **plain summaries** (no raw `#` / `**` noise in one-liners).
- **Composer:** Split **Markdown source** + **live preview**; publish to resolved write relays.
- **Accounts:** Onboarding wizard, **nsec** / **ncryptsec** (NIP-49) storage, composer and NIP-09 publish.
- **Other pages:** Search (local `LIKE`), notifications list (DB table + joined event text when present), local **DB admin** (safe SELECT, purge, NIP-09 hook).
## Dependencies (runtime)
See `pyproject.toml`: PySide6, cryptography, bech32, coincurve, PyNaCl, websockets, **quickjs-ng**, **nh3**, **markdown** (Markdown rendering is fully local; `marked` is vendored under `src/imwald/ui/assets/vendor/`).
## Not in scope (yet)
Server-backed grammar (LanguageTool), translation, read-aloud, rich media gallery, full “profile app” shell, or a separate bulletin-board product surface—the README used to list aspirational items; they are **not** current features unless added in code.

12
pyproject.toml

@ -15,6 +15,9 @@ dependencies = [ @@ -15,6 +15,9 @@ dependencies = [
"coincurve>=20",
"PyNaCl>=1.5",
"websockets>=12",
"quickjs-ng>=0.13",
"nh3>=0.2",
"markdown>=3.5",
]
[project.optional-dependencies]
@ -29,5 +32,14 @@ where = ["src"] @@ -29,5 +32,14 @@ where = ["src"]
[tool.setuptools.package-dir]
"" = "src"
[tool.setuptools.package-data]
imwald = ["ui/assets/vendor/*.js"]
[tool.pytest.ini_options]
pythonpath = ["src"]
# Pylance / basedpyright: resolve ``imwald`` from the src layout (same as pytest).
[tool.pyright]
include = ["src", "tests"]
extraPaths = ["src"]
pythonVersion = "3.11"

1
src/imwald/app.py

@ -25,7 +25,6 @@ def main() -> None: @@ -25,7 +25,6 @@ def main() -> None:
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()

32
src/imwald/core/database.py

@ -275,6 +275,38 @@ class Database: @@ -275,6 +275,38 @@ class Database:
with self.write_lock() as c:
c.execute("DELETE FROM events WHERE id=?", (event_id,))
def get_latest_kind10002_event(self, pubkey: str) -> dict[str, Any] | None:
"""Latest non-deleted kind 10002 authored by ``pubkey`` (hex), or None."""
cur = self.conn().execute(
"""
SELECT id, pubkey, created_at, kind, content, sig, tags_json, raw_json, deleted
FROM events
WHERE deleted=0 AND kind=10002 AND lower(pubkey)=?
ORDER BY created_at DESC LIMIT 1
""",
(pubkey.lower(),),
)
row = cur.fetchone()
if not row:
return None
raw = row["raw_json"]
if raw:
try:
ev = json.loads(raw)
if isinstance(ev, dict):
return ev
except json.JSONDecodeError:
pass
return {
"id": row["id"],
"pubkey": row["pubkey"],
"created_at": row["created_at"],
"kind": row["kind"],
"content": row["content"] or "",
"sig": row["sig"],
"tags": json.loads(row["tags_json"] or "[]"),
}
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=?",

110
src/imwald/core/md_render.py

@ -0,0 +1,110 @@ @@ -0,0 +1,110 @@
"""Offline Markdown → HTML using vendored ``marked`` (QuickJS) with ``nh3`` sanitization."""
from __future__ import annotations
import html
import json
import logging
import re
from pathlib import Path
import nh3
log = logging.getLogger(__name__)
_MARKED_PATH = Path(__file__).resolve().parents[1] / "ui" / "assets" / "vendor" / "marked.min.js"
_qjs_ctx = None
_marked_load_failed = False
def _marked_quickjs_ctx():
"""Singleton QuickJS context with ``marked`` loaded, or None if unavailable."""
global _qjs_ctx, _marked_load_failed
if _marked_load_failed:
return None
if _qjs_ctx is not None:
return _qjs_ctx
if not _MARKED_PATH.is_file():
log.warning("Vendored marked not found: %s", _MARKED_PATH)
_marked_load_failed = True
return None
try:
import quickjs
except ImportError:
log.info("quickjs-ng not installed; Markdown uses Python fallback renderer")
_marked_load_failed = True
return None
try:
ctx = quickjs.Context()
ctx.eval(_MARKED_PATH.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
log.warning("Could not initialize marked in QuickJS: %s", e)
_marked_load_failed = True
return None
_qjs_ctx = ctx
return _qjs_ctx
def _render_marked_js(md: str) -> str | None:
ctx = _marked_quickjs_ctx()
if ctx is None:
return None
try:
expr = "marked.parse(" + json.dumps(md or "") + ", {gfm: true, breaks: true})"
return str(ctx.eval(expr))
except Exception as e: # noqa: BLE001
log.debug("marked.parse failed: %s", e)
return None
def _render_markdown_fallback(md: str) -> str:
import markdown
return markdown.markdown(
md or "",
extensions=["fenced_code", "tables", "nl2br", "sane_lists"],
output_format="html",
)
def markdown_html_fragment(md: str) -> str:
"""Sanitized HTML fragment (body inner HTML) for embedding in templates."""
raw = _render_marked_js(md)
if raw is None:
raw = _render_markdown_fallback(md)
return nh3.clean(raw)
def markdown_plain_summary(md: str, *, max_len: int = 100) -> str:
"""
Plain-text one-line preview for list widgets: same pipeline as ``markdown_html_fragment``,
then strip tags and collapse whitespace (no Markdown noise in the UI chrome).
"""
# Cap source length so list views (search, notifications, threads) do not parse huge notes.
src = (md or "")[:1200]
frag = markdown_html_fragment(src)
plain = html.unescape(re.sub(r"<[^>]+>", " ", frag))
plain = re.sub(r"\s+", " ", plain).strip()
if len(plain) <= max_len:
return plain
return plain[: max_len - 1] + ""
_PREVIEW_CSS = """<style>
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:15px;margin:0;padding:12px;line-height:1.45;color:#1a1a1a;}
pre,code{font-family:ui-monospace,"Cascadia Code","Consolas",monospace;font-size:13px;}
pre{background:#f4f4f4;padding:10px;border-radius:6px;overflow-x:auto;}
blockquote{border-left:3px solid #bbb;margin:8px 0;padding:4px 0 4px 12px;color:#444;}
table{border-collapse:collapse;margin:8px 0;width:100%;}
th,td{border:1px solid #ccc;padding:6px;}
img{max-width:100%;height:auto;}
</style>"""
def markdown_html_document(md: str) -> str:
"""Full HTML document for ``QTextBrowser`` preview panes."""
inner = markdown_html_fragment(md)
return (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{_PREVIEW_CSS}</head><body>{inner}</body></html>"
)

34
src/imwald/core/nostr_crypto.py

@ -7,6 +7,7 @@ from hashlib import sha256 @@ -7,6 +7,7 @@ from hashlib import sha256
from typing import Any
from coincurve import PrivateKey
from coincurve.keys import PublicKeyXOnly
def serialize_event_for_id(pubkey: str, created_at: int, kind: int, tags: list, content: str) -> str:
@ -35,6 +36,39 @@ def sign_event_id(secret: bytes, event_id_hex_str: str) -> str: @@ -35,6 +36,39 @@ def sign_event_id(secret: bytes, event_id_hex_str: str) -> str:
return sig.hex()
def verify_nostr_event(ev: dict[str, Any]) -> bool:
"""NIP-01 id (serialized JSON) + BIP-340 Schnorr signature over the 32-byte event id."""
required = ("id", "pubkey", "created_at", "kind", "tags", "content", "sig")
if not all(k in ev for k in required):
return False
tags = ev["tags"]
if not isinstance(tags, list):
return False
pk_hex = str(ev["pubkey"]).lower()
if len(pk_hex) != 64 or any(c not in "0123456789abcdef" for c in pk_hex):
return False
sig_hex = str(ev["sig"]).lower()
if len(sig_hex) != 128 or any(c not in "0123456789abcdef" for c in sig_hex):
return False
try:
created_at = int(ev["created_at"])
kind = int(ev["kind"])
except (TypeError, ValueError):
return False
content = ev.get("content")
if content is not None and not isinstance(content, str):
return False
content_s = "" if content is None else str(content)
eid = event_id_hex(pk_hex, created_at, kind, tags, content_s)
if eid != str(ev["id"]).lower():
return False
try:
pk = PublicKeyXOnly(bytes.fromhex(pk_hex))
return bool(pk.verify(bytes.fromhex(sig_hex), bytes.fromhex(eid)))
except ValueError:
return False
def build_signed_event(
secret: bytes,
*,

18
src/imwald/core/nostr_engine.py

@ -13,12 +13,14 @@ from PySide6.QtCore import QObject, Signal @@ -13,12 +13,14 @@ 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_crypto import build_signed_event, verify_nostr_event
from imwald.core.relay_list import resolve_for_account
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_READ_RELAYS,
DEFAULT_WRITE_RELAYS,
WISP_TRENDING_FEED_KINDS,
default_feed_read_relays,
@ -48,11 +50,13 @@ class NostrEngine(QObject): @@ -48,11 +50,13 @@ class NostrEngine(QObject):
self,
read_urls: list[str] | None = None,
*,
user_write_urls: list[str] | None = None,
list30000_owner: str | None = None,
) -> None:
if self._thread and self._thread.is_alive():
return
self.stop_relays()
urls = list(read_urls or default_feed_read_relays())
aggr_writes = set(user_write_urls or list(DEFAULT_WRITE_RELAYS))
k3000_owner = (list30000_owner or "").strip().lower()
def runner() -> None:
@ -76,7 +80,7 @@ class NostrEngine(QObject): @@ -76,7 +80,7 @@ class NostrEngine(QObject):
f"imwald-{abs(hash(u)) % 10**8}",
[{"kinds": kinds, "limit": 220}],
)
if use_aggr_for_threads(set(DEFAULT_WRITE_RELAYS)):
if use_aggr_for_threads(aggr_writes):
mgr.register(AGGR_THREAD_RELAY)
mgr.request_subscribe(
AGGR_THREAD_RELAY,
@ -120,6 +124,8 @@ class NostrEngine(QObject): @@ -120,6 +124,8 @@ class NostrEngine(QObject):
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 not verify_nostr_event(ev):
return
if ev.get("kind") == 5:
for t in ev.get("tags") or []:
if t and t[0] == "e" and len(t) > 1:
@ -147,9 +153,9 @@ class NostrEngine(QObject): @@ -147,9 +153,9 @@ class NostrEngine(QObject):
)
tags_10002: list[list[str]] = [["client", "imwald"]]
for r in DEFAULT_READ_RELAYS:
tags_10002.extend([["r", r], ["read", "true"]])
tags_10002.append(["r", r, "read"])
for r in DEFAULT_WRITE_RELAYS:
tags_10002.extend([["r", r], ["write", "true"]])
tags_10002.append(["r", r, "write"])
ev10002 = build_signed_event(sec, created_at=now, kind=10002, tags=tags_10002, content="")
tags_10015 = [["client", "imwald"]]
for t in interest_tags[:50]:
@ -170,5 +176,5 @@ class NostrEngine(QObject): @@ -170,5 +176,5 @@ class NostrEngine(QObject):
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)
publish_to_relays_sync(resolve_for_account(self.db, account.pubkey).write_urls, ev)
self.db.upsert_event(ev)

3
src/imwald/core/nostr_publish.py

@ -30,7 +30,8 @@ async def publish_to_relays(urls: list[str], event: dict[str, Any], timeout: flo @@ -30,7 +30,8 @@ async def publish_to_relays(urls: list[str], event: dict[str, Any], timeout: flo
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])
# NIP-01: ["OK", <event_id>, <bool>, <message optional>]
ok = isinstance(msg, list) and len(msg) >= 3 and msg[0] == "OK" and msg[2] is True
results[url] = ok
except Exception as e: # noqa: BLE001
log.info("publish fail %s: %s", url, e)

125
src/imwald/core/relay_list.py

@ -0,0 +1,125 @@ @@ -0,0 +1,125 @@
"""NIP-65 kind 10002 relay lists: parse tags and resolve read/write URLs for an account."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from imwald.core.database import Database
from imwald.core.relay_policy import DEFAULT_READ_RELAYS, DEFAULT_WRITE_RELAYS
_MAX_RELAYS_PER_BUCKET = 24
def _is_ws_relay_url(url: str) -> bool:
u = url.strip()
return u.startswith("wss://") or u.startswith("ws://")
def _dedupe_preserve(urls: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for u in urls:
if u not in seen:
seen.add(u)
out.append(u)
return out
def parse_kind10002_tags(tags: Any) -> tuple[list[str], list[str]]:
"""
Parse NIP-65 `r` tags into (read_urls, write_urls).
- ``["r", url]`` or unknown third value both read and write.
- ``["r", url, "read"|"write"]`` that direction only.
- Legacy imwald/jumble-style pairs: ``["r", url], ["read","true"]`` / ``["write","true"]``.
"""
if not isinstance(tags, list):
return [], []
read: list[str] = []
write: list[str] = []
i = 0
while i < len(tags):
t = tags[i]
i += 1
if not t or not isinstance(t, list) or len(t) < 2:
continue
if str(t[0]) != "r":
continue
url = str(t[1]).strip()
if not _is_ws_relay_url(url):
continue
mode = "both"
if len(t) >= 3:
m = str(t[2]).lower()
if m == "read":
mode = "read"
elif m == "write":
mode = "write"
else:
if i < len(tags):
nxt = tags[i]
if nxt and isinstance(nxt, list) and len(nxt) >= 2:
name = str(nxt[0]).lower()
val = str(nxt[1]).lower()
if name == "read" and val in ("true", "1", "yes"):
mode = "read"
i += 1
elif name == "write" and val in ("true", "1", "yes"):
mode = "write"
i += 1
if mode in ("both", "read"):
read.append(url)
if mode in ("both", "write"):
write.append(url)
return _dedupe_preserve(read[:_MAX_RELAYS_PER_BUCKET]), _dedupe_preserve(write[:_MAX_RELAYS_PER_BUCKET])
@dataclass(frozen=True)
class ResolvedRelays:
"""Read/write websocket URLs after NIP-65 + fallbacks for empty halves."""
read_urls: list[str]
write_urls: list[str]
had_kind10002: bool
def resolve_for_account(db: Database, pubkey: str | None) -> ResolvedRelays:
"""
Load the latest kind 10002 for ``pubkey``. If missing or no usable ``r`` tags,
use default read and write relays. If the list exists but one side is empty,
fill that side from defaults.
"""
if not pubkey or len(pubkey.strip()) != 64:
return ResolvedRelays(
read_urls=list(DEFAULT_READ_RELAYS),
write_urls=list(DEFAULT_WRITE_RELAYS),
had_kind10002=False,
)
ev = db.get_latest_kind10002_event(pubkey.strip().lower())
if not ev:
return ResolvedRelays(
read_urls=list(DEFAULT_READ_RELAYS),
write_urls=list(DEFAULT_WRITE_RELAYS),
had_kind10002=False,
)
parsed_read, parsed_write = parse_kind10002_tags(ev.get("tags") or [])
if not parsed_read and not parsed_write:
return ResolvedRelays(
read_urls=list(DEFAULT_READ_RELAYS),
write_urls=list(DEFAULT_WRITE_RELAYS),
had_kind10002=True,
)
read_urls = parsed_read or list(DEFAULT_READ_RELAYS)
write_urls = parsed_write or list(DEFAULT_WRITE_RELAYS)
return ResolvedRelays(
read_urls=_dedupe_preserve(read_urls),
write_urls=_dedupe_preserve(write_urls),
had_kind10002=True,
)

11
src/imwald/core/relay_policy.py

@ -32,17 +32,22 @@ def use_aggr_for_threads(user_write_urls: set[str]) -> bool: @@ -32,17 +32,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)."""
def augment_feed_with_trending(base_read_relays: list[str]) -> list[str]:
"""Dedupe user/default read relays and append the Wisp trending slice."""
seen: set[str] = set()
out: list[str] = []
for u in (*DEFAULT_READ_RELAYS, WISP_TRENDING_NOTES_RELAY):
for u in (*base_read_relays, WISP_TRENDING_NOTES_RELAY):
if u not in seen:
seen.add(u)
out.append(u)
return out
def default_feed_read_relays() -> list[str]:
"""Read set for the main + trending mix (deduped)."""
return augment_feed_with_trending(list(DEFAULT_READ_RELAYS))
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

6
src/imwald/ui/assets/vendor/marked.min.js vendored

File diff suppressed because one or more lines are too long

26
src/imwald/ui/composer_dialog.py

@ -6,7 +6,6 @@ import json @@ -6,7 +6,6 @@ import json
import time
from typing import Any
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QDialog,
@ -16,7 +15,6 @@ from PySide6.QtWidgets import ( @@ -16,7 +15,6 @@ from PySide6.QtWidgets import (
QLabel,
QLineEdit,
QMessageBox,
QPlainTextEdit,
QSpinBox,
QVBoxLayout,
)
@ -24,7 +22,7 @@ from PySide6.QtWidgets import ( @@ -24,7 +22,7 @@ from PySide6.QtWidgets import (
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
from imwald.ui.markdown_editor_widget import MarkdownBodyEditor
FEED_KINDS = [1, 20, 21, 30023, 9802, 11]
TAG_SUGGESTIONS = ["t", "client", "e", "p", "relay", "imeta"]
@ -38,12 +36,15 @@ class ComposerDialog(QDialog): @@ -38,12 +36,15 @@ class ComposerDialog(QDialog):
edit_from: dict[str, Any] | None = None,
account: StoredAccount,
password: str | None = None,
write_relays: list[str],
) -> None:
super().__init__(parent)
self.setWindowTitle("New event" if edit_from is None else "Edit event (clone)")
self.resize(960, 640)
self._account = account
self._password = password
self._edit_from = edit_from
self._write_relays = list(write_relays)
self.last_published: dict | None = None
self._kind = QSpinBox()
@ -56,10 +57,12 @@ class ComposerDialog(QDialog): @@ -56,10 +57,12 @@ class ComposerDialog(QDialog):
self._tags = QLineEdit()
self._tags.setPlaceholderText('JSON array of tags, e.g. [["t","nostr"]]')
self._content = QPlainTextEdit()
self._content = MarkdownBodyEditor()
self._hint = QLabel("Suggestions: " + ", ".join(f'["{t}",""]' for t in TAG_SUGGESTIONS[:4]))
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self._publish)
buttons.rejected.connect(self.reject)
@ -106,12 +109,19 @@ class ComposerDialog(QDialog): @@ -106,12 +109,19 @@ class ComposerDialog(QDialog):
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)
publish_to_relays_sync(self._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:
def open_composer_for_edit(
parent,
ev: dict[str, Any],
account: StoredAccount,
password: str | None,
*,
write_relays: list[str],
) -> 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 = ComposerDialog(parent, edit_from=clone, account=account, password=password, write_relays=write_relays)
dlg.exec()

23
src/imwald/ui/feed_page.py

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
from __future__ import annotations
import html
import json
from typing import Any
@ -19,6 +20,7 @@ from PySide6.QtWidgets import ( @@ -19,6 +20,7 @@ from PySide6.QtWidgets import (
)
from imwald.core.database import Database
from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary
from imwald.core.nostr_engine import NostrEngine
from imwald.core.ranker import Ranker
@ -124,9 +126,10 @@ class FeedPage(QWidget): @@ -124,9 +126,10 @@ class FeedPage(QWidget):
return
ev = self._queue[self._index % len(self._queue)]
if ev.get("deleted"):
raw = html.escape(ev.get("content") or "")
self._op.setHtml(
f"<p><i>Marked deleted locally</i></p><pre>{ev.get('content','')}</pre>"
f"<p style='color:gray'>{ev['id']}</p>"
f"<p><i>Marked deleted locally</i></p><pre>{raw}</pre>"
f"<p style='color:gray'>{html.escape(ev['id'])}</p>"
)
self._thread.clear()
self._why.setText("")
@ -142,17 +145,23 @@ class FeedPage(QWidget): @@ -142,17 +145,23 @@ class FeedPage(QWidget):
sr = ev.get("source_relay") or ""
if sr and "nostrarchives.com" in sr:
tr = "<p><i>Trending slice (nostrarchives)</i></p>"
pk = html.escape(ev["pubkey"][:16] + "")
eid = html.escape(ev["id"])
md_body = markdown_html_fragment(ev.get("content") or "")
body = (
f"<h2>Kind {ev['kind']}</h2>"
f"<p><b>{ev['pubkey'][:16]}…</b> · {ev['created_at']}</p>"
f"<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>"
f"<h2>Kind {int(ev['kind'])}</h2>"
f"<p><b>{pk}</b> · {int(ev['created_at'])}</p>"
f"{tr}"
f"<pre>{ev.get('content','')}</pre>"
f"<p style='color:gray'>{ev['id']}</p>"
f"<div class=\"md\">{md_body}</div>"
f"<p style='color:gray'>{eid}</p>"
f"</body></html>"
)
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}"
snippet = markdown_plain_summary(r.get("content") or "", max_len=90)
line = f"k{r['kind']} {r['pubkey'][:8]}… — {snippet}"
it = QListWidgetItem(line)
it.setData(Qt.ItemDataRole.UserRole, r["id"])
self._thread.addItem(it)

45
src/imwald/ui/main_window.py

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QComboBox,
@ -22,6 +22,9 @@ from PySide6.QtWidgets import ( @@ -22,6 +22,9 @@ from PySide6.QtWidgets import (
from imwald.core.accounts_store import StoredAccount, load_accounts
from imwald.core.database import Database
from imwald.core.nostr_engine import NostrEngine
from imwald.core.md_render import markdown_plain_summary
from imwald.core.relay_list import resolve_for_account
from imwald.core.relay_policy import augment_feed_with_trending
from imwald.ui.composer_dialog import ComposerDialog
from imwald.ui.db_admin_page import DbAdminPage
from imwald.ui.feed_page import FeedPage
@ -67,6 +70,11 @@ class MainWindow(QMainWindow): @@ -67,6 +70,11 @@ class MainWindow(QMainWindow):
self._wire_engine()
self._wire_pages()
self._ingest_ui_timer = QTimer(self)
self._ingest_ui_timer.setSingleShot(True)
self._ingest_ui_timer.setInterval(450)
self._ingest_ui_timer.timeout.connect(self._flush_ingest_ui_refresh)
self._acct_combo.currentIndexChanged.connect(self._on_account_changed)
self._on_account_changed()
@ -77,8 +85,7 @@ class MainWindow(QMainWindow): @@ -77,8 +85,7 @@ class MainWindow(QMainWindow):
self._reload_account_combo()
self._notif.set_accounts(self._accounts)
self._dbadm.set_accounts(self._accounts)
self._feed.reload_queue()
self._on_account_changed()
def _reload_account_combo(self) -> None:
self._acct_combo.blockSignals(True)
@ -120,6 +127,22 @@ class MainWindow(QMainWindow): @@ -120,6 +127,22 @@ class MainWindow(QMainWindow):
list300 = self._db.list_kind30000_list_pubkeys(pk)
self._feed.set_context(pk, following, list300)
self._feed.reload_queue()
self._restart_relays()
def _restart_relays(self) -> None:
pk = self._current_pubkey()
resolved = resolve_for_account(self._db, pk)
reads = augment_feed_with_trending(resolved.read_urls)
self._engine.start_relays(
read_urls=reads,
user_write_urls=resolved.write_urls,
list30000_owner=self.list_owner_pubkey_for_relays(),
)
def _flush_ingest_ui_refresh(self) -> None:
if self._stack.currentWidget() is self._feed:
self._feed.refresh_tail()
self._notif.refresh_all()
def _wire_menu(self) -> None:
m_file = self.menuBar().addMenu("&File")
@ -173,9 +196,7 @@ class MainWindow(QMainWindow): @@ -173,9 +196,7 @@ class MainWindow(QMainWindow):
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()
self._ingest_ui_timer.start()
def _wire_pages(self) -> None:
self._search.open_event.connect(self._open_event)
@ -201,7 +222,7 @@ class MainWindow(QMainWindow): @@ -201,7 +222,7 @@ class MainWindow(QMainWindow):
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.")
QMessageBox.information(self, "NIP-09", "Deletion request published to your write relays.")
def _edit_current(self) -> None:
eid = self._feed.current_event_id()
@ -214,7 +235,8 @@ class MainWindow(QMainWindow): @@ -214,7 +235,8 @@ class MainWindow(QMainWindow):
if not acc:
QMessageBox.information(self, "Edit", "Select an account.")
return
dlg = ComposerDialog(self, edit_from=ev, account=acc, password=pw)
writes = resolve_for_account(self._db, acc.pubkey).write_urls
dlg = ComposerDialog(self, edit_from=ev, account=acc, password=pw, write_relays=writes)
if dlg.exec() == QDialog.DialogCode.Accepted and dlg.last_published:
self._db.upsert_event(dlg.last_published)
@ -223,7 +245,8 @@ class MainWindow(QMainWindow): @@ -223,7 +245,8 @@ class MainWindow(QMainWindow):
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)
writes = resolve_for_account(self._db, acc.pubkey).write_urls
dlg = ComposerDialog(self, edit_from=None, account=acc, password=pw, write_relays=writes)
if dlg.exec() == QDialog.DialogCode.Accepted and dlg.last_published:
self._db.upsert_event(dlg.last_published)
@ -237,7 +260,8 @@ class MainWindow(QMainWindow): @@ -237,7 +260,8 @@ class MainWindow(QMainWindow):
d.setWindowTitle("Your latest events")
lw = QListWidget()
for ev in rows:
it = QListWidgetItem(f"k{ev['kind']} {ev['id'][:16]}{ev['content'][:60]!r}")
snippet = markdown_plain_summary(ev.get("content") or "", max_len=56)
it = QListWidgetItem(f"k{ev['kind']} {ev['id'][:16]}… — {snippet}")
it.setData(Qt.ItemDataRole.UserRole, ev["id"])
lw.addItem(it)
@ -260,6 +284,7 @@ class MainWindow(QMainWindow): @@ -260,6 +284,7 @@ class MainWindow(QMainWindow):
self._reload_account_combo()
self._notif.set_accounts(self._accounts)
self._dbadm.set_accounts(self._accounts)
self._on_account_changed()
def _account_for_compose(self) -> tuple[StoredAccount | None, str | None]:
if self._stack.currentWidget() is self._notif:

57
src/imwald/ui/markdown_editor_widget.py

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
"""Split Markdown composer: monospace source + offline rendered preview (marked via QuickJS)."""
from __future__ import annotations
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QPlainTextEdit, QSizePolicy, QSplitter, QTextBrowser, QVBoxLayout, QWidget
from imwald.core.md_render import markdown_html_document
class MarkdownBodyEditor(QWidget):
"""Plain-text Markdown editor with live rendered preview (local ``marked`` + nh3)."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._split = QSplitter(Qt.Orientation.Horizontal)
self._source = QPlainTextEdit()
self._source.setPlaceholderText("Markdown source — preview updates as you type")
mono = QFont("monospace")
if not mono.exactMatch():
mono = QFont("Courier New")
self._source.setFont(mono)
self._source.setMinimumHeight(260)
self._source.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._preview = QTextBrowser()
self._preview.setOpenExternalLinks(True)
self._preview.setMinimumHeight(260)
self._preview.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._split.addWidget(self._source)
self._split.addWidget(self._preview)
self._split.setStretchFactor(0, 1)
self._split.setStretchFactor(1, 1)
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self._split)
self._debounce = QTimer(self)
self._debounce.setSingleShot(True)
self._debounce.setInterval(280)
self._debounce.timeout.connect(self._update_preview)
self._source.textChanged.connect(lambda: self._debounce.start())
self._update_preview()
def _update_preview(self) -> None:
self._preview.setHtml(markdown_html_document(self._source.toPlainText()))
def setPlainText(self, text: str) -> None:
self._source.setPlainText(text)
self._debounce.start()
def toPlainText(self) -> str:
return self._source.toPlainText()

11
src/imwald/ui/notifications_page.py

@ -7,6 +7,7 @@ from PySide6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QTabWidget, @@ -7,6 +7,7 @@ from PySide6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QTabWidget,
from imwald.core.accounts_store import StoredAccount
from imwald.core.database import Database
from imwald.core.md_render import markdown_plain_summary
class NotificationsPage(QWidget):
@ -56,13 +57,17 @@ class NotificationsPage(QWidget): @@ -56,13 +57,17 @@ class NotificationsPage(QWidget):
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
SELECT n.source_event_id, n.kind, n.read, n.created_at, e.content AS content
FROM notifications n
LEFT JOIN events e ON e.id = n.source_event_id AND e.deleted = 0
WHERE n.recipient_pubkey=? ORDER BY n.created_at DESC LIMIT 200
""",
(pubkey,),
)
for row in cur:
title = f"{row['kind']} {row['source_event_id'][:12]}… read={row['read']}"
snippet = markdown_plain_summary(row["content"] or "", max_len=56) if row["content"] else ""
tail = f"{snippet}" if snippet else ""
title = f"{row['kind']} {row['source_event_id'][:12]}… read={row['read']}{tail}"
it = QListWidgetItem(title)
it.setData(Qt.ItemDataRole.UserRole, row["source_event_id"])
lw.addItem(it)

4
src/imwald/ui/search_page.py

@ -6,6 +6,7 @@ from PySide6.QtCore import Qt, Signal @@ -6,6 +6,7 @@ from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget
from imwald.core.database import Database
from imwald.core.md_render import markdown_plain_summary
class SearchPage(QWidget):
@ -35,7 +36,8 @@ class SearchPage(QWidget): @@ -35,7 +36,8 @@ class SearchPage(QWidget):
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}"
snippet = markdown_plain_summary(ev.get("content") or "", max_len=72)
title = f"{ev['kind']} {ev['id'][:12]}… — {snippet}"
it = QListWidgetItem(title)
it.setData(Qt.ItemDataRole.UserRole, ev["id"])
self._list.addItem(it)

25
tests/test_event_verify.py

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
from imwald.core.nostr_crypto import build_signed_event, verify_nostr_event
def _sk() -> bytes:
return bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683")
def test_verify_accepts_signed_roundtrip() -> None:
sk = _sk()
ev = build_signed_event(sk, created_at=1700000000, kind=1, tags=[["client", "imwald"]], content="hello")
assert verify_nostr_event(ev) is True
def test_verify_rejects_bad_id() -> None:
sk = _sk()
ev = build_signed_event(sk, created_at=1700000000, kind=1, tags=[], content="x")
ev["id"] = "f" * 64
assert verify_nostr_event(ev) is False
def test_verify_rejects_tampered_content() -> None:
sk = _sk()
ev = build_signed_event(sk, created_at=1700000000, kind=1, tags=[], content="x")
ev["content"] = "y"
assert verify_nostr_event(ev) is False

17
tests/test_md_render.py

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary
def test_plain_summary_strips_markdown_noise() -> None:
s = markdown_plain_summary("# Title\n\nHello **world**", max_len=80)
assert "Title" in s and "world" in s
assert "**" not in s and "#" not in s
def test_markdown_renders_strong() -> None:
html = markdown_html_fragment("Hello **world**")
assert "<strong>world</strong>" in html or "<b>world</b>" in html
def test_markdown_fenced_code() -> None:
html = markdown_html_fragment("```\n1 + 1\n```")
assert "<pre>" in html and "<code>" in html

57
tests/test_relay_list.py

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
import tempfile
from pathlib import Path
from imwald.core.database import Database
from imwald.core.nostr_crypto import build_signed_event
from imwald.core.relay_list import parse_kind10002_tags, resolve_for_account
from imwald.core.relay_policy import DEFAULT_READ_RELAYS, DEFAULT_WRITE_RELAYS
def _sk() -> bytes:
return bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683")
def test_parse_nip65_triple_tags() -> None:
tags = [
["r", "wss://read.only", "read"],
["r", "wss://write.only", "write"],
["r", "wss://both.relay"],
]
reads, writes = parse_kind10002_tags(tags)
assert "wss://read.only" in reads and "wss://read.only" not in writes
assert "wss://write.only" in writes and "wss://write.only" not in reads
assert "wss://both.relay" in reads and "wss://both.relay" in writes
def test_parse_legacy_read_write_pairs() -> None:
tags = [["r", "wss://a"], ["read", "true"], ["r", "wss://b"], ["write", "true"]]
reads, writes = parse_kind10002_tags(tags)
assert reads == ["wss://a"]
assert writes == ["wss://b"]
def test_resolve_defaults_without_kind10002() -> None:
with tempfile.TemporaryDirectory() as td:
db = Database(Path(td) / "x.sqlite")
db.connect()
sk = _sk()
me = build_signed_event(sk, created_at=1, kind=0, tags=[], content="{}")["pubkey"]
r = resolve_for_account(db, me)
assert r.read_urls == list(DEFAULT_READ_RELAYS)
assert r.write_urls == list(DEFAULT_WRITE_RELAYS)
assert r.had_kind10002 is False
def test_resolve_uses_stored_kind10002() -> None:
with tempfile.TemporaryDirectory() as td:
db = Database(Path(td) / "y.sqlite")
db.connect()
sk = _sk()
me = build_signed_event(sk, created_at=1, kind=0, tags=[], content="{}")["pubkey"]
tags = [["r", "wss://custom.read", "read"], ["r", "wss://custom.write", "write"]]
ev = build_signed_event(sk, created_at=2, kind=10002, tags=tags, content="")
db.upsert_event(ev)
r = resolve_for_account(db, me)
assert r.read_urls == ["wss://custom.read"]
assert r.write_urls == ["wss://custom.write"]
assert r.had_kind10002 is True
Loading…
Cancel
Save