Browse Source

beautify the profile pages

master
Silberengel 2 weeks ago
parent
commit
a03f9d1fc7
  1. 79
      src/imwald/core/author_html.py
  2. 23
      src/imwald/core/forest_avatar.py
  3. 55
      src/imwald/core/kind0_profile.py
  4. 90
      src/imwald/core/nip05.py
  5. 96
      src/imwald/core/nip38_status.py
  6. 2
      src/imwald/core/nostr_engine.py
  7. 177
      src/imwald/ui/feed_page.py
  8. 47
      src/imwald/ui/main_window.py
  9. 376
      src/imwald/ui/media_viewer_dialog.py
  10. 526
      src/imwald/ui/profile_page.py
  11. 45
      src/imwald/ui/theme.py
  12. 20
      tests/test_forest_avatar.py
  13. 16
      tests/test_media_viewer.py
  14. 21
      tests/test_nip05_collect.py

79
src/imwald/core/author_html.py

@ -5,6 +5,7 @@ from __future__ import annotations @@ -5,6 +5,7 @@ from __future__ import annotations
import html
from imwald.core.kind0_profile import display_name_from_profile, display_name_from_profile_or_hex
from imwald.core.nip05 import favicon_url_for_domain, parse_nip05_identifier
def safe_http_url(u: str | None) -> str | None:
@ -50,38 +51,78 @@ def avatar_img_or_placeholder( @@ -50,38 +51,78 @@ def avatar_img_or_placeholder(
)
def feed_op_author_block_html(
def format_nip05_chips_html(
identifiers: list[tuple[str, bool | None]],
*,
muted: str,
dim: str,
ok: str,
bad: str,
) -> str:
"""
Horizontal row of NIP-05 chips: favicon (per domain), identifier, verification mark.
``bool | None``: ``True`` verified, ``False`` failed, ``None`` pending / not checked.
"""
parts: list[str] = []
for ident, okv in identifiers:
parsed = parse_nip05_identifier(ident)
dom = parsed[1] if parsed else ""
fav = favicon_url_for_domain(dom) if dom else ""
esc = html.escape(ident, quote=False)
esc_fav = html.escape(fav, quote=True) if fav else ""
icon = ""
if fav:
icon = (
f'<img src="{esc_fav}" width="14" height="14" alt="" '
f'style="vertical-align:middle;border-radius:3px;opacity:0.92" />'
)
if okv is True:
mark = f'<span style="color:{ok}" title="NIP-05 verified">✓</span>'
elif okv is False:
mark = f'<span style="color:{bad}" title="NIP-05 not verified">✗</span>'
else:
mark = f'<span style="color:{dim}" title="NIP-05 not verified yet">○</span>'
parts.append(
f'<span style="display:inline-flex;align-items:center;gap:5px;margin:4px 14px 0 0">'
f"{icon}"
f'<span style="color:{muted};font-size:14px">{esc}</span>{mark}'
f"</span>"
)
if not parts:
return ""
return f'<div style="display:flex;flex-wrap:wrap;align-items:center;margin-top:4px">{"".join(parts)}</div>'
def feed_op_author_compact_html(
parsed: dict[str, str | None],
npub_bech: str,
pk_short: str,
nip_line_html: str,
about_line_html: str,
*,
pubkey_hex: str,
text: str,
*,
status_inner_html: str,
nip05_chips_html: str,
muted: str,
dim: str,
border: str,
) -> str:
"""Top-of-note author row: picture, display name, npub, optional nip05/about lines (links to profile tab)."""
disp = html.escape(display_name_from_profile(parsed))
"""Feed OP header: avatar, npub only, optional NIP-38 status (HTML), NIP-05 chips."""
pk_l = pubkey_hex.strip().lower()
href = html.escape(f"imwald://pub/{pk_l}", quote=True)
av = avatar_img_or_placeholder(parsed, 52, border_hex=border, profile_href=href)
npub_e = html.escape(npub_bech)
pk_s = html.escape(pk_short)
inner = (
f'<div style="display:flex;align-items:flex-start;margin-bottom:12px">'
status_block = ""
if status_inner_html.strip():
status_block = f'<div style="margin-top:4px" class="md">{status_inner_html}</div>'
return (
f'<div style="display:flex;align-items:flex-start;margin-bottom:12px;gap:12px">'
f"{av}"
f'<a href="{href}" style="text-decoration:none;color:inherit;cursor:pointer;display:block;flex:1;min-width:0" '
f'title="View profile">'
f'<div style="font-size:21px;font-weight:600;color:{text}">{disp}</div>'
f'<div style="color:{muted};font-size:15px">{npub_e} · {pk_s}</div>'
f"{nip_line_html}{about_line_html}"
f'<div style="flex:1;min-width:0">'
f'<a href="{href}" style="text-decoration:none;color:inherit" title="View profile">'
f'<div style="color:{muted};font-size:15px;font-weight:500;word-break:break-all">{npub_e}</div>'
f"</a>"
f"</div>"
f"{status_block}"
f"{nip05_chips_html}"
f"</div></div>"
)
return inner
def thread_reply_author_row_html(

23
src/imwald/core/forest_avatar.py

@ -6,11 +6,11 @@ import io @@ -6,11 +6,11 @@ import io
import random
def build_forest_avatar_png(*, size: int = 192) -> bytes:
def build_forest_avatar_png(*, size: int = 192, seed: int = 42) -> bytes:
"""Return small PNG bytes: low resolution, palette, max zlib — keeps nostr.build uploads tiny."""
from PIL import Image, ImageDraw # type: ignore[import-not-found, import-untyped]
rng = random.Random(42)
rng = random.Random(int(seed) % (2**31))
w = h = max(64, min(size, 256))
img = Image.new("RGB", (w, h), (22, 68, 42))
dr = ImageDraw.Draw(img)
@ -46,3 +46,22 @@ def build_forest_avatar_png(*, size: int = 192) -> bytes: @@ -46,3 +46,22 @@ def build_forest_avatar_png(*, size: int = 192) -> bytes:
buf = io.BytesIO()
paletted.save(buf, format="PNG", compress_level=9, optimize=True)
return buf.getvalue()
def build_forest_banner_png(*, width: int = 1600, height: int = 260, seed: int = 0) -> bytes:
"""
Wide forest-themed PNG for profile headers when the user has no ``banner`` URL.
Built from the same generative style as :func:`build_forest_avatar_png`, stretched to banner aspect.
"""
from PIL import Image # type: ignore[import-not-found, import-untyped]
w = max(480, min(int(width), 2400))
h = max(96, min(int(height), 480))
sq = max(256, min(h * 2, 640))
raw = build_forest_avatar_png(size=sq, seed=seed)
im = Image.open(io.BytesIO(raw)).convert("RGB")
im = im.resize((w, h), Image.Resampling.LANCZOS)
buf = io.BytesIO()
im.save(buf, format="PNG", compress_level=9, optimize=True)
return buf.getvalue()

55
src/imwald/core/kind0_profile.py

@ -5,6 +5,8 @@ from __future__ import annotations @@ -5,6 +5,8 @@ from __future__ import annotations
import json
from typing import cast
from imwald.core.nip05 import scan_nip05_like_strings
def parse_kind0_profile(content: str) -> dict[str, str | None]:
"""Return display fields from kind-0 ``content`` JSON (best-effort)."""
@ -48,6 +50,59 @@ def display_name_from_profile(p: dict[str, str | None]) -> str: @@ -48,6 +50,59 @@ def display_name_from_profile(p: dict[str, str | None]) -> str:
return (p.get("display_name") or p.get("name") or "").strip() or "anon"
def kind0_json_object_prefix(content: str) -> str:
"""If ``content`` starts with JSON then has trailing text, return only the first ``{…}`` object."""
s = (content or "").strip()
if not s or not s.lstrip().startswith("{"):
return s
depth = 0
start = s.find("{")
if start < 0:
return s
for i in range(start, len(s)):
c = s[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return s[start : i + 1]
return s
def collect_nip05_identifiers(kind0_content: str, kind0_tags: list[list[str]] | None) -> list[str]:
"""
All plausible NIP-05 identifiers for an author: JSON ``nip05``, ``nip05`` / ``nip-05`` tags,
and ``user@host``-shaped strings in the raw kind-0 ``content`` (deduped, order preserved).
"""
seen: set[str] = set()
out: list[str] = []
def add(s: str | None) -> None:
if not s or not isinstance(s, str):
return
t = s.strip()
if not t or "@" not in t:
return
k = t.lower()
if k in seen:
return
seen.add(k)
out.append(t)
p = parse_kind0_profile(kind0_json_object_prefix(kind0_content or ""))
add(p.get("nip05"))
for t in kind0_tags or []:
if len(t) < 2:
continue
name = str(t[0]).lower()
if name in ("nip05", "nip-05"):
add(str(t[1]))
for s in scan_nip05_like_strings(kind0_content or ""):
add(s)
return out
def display_name_from_profile_or_hex(p: dict[str, str | None], pubkey_hex: str) -> str:
"""Like ``display_name_from_profile`` but falls back to a short hex id when no name is set."""
n = (p.get("display_name") or p.get("name") or "").strip()

90
src/imwald/core/nip05.py

@ -0,0 +1,90 @@ @@ -0,0 +1,90 @@
"""NIP-05 identifier parsing and HTTPS ``/.well-known/nostr.json`` verification (Jumble-style)."""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.parse
import urllib.request
from typing import Final
_USER_AGENT: Final = "imwald/0.1 (NIP-05; +https://github.com/nostr-protocol/nostr)"
# Loose identifier: local@domain with plausible host (NIP-05 shape).
_NIP05_LIKE: Final = re.compile(
r"\b([a-z0-9._+-]+)@([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)\b",
re.IGNORECASE,
)
def parse_nip05_identifier(raw: str) -> tuple[str, str] | None:
"""
Split ``local@domain`` into parts suitable for NIP-05 well-known lookup.
Returns ``(local, domain_lower)`` or ``None`` if the string is not a plausible NIP-05 id.
"""
s = (raw or "").strip()
if not s or "@" not in s:
return None
local, _, host = s.rpartition("@")
local = local.strip()
host = host.strip().lower()
if not local or not host or "." not in host:
return None
if any(c in local for c in (" ", "\t", "\n", "/", "\\")):
return None
return local, host
def favicon_url_for_domain(domain: str) -> str:
"""Small favicon URL for UI chips (same pattern many Nostr clients use)."""
d = domain.strip().lower()
return f"https://www.google.com/s2/favicons?sz=16&domain={urllib.parse.quote(d, safe='')}"
def verify_nip05(pubkey_hex: str, nip05_identifier: str, *, timeout: float = 10.0) -> bool:
"""
Return whether ``nip05_identifier`` maps to ``pubkey_hex`` via
``https://<domain>/.well-known/nostr.json?name=<local>`` (NIP-05).
"""
parsed = parse_nip05_identifier(nip05_identifier)
if not parsed:
return False
local, domain = parsed
pk = pubkey_hex.strip().lower()
if len(pk) != 64 or any(c not in "0123456789abcdef" for c in pk):
return False
url = f"https://{domain}/.well-known/nostr.json?name={urllib.parse.quote(local, safe='')}"
try:
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
raw = resp.read(256_000)
data = json.loads(raw.decode("utf-8", errors="replace"))
except (OSError, UnicodeError, json.JSONDecodeError, urllib.error.URLError):
return False
if not isinstance(data, dict):
return False
names = data.get("names")
if not isinstance(names, dict):
return False
for key, val in names.items():
if str(key).lower() == local.lower() and isinstance(val, str):
got = val.strip().lower()
if len(got) == 64 and all(c in "0123456789abcdef" for c in got):
return got == pk
return False
def scan_nip05_like_strings(text: str) -> list[str]:
"""Return ordered unique substrings in ``text`` that look like ``user@host.tld``."""
seen: set[str] = set()
out: list[str] = []
for m in _NIP05_LIKE.finditer(text or ""):
s = m.group(0).strip()
key = s.lower()
if key in seen:
continue
seen.add(key)
out.append(s)
return out

96
src/imwald/core/nip38_status.py

@ -0,0 +1,96 @@ @@ -0,0 +1,96 @@
"""NIP-38 (kind 30315) user status: pick latest non-expired status event from the local DB."""
from __future__ import annotations
import json
import time
from typing import Any, cast
from imwald.core.database import Database
def _expiration_ts(tags: list[list[str]]) -> int | None:
for t in tags:
if len(t) >= 2 and str(t[0]).lower() == "expiration":
try:
return int(str(t[1]).strip())
except ValueError:
return None
return None
def _first_link_tag(tags: list[list[str]]) -> str | None:
for t in tags:
if len(t) >= 2 and str(t[0]).lower() == "r":
u = str(t[1]).strip()
if u.startswith("https://") or u.startswith("http://"):
return u
return None
def get_active_user_status_event(db: Database, pubkey_hex: str) -> dict[str, Any] | None:
"""
Latest **non-expired** kind ``30315`` for ``pubkey_hex`` with non-empty ``content``.
Prefers ``d`` tag ``general``, then ``music``, then any other ``d`` with a body (newest first).
"""
pk = pubkey_hex.strip().lower()
if len(pk) != 64 or any(c not in "0123456789abcdef" for c in pk):
return None
now = int(time.time())
for d in ("general", "music"):
ev = db.get_event_by_addressable(30315, pk, d)
if not ev or ev.get("deleted"):
continue
tags_raw = ev.get("tags")
tags = cast(list[list[str]], tags_raw) if isinstance(tags_raw, list) else []
exp = _expiration_ts(tags)
if exp is not None and exp <= now:
continue
if not str(ev.get("content") or "").strip():
continue
return ev
# Other custom ``d`` values: newest 30315 by this author with content + not expired.
cur = db.conn().execute(
"""
SELECT id, pubkey, created_at, kind, content, sig, tags_json, deleted, source_relay
FROM events
WHERE deleted = 0 AND kind = 30315 AND lower(pubkey) = lower(?)
ORDER BY created_at DESC
LIMIT 24
""",
(pk,),
)
for row in cur.fetchall():
tags = cast(list[list[str]], json.loads(row["tags_json"] or "[]"))
d_val = ""
for t in tags:
if len(t) >= 2 and str(t[0]).lower() == "d":
d_val = str(t[1] or "")
break
if d_val in ("general", "music"):
continue
exp = _expiration_ts(tags)
if exp is not None and exp <= now:
continue
if not str(row["content"] or "").strip():
continue
return {
"id": row["id"],
"pubkey": row["pubkey"],
"created_at": int(row["created_at"]),
"kind": int(row["kind"]),
"content": row["content"],
"sig": row["sig"],
"tags": tags,
"deleted": bool(row["deleted"]),
"source_relay": row["source_relay"],
}
return None
def status_link_from_event(ev: dict[str, Any]) -> str | None:
"""First ``r`` tag with ``http(s)`` URL on a status event, if any."""
tags_raw = ev.get("tags")
tags = cast(list[list[str]], tags_raw) if isinstance(tags_raw, list) else []
return _first_link_tag(tags)

2
src/imwald/core/nostr_engine.py

@ -33,7 +33,7 @@ from imwald.core.relay_policy import ( @@ -33,7 +33,7 @@ from imwald.core.relay_policy import (
log = logging.getLogger(__name__)
# Per-author backfill: profile + lists + NIP-30 inventory (Jumble-style).
AUTHOR_METADATA_KINDS: tuple[int, ...] = (0, 10015, 30000, 10030, 30030)
AUTHOR_METADATA_KINDS: tuple[int, ...] = (0, 10015, 30000, 10030, 30030, 30315)
_AUTHOR_META_SUB_ID = "imwald-ameta"

177
src/imwald/ui/feed_page.py

@ -8,7 +8,7 @@ import re @@ -8,7 +8,7 @@ import re
from collections.abc import Sequence
from typing import Any, cast
from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal, QUrl
from PySide6.QtCore import QEvent, QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, QUrl
from PySide6.QtGui import QDesktopServices, QKeyEvent, QTextOption
from PySide6.QtWidgets import (
@ -24,16 +24,26 @@ from PySide6.QtWidgets import ( @@ -24,16 +24,26 @@ from PySide6.QtWidgets import (
QWidget,
)
from imwald.core.author_html import feed_op_author_block_html, thread_reply_author_row_html
from imwald.core.author_html import (
feed_op_author_compact_html,
format_nip05_chips_html,
thread_reply_author_row_html,
)
from imwald.core.database import Database
from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX
from imwald.core.kind0_profile import parse_kind0_profile
from imwald.core.kind0_profile import (
collect_nip05_identifiers,
kind0_json_object_prefix,
parse_kind0_profile,
)
from imwald.core.nip38_status import get_active_user_status_event
from imwald.core.md_render import markdown_html_fragment, markdown_to_plain_text
from imwald.core.nip19 import encode_npub
from imwald.core.nostr_engine import NostrEngine
from imwald.core.ranker import Ranker
from imwald.ui.media_viewer_dialog import try_open_media_url_in_app
from imwald.ui.note_text_browser import NoteTextBrowser
from imwald.ui.theme import BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED
from imwald.ui.theme import ACCENT, BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED
FEED_KINDS = (1, 20, 21, 30023, 9802, 11)
@ -46,6 +56,25 @@ def _nip30_tags(ev_row: dict[str, Any]) -> list[list[str]] | None: @@ -46,6 +56,25 @@ def _nip30_tags(ev_row: dict[str, Any]) -> list[list[str]] | None:
return cast(list[list[str]], t) if isinstance(t, list) else None
class _Nip05VerifySignals(QObject):
finished = Signal(int, object)
class _Nip05VerifyRunnable(QRunnable):
def __init__(self, pk: str, cands: list[str], gen: int, out: _Nip05VerifySignals) -> None:
super().__init__()
self._pk = pk
self._cands = cands
self._gen = gen
self._out = out
def run(self) -> None:
from imwald.core.nip05 import verify_nip05
pairs: list[tuple[str, bool]] = [(c, verify_nip05(self._pk, c, timeout=9.0)) for c in self._cands]
self._out.finished.emit(self._gen, pairs)
def _set_plain_height_to_content(te: QPlainTextEdit) -> None:
doc = te.document()
lay = doc.documentLayout()
@ -158,6 +187,12 @@ class FeedPage(QWidget): @@ -158,6 +187,12 @@ class FeedPage(QWidget):
self._rendered_op_id: str | None = None
self._rendered_reply_sig: tuple[str, ...] | None = None
self._thread_card_by_eid: dict[str, QFrame] = {}
self._op_ev_snapshot: dict[str, Any] | None = None
self._op_nip05_gen = 0
self._nip05_sigs = _Nip05VerifySignals(self)
self._nip05_sigs.finished.connect(self._on_nip05_verify_done)
self._nip05_pool = QThreadPool(self)
self._nip05_pool.setMaxThreadCount(2)
self._engagement = QFrame()
self._engagement.setObjectName("EngagementBar")
@ -264,6 +299,8 @@ class FeedPage(QWidget): @@ -264,6 +299,8 @@ class FeedPage(QWidget):
return
s = url.toString()
if s.startswith("https://") or s.startswith("http://"):
if try_open_media_url_in_app(self.window(), url):
return
QDesktopServices.openUrl(url)
def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802
@ -361,6 +398,7 @@ class FeedPage(QWidget): @@ -361,6 +398,7 @@ class FeedPage(QWidget):
def show_event(self, event_id: str) -> None:
ev = self._db.get_event(event_id)
if not ev:
self._op_ev_snapshot = None
self._op.setPlainText(f"(not in local DB yet) {event_id}")
return
self._queue = [cast(dict[str, Any], ev)]
@ -369,6 +407,77 @@ class FeedPage(QWidget): @@ -369,6 +407,77 @@ class FeedPage(QWidget):
if not ev.get("deleted"):
self._db.mark_feed_viewed(self._feed_viewer_key(), ev["id"])
def _build_op_html(self, ev: dict[str, Any], nip05_verified: list[tuple[str, bool]] | None) -> str:
"""Full OP ``QTextBrowser`` document: compact author row + note body."""
pk = str(ev["pubkey"])
prof_row = self._db.get_latest_kind0_profile(pk)
k0_ev = self._db.get_latest_kind0_event(pk)
content = (k0_ev.get("content") if k0_ev else None) or (prof_row["content"] if prof_row else "") or "{}"
tags_l: list[list[str]] = k0_ev["tags"] if k0_ev else []
raw_prof = prof_row["content"] if prof_row else content
parsed = parse_kind0_profile(kind0_json_object_prefix(raw_prof))
npub = encode_npub(pk)
candidates = collect_nip05_identifiers(content, tags_l)
if nip05_verified is None:
states: list[tuple[str, bool | None]] = [(c, None) for c in candidates]
else:
vmap = {a.strip().lower(): b for a, b in nip05_verified}
states = [(c, vmap.get(c.strip().lower(), False)) for c in candidates]
chips = format_nip05_chips_html(
states, muted=TEXT_MUTED, dim=TEXT_DIM, ok=ACCENT, bad="#b86a6a"
)
st_ev = get_active_user_status_event(self._db, pk)
st_html = ""
if st_ev:
st_html = markdown_html_fragment(
st_ev.get("content") or "",
db=self._db,
nip30_tags=_nip30_tags(st_ev),
nip30_author_pubkey=pk,
)
author_block = feed_op_author_compact_html(
parsed,
npub,
pk,
status_inner_html=st_html,
nip05_chips_html=chips,
muted=TEXT_MUTED,
border=BORDER,
)
tr = ""
sr = ev.get("source_relay") or ""
if sr and "nostrarchives.com" in sr:
tr = f"<div style='color:{TEXT_DIM};font-size:15px;margin:6px 0'><i>Trending slice (nostrarchives)</i></div>"
eid = html.escape(str(ev["id"]))
md_body = markdown_html_fragment(
ev.get("content") or "",
db=self._db,
nip30_tags=_nip30_tags(ev),
nip30_author_pubkey=pk,
)
return (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body>"
f"{author_block}"
f"<div style='color:{TEXT_DIM};font-size:15px;margin-bottom:8px'>Kind {int(ev['kind'])} · {int(ev['created_at'])}</div>"
f"{tr}"
f"<div class=\"md\">{md_body}</div>"
f"<p style='color:{TEXT_DIM};font-size:14px;margin-top:14px'>{eid}</p>"
"</body></html>"
)
def _on_nip05_verify_done(self, gen: int, pairs_obj: object) -> None:
if gen != self._op_nip05_gen:
return
ev = self._op_ev_snapshot
op_id = self._rendered_op_id
if not ev or not op_id or str(ev.get("id")) != op_id:
return
if not isinstance(pairs_obj, list):
return
pairs = cast(list[tuple[str, bool]], pairs_obj)
self._op.setHtml(self._build_op_html(ev, pairs))
def _clear_thread_rows(self) -> None:
self._thread_card_by_eid.clear()
while self._thread_layout.count():
@ -385,6 +494,7 @@ class FeedPage(QWidget): @@ -385,6 +494,7 @@ class FeedPage(QWidget):
if not self._queue:
self._rendered_op_id = None
self._rendered_reply_sig = None
self._op_ev_snapshot = None
self._op.setPlainText("No events in local database yet — wait for relay sync.")
self._clear_thread_rows()
self._why.setText("")
@ -394,6 +504,7 @@ class FeedPage(QWidget): @@ -394,6 +504,7 @@ class FeedPage(QWidget):
if ev.get("deleted"):
self._rendered_op_id = None
self._rendered_reply_sig = None
self._op_ev_snapshot = None
raw = html.escape(ev.get("content") or "")
self._op.setHtml(
f"<body style=\"color:{TEXT};background:transparent\">"
@ -431,54 +542,20 @@ class FeedPage(QWidget): @@ -431,54 +542,20 @@ class FeedPage(QWidget):
self._rendered_op_id = root_id
self._rendered_reply_sig = reply_sig
pk = op_pk
prof_row = self._db.get_latest_kind0_profile(pk)
parsed = parse_kind0_profile(prof_row["content"] if prof_row else "")
npub = encode_npub(pk)
pk_short = pk[:12] + ""
nip05 = html.escape((parsed.get("nip05") or "").strip()) if parsed.get("nip05") else ""
about = html.escape((parsed.get("about") or "")[:280]) if parsed.get("about") else ""
nip_line = (
f"<div style='color:{TEXT_MUTED};font-size:15px;margin-top:4px'>{nip05}</div>" if nip05 else ""
)
about_line = f"<div style='color:{TEXT_DIM};font-size:15px;margin-top:6px'>{about}</div>" if about else ""
author_block = feed_op_author_block_html(
parsed,
npub,
pk_short,
nip_line,
about_line,
pubkey_hex=pk,
text=TEXT,
muted=TEXT_MUTED,
dim=TEXT_DIM,
border=BORDER,
)
tr = ""
sr = ev.get("source_relay") or ""
if sr and "nostrarchives.com" in sr:
tr = f"<div style='color:{TEXT_DIM};font-size:15px;margin:6px 0'><i>Trending slice (nostrarchives)</i></div>"
eid = html.escape(ev["id"])
md_body = markdown_html_fragment(
ev.get("content") or "",
db=self._db,
nip30_tags=_nip30_tags(ev),
nip30_author_pubkey=pk,
)
body = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body>"
f"{author_block}"
f"<div style='color:{TEXT_DIM};font-size:15px;margin-bottom:8px'>Kind {int(ev['kind'])} · {int(ev['created_at'])}</div>"
f"{tr}"
f"<div class=\"md\">{md_body}</div>"
f"<p style='color:{TEXT_DIM};font-size:14px;margin-top:14px'>{eid}</p>"
"</body></html>"
)
self._op_ev_snapshot = ev
body = self._build_op_html(ev, None)
self._op.setHtml(body)
pk = op_pk
prof_row2 = self._db.get_latest_kind0_profile(pk)
k0_ev2 = self._db.get_latest_kind0_event(pk)
content2 = (k0_ev2.get("content") if k0_ev2 else None) or (prof_row2["content"] if prof_row2 else "") or "{}"
tags_l2: list[list[str]] = k0_ev2["tags"] if k0_ev2 else []
candidates = collect_nip05_identifiers(content2, tags_l2)
if candidates:
self._op_nip05_gen += 1
self._nip05_pool.start(_Nip05VerifyRunnable(pk, candidates, self._op_nip05_gen, self._nip05_sigs))
self._thread_scroll.setUpdatesEnabled(False)
try:
self._clear_thread_rows()

47
src/imwald/ui/main_window.py

@ -301,8 +301,8 @@ class MainWindow(QMainWindow): @@ -301,8 +301,8 @@ class MainWindow(QMainWindow):
def _flush_ingest_ui_refresh(self) -> None:
if self._stack.currentIndex() == 0:
cur = self._browser_tabs.currentWidget()
if cur is self._feed:
self._feed.refresh_tail()
if isinstance(cur, FeedPage):
cur.refresh_tail()
elif isinstance(cur, ProfilePage):
cur.refresh()
self._notif.refresh_all()
@ -432,7 +432,7 @@ class MainWindow(QMainWindow): @@ -432,7 +432,7 @@ class MainWindow(QMainWindow):
existing.refresh()
return
page = ProfilePage(self._db, self._engine, pk, self._browser_tabs)
page.open_note.connect(self._open_event)
page.open_note_new_tab.connect(self._open_event_in_new_tab)
page.open_profile.connect(self._open_profile_tab)
self._profile_tabs_by_pubkey[pk] = page
self._browser_tabs.addTab(page, page.tab_title())
@ -448,6 +448,7 @@ class MainWindow(QMainWindow): @@ -448,6 +448,7 @@ class MainWindow(QMainWindow):
if v is w:
del self._profile_tabs_by_pubkey[k]
break
if w is not None:
w.deleteLater()
def _wire_pages(self) -> None:
@ -465,6 +466,46 @@ class MainWindow(QMainWindow): @@ -465,6 +466,46 @@ class MainWindow(QMainWindow):
self._go_stack_page(0)
self._feed.show_event(event_id)
def _open_event_in_new_tab(self, event_id: str) -> None:
eid = event_id.strip().lower()
if len(eid) != 64 or any(c not in "0123456789abcdef" for c in eid):
return
self._go_stack_page(0)
fp = FeedPage(self._db, self._engine)
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)
fp.set_context(pk, following, list300)
fp.profile_requested.connect(self._open_profile_tab)
fp.show_event(eid)
self._browser_tabs.addTab(fp, self._event_view_tab_title(eid))
self._browser_tabs.setCurrentWidget(fp)
def _event_view_tab_title(self, event_id: str) -> str:
ev = self._db.get_event(event_id)
if not ev:
return f"Note {event_id[:10]}"
nip30_tags = ev["tags"]
pk_ev = str(ev.get("pubkey") or "").strip().lower() or None
summ = markdown_plain_summary(
ev.get("content") or "",
max_len=40,
db=self._db,
nip30_tags=nip30_tags,
nip30_author_pubkey=pk_ev,
)
if summ.strip():
t = summ.strip().replace("\n", " ")
return (t[:30] + "") if len(t) > 30 else t
try:
k = int(ev.get("kind", 0))
except (TypeError, ValueError):
k = 0
return f"k{k} · {event_id[:10]}"
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:

376
src/imwald/ui/media_viewer_dialog.py

@ -0,0 +1,376 @@ @@ -0,0 +1,376 @@
"""In-app viewer for remote images, audio, and video (HTTP/S) opened from note links."""
from __future__ import annotations
import urllib.request
from typing import Final, Literal
from PySide6.QtCore import QByteArray, QObject, QRunnable, Qt, QThreadPool, QUrl, Signal
from PySide6.QtGui import (
QClipboard,
QCloseEvent,
QGuiApplication,
QImage,
QKeySequence,
QPixmap,
QResizeEvent,
QShortcut,
QWheelEvent,
)
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
from PySide6.QtMultimediaWidgets import QVideoWidget
from PySide6.QtSvgWidgets import QSvgWidget
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSlider,
QStyle,
QVBoxLayout,
QWidget,
)
from imwald.ui.theme import ACCENT, BG_CARD, BG_FIELD, BG_WINDOW, BORDER, TEXT, TEXT_DIM, TEXT_MUTED
_MAX_BYTES: Final = 8 * 1024 * 1024
_USER_AGENT: Final = "imwald/0.1 (PySide6; +https://github.com/nostr-protocol/nostr)"
_IMAGE_EXT: Final = frozenset(
{".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp", ".tif", ".tiff", ".svg"}
)
_AUDIO_EXT: Final = frozenset({".mp3", ".ogg", ".opus", ".wav", ".flac", ".m4a", ".aac", ".oga"})
_VIDEO_EXT: Final = frozenset({".mp4", ".webm", ".mov", ".mkv", ".ogv", ".m4v", ".avi", ".wmv"})
def classify_media_url(url: QUrl) -> Literal["image", "audio", "video"] | None:
"""Best-effort media kind from URL path (query stripped). Unknown → ``None`` (open externally)."""
if url.scheme() not in ("http", "https"):
return None
path = url.path().lower()
if not path:
return None
dot = path.rfind(".")
ext = path[dot:] if dot >= 0 else ""
if ext in _IMAGE_EXT:
return "image"
if ext in _AUDIO_EXT:
return "audio"
if ext in _VIDEO_EXT:
return "video"
return None
class _FetchSignals(QObject):
ok = Signal(bytes)
fail = Signal(str)
class _FetchRunnable(QRunnable):
def __init__(self, url: str, sigs: _FetchSignals) -> None:
super().__init__()
self._url = url
self._sigs = sigs
def run(self) -> None:
try:
req = urllib.request.Request(self._url, headers={"User-Agent": _USER_AGENT}, method="GET")
with urllib.request.urlopen(req, timeout=45) as resp: # noqa: S310
data = resp.read(_MAX_BYTES + 1)
if len(data) > _MAX_BYTES:
self._sigs.fail.emit("File is larger than the in-app viewer limit (8 MiB).")
return
self._sigs.ok.emit(data)
except OSError as e:
self._sigs.fail.emit(str(e) or "Download failed.")
class _ImageCanvas(QWidget):
"""Scrollable image with Ctrl+wheel zoom (keeps a crisp source pixmap)."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._src = QPixmap()
self._zoom = 1.0
self._label = QLabel()
self._label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
self._scroll = QScrollArea(self)
self._scroll.setWidget(self._label)
self._scroll.setWidgetResizable(True)
self._scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._scroll.setFrameShape(QScrollArea.Shape.NoFrame)
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self._scroll)
def set_pixmap(self, pm: QPixmap) -> None:
self._src = pm
self._zoom = 1.0
self._apply()
def wheelEvent(self, event: QWheelEvent) -> None:
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
delta = 1.0 + (0.12 if event.angleDelta().y() > 0 else -0.12)
self._zoom = max(0.25, min(6.0, self._zoom * delta))
self._apply()
event.accept()
return
super().wheelEvent(event)
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
self._apply()
def _apply(self) -> None:
if self._src.isNull():
self._label.clear()
return
vw = max(self._scroll.viewport().width() - 8, 120)
vh = max(self._scroll.viewport().height() - 8, 120)
tw = max(int(vw * self._zoom), 1)
th = max(int(vh * self._zoom), 1)
fitted = self._src.scaled(
tw,
th,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self._label.setPixmap(fitted)
class MediaViewerDialog(QDialog):
"""
Non-modal dark viewer: raster/SVG images (async fetch + Ctrl-zoom), or Qt Multimedia for A/V.
Use :func:`try_open_media_url_in_app` from link handlers so normal web links still open externally.
"""
def __init__(
self,
parent: QWidget | None,
url: QUrl,
kind: Literal["image", "audio", "video"],
) -> None:
super().__init__(parent)
self.setWindowTitle(self._short_title(url))
self.setModal(False)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.resize(920, 640)
self.setStyleSheet(
f"QDialog {{ background-color: {BG_WINDOW}; color: {TEXT}; }}"
f"QLabel {{ color: {TEXT}; }}"
f"QPushButton {{ background-color: {BG_CARD}; color: {TEXT}; border: 1px solid {BORDER}; "
f"padding: 6px 14px; border-radius: 8px; }}"
f"QPushButton:hover {{ border-color: {ACCENT}; color: {ACCENT}; }}"
f"QSlider::groove:horizontal {{ height: 6px; background: {BG_FIELD}; border-radius: 3px; }}"
f"QSlider::handle:horizontal {{ width: 14px; margin: -5px 0; background: {ACCENT}; border-radius: 7px; }}"
)
self._url = url
self._kind = kind
self._pool = QThreadPool(self)
self._pool.setMaxThreadCount(1)
self._player: QMediaPlayer | None = None
self._audio_out: QAudioOutput | None = None
root = QVBoxLayout(self)
root.setContentsMargins(12, 12, 12, 12)
root.setSpacing(10)
self._subtitle = QLabel(url.toString())
self._subtitle.setWordWrap(True)
self._subtitle.setStyleSheet(f"color:{TEXT_MUTED};font-size:13px")
root.addWidget(self._subtitle)
self._image_wait: QLabel | None = None
self._stack_host = QWidget()
self._stack_layout = QVBoxLayout(self._stack_host)
self._stack_layout.setContentsMargins(0, 0, 0, 0)
root.addWidget(self._stack_host, stretch=1)
btn_row = QHBoxLayout()
copy_b = QPushButton("Copy link")
copy_b.clicked.connect(self._copy_url)
ext_b = QPushButton("Open in browser")
ext_b.clicked.connect(self._open_external)
btn_row.addWidget(copy_b)
btn_row.addWidget(ext_b)
btn_row.addStretch()
close_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
close_box.rejected.connect(self.close)
close_box.accepted.connect(self.close)
btn_row.addWidget(close_box)
root.addLayout(btn_row)
esc = QShortcut(QKeySequence(Qt.Key.Key_Escape), self)
esc.activated.connect(self.close)
if kind == "image":
self._setup_image(url)
else:
self._setup_av(url, kind)
@staticmethod
def _short_title(url: QUrl) -> str:
s = url.toString()
return s if len(s) <= 56 else s[:26] + "" + s[-24:]
def _copy_url(self) -> None:
QGuiApplication.clipboard().setText(self._url.toString(), QClipboard.Mode.Clipboard)
def _open_external(self) -> None:
from PySide6.QtGui import QDesktopServices
QDesktopServices.openUrl(self._url)
def _setup_image(self, url: QUrl) -> None:
self._image_wait = QLabel("Loading…")
self._image_wait.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._image_wait.setStyleSheet(f"color:{TEXT_DIM};font-size:18px;padding:40px")
self._stack_layout.addWidget(self._image_wait)
sigs = _FetchSignals(self)
sigs.ok.connect(self._on_image_bytes)
sigs.fail.connect(self._on_image_fail)
self._pool.start(_FetchRunnable(url.toString(), sigs))
def _on_image_bytes(self, data: bytes) -> None:
w = self._image_wait
if w is not None:
self._stack_layout.removeWidget(w)
w.deleteLater()
self._image_wait = None
u = self._url.toString().lower()
if u.endswith(".svg") or data.lstrip().startswith((b"<svg", b"<?xml")):
svg = QSvgWidget()
svg.load(QByteArray(data))
svg.setMinimumHeight(320)
self._stack_layout.addWidget(svg, stretch=1)
return
img = QImage.fromData(data)
if img.isNull():
self._stack_layout.addWidget(QLabel("Could not decode this image."), stretch=1)
return
pm = QPixmap.fromImage(img)
canvas = _ImageCanvas()
canvas.set_pixmap(pm)
self._stack_layout.addWidget(canvas, stretch=1)
def _on_image_fail(self, msg: str) -> None:
w = self._image_wait
if w is None:
return
w.setText(f"Could not load image.\n{msg}")
w.setStyleSheet(f"color:{TEXT_DIM};font-size:14px;padding:24px")
def _setup_av(self, url: QUrl, kind: Literal["audio", "video"]) -> None:
self._audio_out = QAudioOutput(self)
self._player = QMediaPlayer(self)
self._player.setAudioOutput(self._audio_out)
vw: QVideoWidget | None = None
if kind == "video":
vw = QVideoWidget()
vw.setMinimumSize(480, 270)
self._player.setVideoOutput(vw)
self._stack_layout.addWidget(vw, stretch=1)
else:
art = QLabel("")
art.setAlignment(Qt.AlignmentFlag.AlignCenter)
art.setStyleSheet(f"font-size:96px;color:{TEXT_DIM};padding:48px")
self._stack_layout.addWidget(art, stretch=1)
ctrl = QHBoxLayout()
self._play_btn = QPushButton()
self._play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
self._play_btn.clicked.connect(self._toggle_play)
ctrl.addWidget(self._play_btn)
self._pos_slider = QSlider(Qt.Orientation.Horizontal)
self._pos_slider.setRange(0, 0)
self._pos_slider.sliderMoved.connect(self._seek)
ctrl.addWidget(self._pos_slider, stretch=1)
self._time_lbl = QLabel("0:00 / 0:00")
self._time_lbl.setStyleSheet(f"color:{TEXT_MUTED}")
ctrl.addWidget(self._time_lbl)
wrap = QWidget()
wrap.setLayout(ctrl)
self._stack_layout.addWidget(wrap)
self._player.setSource(url)
self._player.playbackStateChanged.connect(self._sync_play_icon)
self._player.positionChanged.connect(self._on_position)
self._player.durationChanged.connect(self._on_duration)
self._player.errorOccurred.connect(self._on_player_error) # type: ignore[arg-type]
self._player.play()
def _toggle_play(self) -> None:
if self._player is None:
return
if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
self._player.pause()
else:
self._player.play()
def _sync_play_icon(self) -> None:
if self._player is None:
return
if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
self._play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause))
else:
self._play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
def _seek(self, pos: int) -> None:
if self._player is not None:
self._player.setPosition(pos)
def _on_position(self, pos: int) -> None:
if self._player is None:
return
d = self._player.duration()
if d > 0 and self._pos_slider.maximum() != d:
self._pos_slider.setMaximum(d)
self._pos_slider.blockSignals(True)
self._pos_slider.setValue(pos)
self._pos_slider.blockSignals(False)
self._time_lbl.setText(f"{self._fmt_ms(pos)} / {self._fmt_ms(max(d, 0))}")
def _on_duration(self, d: int) -> None:
self._pos_slider.setMaximum(max(d, 0))
@staticmethod
def _fmt_ms(ms: int) -> str:
s = max(ms, 0) // 1000
m, s = divmod(s, 60)
if m >= 60:
h, m = divmod(m, 60)
return f"{h}:{m:02d}:{s:02d}"
return f"{m}:{s:02d}"
def _on_player_error(self, *_args: object) -> None:
if self._player is None:
return
err = self._player.errorString()
QMessageBox.warning(self, "Playback", err or "Media playback failed.")
def closeEvent(self, event: QCloseEvent) -> None:
if self._player is not None:
self._player.stop()
super().closeEvent(event)
def try_open_media_url_in_app(parent: QWidget | None, url: QUrl) -> bool:
"""
If ``url`` looks like a direct media file we support, open :class:`MediaViewerDialog` and return ``True``.
Otherwise return ``False`` so callers can fall back to :class:`QDesktopServices`.
"""
kind = classify_media_url(url)
if kind is None:
return False
dlg = MediaViewerDialog(parent, url, kind)
dlg.show()
dlg.raise_()
dlg.activateWindow()
return True

526
src/imwald/ui/profile_page.py

@ -4,27 +4,279 @@ from __future__ import annotations @@ -4,27 +4,279 @@ from __future__ import annotations
import html
import json
from datetime import datetime, timezone
from typing import cast
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, Signal, QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QFrame, QScrollArea, QTabWidget, QVBoxLayout, QWidget
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal, QUrl
from PySide6.QtGui import QDesktopServices, QMouseEvent, QPainter, QPainterPath, QPixmap, QResizeEvent
from PySide6.QtWidgets import QFrame, QLabel, QSizePolicy, QSplitter, QTabWidget, QVBoxLayout, QWidget
from imwald.core.author_html import avatar_img_or_placeholder
from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX
from imwald.core.database import Database
from imwald.core.forest_avatar import build_forest_avatar_png, build_forest_banner_png
from imwald.core.kind0_profile import display_name_from_profile_or_hex, parse_kind0_profile
from imwald.core.profile_lnurl import build_merged_lnurl_pay_section, collect_unique_lnurlp_urls
from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary
from imwald.core.nip19 import encode_npub
from imwald.core.nostr_engine import NostrEngine
from imwald.core.relay_list import parse_kind10002_tags
from imwald.ui.media_viewer_dialog import try_open_media_url_in_app
from imwald.ui.note_text_browser import NoteTextBrowser
from imwald.ui.theme import BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED
from imwald.ui.theme import ACCENT_SOFT, BG_CARD, BG_CODE, BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED
# Notes to list under “Recent in local DB” (feed-shaped kinds).
_PROFILE_NOTE_KINDS: tuple[int, ...] = (1, 6, 20, 21, 30023, 9802, 11)
PROFILE_HERO_BANNER_H = 240
PROFILE_HERO_AVATAR = 136
PROFILE_HERO_OVERLAP = 0.48 # fraction of avatar height drawn above the banner bottom edge
def _cover_pixmap(src: QPixmap, tw: int, th: int) -> QPixmap:
if src.isNull() or tw < 2 or th < 2:
return QPixmap()
sc = src.scaled(
tw,
th,
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation,
)
sw, sh = sc.width(), sc.height()
x = max(0, (sw - tw) // 2)
y = max(0, (sh - th) // 2)
return sc.copy(x, y, tw, th)
def _square_center_crop(pm: QPixmap) -> QPixmap:
if pm.isNull():
return QPixmap()
s = min(pm.width(), pm.height())
if s < 2:
return pm
x = (pm.width() - s) // 2
y = (pm.height() - s) // 2
return pm.copy(x, y, s, s)
def _circle_pixmap(src: QPixmap, diameter: int) -> QPixmap:
if src.isNull() or diameter < 4:
return QPixmap()
scaled = src.scaled(
diameter,
diameter,
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation,
)
sw, sh = scaled.width(), scaled.height()
x = max(0, (sw - diameter) // 2)
y = max(0, (sh - diameter) // 2)
inner = scaled.copy(x, y, diameter, diameter)
out = QPixmap(diameter, diameter)
out.fill(Qt.GlobalColor.transparent)
painter = QPainter(out)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
path = QPainterPath()
path.addEllipse(0, 0, diameter, diameter)
painter.setClipPath(path)
painter.drawPixmap(0, 0, inner)
painter.end()
return out
class _BannerLabel(QLabel):
"""Opens the remote banner URL in the system browser when set."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._open_url: str | None = None
self.setCursor(Qt.CursorShape.PointingHandCursor)
def set_open_url(self, url: str | None) -> None:
self._open_url = url.strip() if url else None
self.setCursor(
Qt.CursorShape.PointingHandCursor if self._open_url else Qt.CursorShape.ArrowCursor
)
def mousePressEvent(self, event: QMouseEvent) -> None:
if self._open_url and event.button() == Qt.MouseButton.LeftButton:
q = QUrl(self._open_url)
if not try_open_media_url_in_app(self.window(), q):
QDesktopServices.openUrl(q)
super().mousePressEvent(event)
class ProfileHeroFrame(QFrame):
"""Full-width cover + circular avatar overlapping the lower banner (social-style header)."""
BANNER_H = PROFILE_HERO_BANNER_H
AVATAR = PROFILE_HERO_AVATAR
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("ProfileHeroFrame")
self._banner_src = QPixmap()
self._disp = QLabel(self)
self._npub = QLabel(self)
self._pk = QLabel(self)
self._nip05 = QLabel(self)
self._meta = QLabel(self)
self._banner_lbl = _BannerLabel(self)
self._banner_lbl.setScaledContents(False)
self._avatar_lbl = QLabel(self)
self._avatar_lbl.setFixedSize(self.AVATAR, self.AVATAR)
self._avatar_lbl.setScaledContents(False)
self._avatar_lbl.setStyleSheet(
f"QLabel {{ border: 3px solid {BORDER}; border-radius: {self.AVATAR // 2}px; "
f"background-color: {BG_CODE}; }}"
)
for lb in (self._disp, self._npub, self._pk, self._nip05, self._meta):
lb.setWordWrap(True)
lb.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._disp.setStyleSheet(f"color:{TEXT}; font-size:26px; font-weight:700;")
self._npub.setStyleSheet(f"color:{TEXT_MUTED}; font-size:14px;")
self._pk.setStyleSheet(f"color:{TEXT_DIM}; font-size:12px;")
self._nip05.setStyleSheet(f"color:{TEXT_MUTED}; font-size:15px;")
self._meta.setStyleSheet(f"color:{TEXT_DIM}; font-size:12px;")
self._relayout()
def set_banner_source(self, pm: QPixmap) -> None:
self._banner_src = pm if not pm.isNull() else QPixmap()
self._relayout()
def set_banner_open_url(self, url: str | None) -> None:
self._banner_lbl.set_open_url(url)
def set_avatar_pixmap(self, pm: QPixmap) -> None:
if pm.isNull():
self._avatar_lbl.clear()
else:
self._avatar_lbl.setPixmap(pm)
def set_identity(
self,
display: str,
npub: str,
pubkey_hex: str,
nip05: str | None,
k0_meta: str | None,
) -> None:
self._disp.setText(display)
self._npub.setText(npub)
self._pk.setText(pubkey_hex)
self._nip05.setText(nip05 or "")
self._nip05.setVisible(bool(nip05 and nip05.strip()))
self._meta.setText(k0_meta or "")
self._meta.setVisible(bool(k0_meta and k0_meta.strip()))
self._relayout()
def minimumSizeHint(self) -> QSize: # noqa: N802
overlap = int(self.AVATAR * PROFILE_HERO_OVERLAP)
h = self.BANNER_H + self.AVATAR - overlap + 18
return QSize(280, h)
def resizeEvent(self, event: QResizeEvent) -> None:
self._relayout()
super().resizeEvent(event)
def _relayout(self) -> None:
w = max(self.width(), 80)
bh = self.BANNER_H
av = self.AVATAR
cover = _cover_pixmap(self._banner_src, w, bh)
self._banner_lbl.setPixmap(cover)
self._banner_lbl.setGeometry(0, 0, w, bh)
ax = 22
ay = bh - int(av * PROFILE_HERO_OVERLAP)
self._avatar_lbl.setGeometry(ax, ay, av, av)
tx = ax + av + 16
tw = w - tx - 16
y0 = ay + 4
self._disp.setGeometry(tx, y0, tw, 34)
self._npub.setGeometry(tx, y0 + 36, tw, 44)
self._pk.setGeometry(tx, y0 + 82, tw, 36)
y1 = y0 + 118
if self._nip05.isVisible():
self._nip05.setGeometry(tx, y1, tw, 28)
y1 += 30
if self._meta.isVisible():
self._meta.setGeometry(tx, y1, tw, 40)
overlap = int(av * PROFILE_HERO_OVERLAP)
total_h = bh + av - overlap + 14
self.setFixedHeight(max(total_h, self.minimumSizeHint().height()))
self._avatar_lbl.raise_()
class _CoverImageSignals(QObject):
banner_ready = Signal(object, int)
avatar_ready = Signal(object, int)
class _ProfileHttpImageRunnable(QRunnable):
def __init__(self, url: str, gen: int, mode: str, out: _CoverImageSignals) -> None:
super().__init__()
self._url = url
self._gen = gen
self._mode = mode
self._out = out
def run(self) -> None:
empty = QPixmap()
pm = QPixmap()
try:
from urllib.request import Request, urlopen
req = Request(self._url, headers={"User-Agent": "imwald/1"}, method="GET")
with urlopen(req, timeout=16) as resp: # noqa: S310
data = resp.read(6 * 1024 * 1024 + 1)
if len(data) > 6 * 1024 * 1024 or not pm.loadFromData(data):
self._emit_fail()
return
except OSError:
self._emit_fail()
return
if self._mode == "banner":
self._out.banner_ready.emit(pm, self._gen)
elif self._mode == "avatar":
sq = _square_center_crop(pm)
if sq.isNull():
self._out.avatar_ready.emit(empty, self._gen)
return
scaled = sq.scaled(
400,
400,
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
circ = _circle_pixmap(scaled, ProfileHeroFrame.AVATAR)
self._out.avatar_ready.emit(circ, self._gen)
else:
self._emit_fail()
def _emit_fail(self) -> None:
if self._mode == "banner":
self._out.banner_ready.emit(QPixmap(), self._gen)
else:
self._out.avatar_ready.emit(QPixmap(), self._gen)
def _sec_title(label: str) -> str:
return (
f'<div style="font-size:11px;font-weight:700;color:{TEXT_MUTED};text-transform:uppercase;'
f'letter-spacing:0.07em;margin:0 0 12px 0">{html.escape(label)}</div>'
)
def _fmt_event_time(created_at: int | float | str | None) -> str:
if created_at is None:
return ""
try:
ts = int(created_at)
except (TypeError, ValueError):
return str(created_at)
try:
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d · %H:%M UTC")
except (OSError, OverflowError, ValueError):
return str(ts)
class _ProfileLnurlSignals(QObject):
finished = Signal(str, int)
@ -45,7 +297,7 @@ class _ProfileLnurlRunnable(QRunnable): @@ -45,7 +297,7 @@ class _ProfileLnurlRunnable(QRunnable):
class ProfilePage(QWidget):
"""One pubkey: metadata, NIP-65 relays, follows (kind 3), emoji inventory, raw JSON, recent notes."""
open_note = Signal(str)
open_note_new_tab = Signal(str)
open_profile = Signal(str)
def __init__(
@ -60,25 +312,50 @@ class ProfilePage(QWidget): @@ -60,25 +312,50 @@ class ProfilePage(QWidget):
self._db = db
self._engine = engine
self._pubkey = pubkey_hex.strip().lower()
scroll = QScrollArea(self)
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._body = NoteTextBrowser()
self._body.setObjectName("ProfileBody")
self._body.setOpenLinks(False)
self._body.setOpenExternalLinks(False)
self._body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._body.anchorClicked.connect(self._on_anchor)
scroll.setWidget(self._body)
self._hero = ProfileHeroFrame(self)
self._left_body = NoteTextBrowser()
self._left_body.setObjectName("ProfileBodyLeft")
self._left_body.setOpenLinks(False)
self._left_body.setOpenExternalLinks(False)
self._left_body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._left_body.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._left_body.anchorClicked.connect(self._dispatch_profile_anchor)
self._feed_body = NoteTextBrowser()
self._feed_body.setObjectName("ProfileBodyFeed")
self._feed_body.setOpenLinks(False)
self._feed_body.setOpenExternalLinks(False)
self._feed_body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._feed_body.anchorClicked.connect(self._dispatch_profile_anchor)
self._left_column = QWidget(self)
left_lay = QVBoxLayout(self._left_column)
left_lay.setContentsMargins(0, 0, 0, 0)
left_lay.setSpacing(0)
left_lay.addWidget(self._hero, 0)
left_lay.addWidget(self._left_body, 1)
split = QSplitter(Qt.Orientation.Horizontal, self)
split.setObjectName("ProfileSplit")
split.setChildrenCollapsible(False)
split.addWidget(self._left_column)
split.addWidget(self._feed_body)
split.setStretchFactor(0, 100)
split.setStretchFactor(1, 58)
split.setSizes([720, 400])
self._feed_body.setMinimumWidth(260)
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(scroll)
lay.addWidget(split)
self._lnurl_gen = 0
self._lnurl_sigs = _ProfileLnurlSignals(self)
self._lnurl_sigs.finished.connect(self._on_lnurl_profile_ready)
self._lnurl_pool = QThreadPool(self)
self._lnurl_pool.setMaxThreadCount(1)
self._cover_banner_gen = 0
self._cover_avatar_gen = 0
self._cov_sigs = _CoverImageSignals(self)
self._cov_sigs.banner_ready.connect(self._on_cover_banner_http)
self._cov_sigs.avatar_ready.connect(self._on_cover_avatar_http)
self._cov_pool = QThreadPool(self)
self._cov_pool.setMaxThreadCount(3)
self.refresh()
def tab_title(self) -> str:
@ -92,6 +369,18 @@ class ProfilePage(QWidget): @@ -92,6 +369,18 @@ class ProfilePage(QWidget):
return
self.refresh(from_lnurl=True, lnurl_html=html)
def _on_cover_banner_http(self, pm: object, gen: int) -> None:
if gen != self._cover_banner_gen:
return
if isinstance(pm, QPixmap) and not pm.isNull():
self._hero.set_banner_source(pm)
def _on_cover_avatar_http(self, pm: object, gen: int) -> None:
if gen != self._cover_avatar_gen:
return
if isinstance(pm, QPixmap) and not pm.isNull():
self._hero.set_avatar_pixmap(pm)
def refresh(self, *, from_lnurl: bool = False, lnurl_html: str | None = None) -> None:
if not from_lnurl:
self._engine.enqueue_author_metadata(self._pubkey)
@ -129,35 +418,57 @@ class ProfilePage(QWidget): @@ -129,35 +418,57 @@ class ProfilePage(QWidget):
gen = self._lnurl_gen
self._lnurl_pool.start(_ProfileLnurlRunnable(lnurls, gen, self._lnurl_sigs))
live_lnurl = f"<p style='color:{TEXT_DIM}'><i>Fetching LNURL-pay metadata…</i></p>"
pay_block = ""
pay_card = ""
if pay_static or live_lnurl:
pay_block = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Lightning (NIP-57)</h3>"
f"<div style='margin-bottom:8px'>{pay_static}{live_lnurl}</div>"
)
pay_card = f"{_sec_title('Lightning · NIP-57')}<div>{pay_static}{live_lnurl}</div>"
disp = html.escape(display_name_from_profile_or_hex(parsed, pk))
prof_href = html.escape(f"imwald://pub/{pk}", quote=True)
av = avatar_img_or_placeholder(parsed, 72, border_hex=BORDER, profile_href=prof_href)
nip05 = html.escape((parsed.get("nip05") or "").strip()) if parsed.get("nip05") else ""
nip05_html = (
f"<div style='color:{TEXT_MUTED};font-size:16px;margin-top:6px'>{nip05}</div>" if nip05 else ""
disp_plain = display_name_from_profile_or_hex(parsed, pk)
nip05_plain = (parsed.get("nip05") or "").strip() if parsed.get("nip05") else ""
banner_raw = parsed.get("banner")
banner_https = (
str(banner_raw).strip()
if banner_raw and str(banner_raw).strip().startswith("https://")
else ""
)
picture_raw = parsed.get("picture")
picture_https = (
str(picture_raw).strip()
if picture_raw and str(picture_raw).strip().startswith("https://")
else ""
)
banner = parsed.get("banner")
banner_html = ""
if banner and str(banner).strip().startswith("https://"):
bu = html.escape(str(banner).strip(), quote=True)
bw = IMAGE_DISPLAY_MAX_WIDTH_PX
banner_html = (
f"<div style='margin-bottom:12px;border-radius:10px;overflow:hidden;"
f"max-width:{bw}px'>"
f'<a class="imwald-fullimg" href="{bu}" title="View full size" style="text-decoration:none">'
f'<img src="{bu}" alt="" style="max-width:100%;width:100%;max-height:160px;object-fit:cover" />'
f"</a></div>"
forest_seed = int(pk[:16], 16) % (2**31) if len(pk) >= 16 else 0
ph_b = QPixmap()
bh_bytes = build_forest_banner_png(
width=2000, height=ProfileHeroFrame.BANNER_H + 48, seed=forest_seed
)
if not ph_b.loadFromData(bh_bytes):
ph_b.loadFromData(
build_forest_banner_png(width=2000, height=ProfileHeroFrame.BANNER_H + 48, seed=42)
)
self._hero.set_banner_source(ph_b if not ph_b.isNull() else QPixmap())
ph_a = QPixmap()
if not ph_a.loadFromData(build_forest_avatar_png(size=384, seed=forest_seed)):
ph_a.loadFromData(build_forest_avatar_png(size=384, seed=42))
av_circ = _circle_pixmap(ph_a, ProfileHeroFrame.AVATAR)
self._hero.set_avatar_pixmap(av_circ)
self._cover_banner_gen += 1
b_gen = self._cover_banner_gen
self._cover_avatar_gen += 1
a_gen = self._cover_avatar_gen
if banner_https:
self._cov_pool.start(_ProfileHttpImageRunnable(banner_https, b_gen, "banner", self._cov_sigs))
if picture_https:
self._cov_pool.start(_ProfileHttpImageRunnable(picture_https, a_gen, "avatar", self._cov_sigs))
self._hero.set_banner_open_url(banner_https or None)
k0_meta_plain = ""
if k0_ev:
k0_meta_plain = (
f"Kind 0 id {str(k0_ev['id'])[:20]}… · profile updated {_fmt_event_time(created0)}"
)
self._hero.set_identity(disp_plain, npub, pk, nip05_plain or None, k0_meta_plain or None)
about_raw = (parsed.get("about") or "").strip()
about_md = ""
about_inner = ""
if about_raw:
frag = markdown_html_fragment(
about_raw,
@ -165,7 +476,7 @@ class ProfilePage(QWidget): @@ -165,7 +476,7 @@ class ProfilePage(QWidget):
nip30_tags=tags0 or None,
nip30_author_pubkey=pk,
)
about_md = f"<h3 style='color:{TEXT};margin:16px 0 8px'>About</h3><div class=\"md\">{frag}</div>"
about_inner = f"{_sec_title('About')}<div class=\"md\">{frag}</div>"
raw_json = ""
try:
@ -175,27 +486,27 @@ class ProfilePage(QWidget): @@ -175,27 +486,27 @@ class ProfilePage(QWidget):
except json.JSONDecodeError:
raw_json = content or ""
raw_esc = html.escape(raw_json[:12000] + ("" if len(raw_json) > 12000 else ""), quote=False)
json_block = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Kind 0 JSON (full)</h3>"
f"<pre style='color:{TEXT_DIM};font-size:14px;white-space:pre-wrap;word-break:break-all;"
f"background:rgba(0,0,0,0.25);padding:12px;border-radius:8px;border:1px solid {BORDER}'>{raw_esc}</pre>"
json_inner = (
f"{_sec_title('Kind 0 JSON')}"
f"<pre style='color:{TEXT_DIM};font-size:13px;white-space:pre-wrap;word-break:break-all;"
f"background:{BG_CODE};padding:12px;border-radius:8px;border:1px solid {BORDER};margin:0'>{raw_esc}</pre>"
)
k10002 = self._db.get_latest_kind10002_event(pk)
relay_html = (
f"<p style='color:{TEXT_DIM};font-size:15px'>"
relay_inner = (
f"<p style='color:{TEXT_DIM};font-size:15px;margin:0'>"
f"<i>No NIP-65 relay list (kind 10002) in local DB yet.</i></p>"
)
if k10002:
reads, writes = parse_kind10002_tags(k10002.get("tags") or [])
r_esc = "<br>".join(html.escape(u) for u in reads[:40])
w_esc = "<br>".join(html.escape(u) for u in writes[:40])
relay_html = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Relays (NIP-65, kind 10002)</h3>"
f"<p style='color:{TEXT_MUTED};font-size:15px'><b>Read</b></p>"
f"<div style='color:{TEXT_DIM};font-size:14px'>{r_esc or ''}</div>"
f"<p style='color:{TEXT_MUTED};font-size:15px;margin-top:10px'><b>Write</b></p>"
f"<div style='color:{TEXT_DIM};font-size:14px'>{w_esc or ''}</div>"
relay_inner = (
f"{_sec_title('Relays · NIP-65 (kind 10002)')}"
f"<p style='color:{TEXT_MUTED};font-size:14px;margin:0 0 6px 0'><b>Read</b></p>"
f"<div style='color:{TEXT_DIM};font-size:14px;line-height:1.45'>{r_esc or ''}</div>"
f"<p style='color:{TEXT_MUTED};font-size:14px;margin:12px 0 6px 0'><b>Write</b></p>"
f"<div style='color:{TEXT_DIM};font-size:14px;line-height:1.45'>{w_esc or ''}</div>"
)
follows = self._db.get_latest_kind3_contact_pubkeys(pk, limit=400)
@ -209,9 +520,9 @@ class ProfilePage(QWidget): @@ -209,9 +520,9 @@ class ProfilePage(QWidget):
f'<span style="color:{TEXT_DIM};font-size:13px"> · {html.escape(fp[:16])}…</span></div>'
)
_no_follow = f"<i style='color:{TEXT_DIM}'>No kind 3 in local DB.</i>"
follow_block = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Following (kind 3, local snapshot)</h3>"
f"<div style='font-size:14px'>{''.join(follow_lines) or _no_follow}</div>"
follow_inner = (
f"{_sec_title('Following · kind 3 (local)')}"
f"<div style='font-size:14px;line-height:1.45'>{''.join(follow_lines) or _no_follow}</div>"
)
nip30 = self._db.get_author_nip30_emoji_urls(pk)
@ -222,10 +533,7 @@ class ProfilePage(QWidget): @@ -222,10 +533,7 @@ class ProfilePage(QWidget):
f'<a href="{html.escape(url, quote=True)}" style="color:{TEXT}">{html.escape(url[:48])}…</a></div>'
)
_no_emoji = f"<i style='color:{TEXT_DIM}'>No emoji packs indexed yet.</i>"
emoji_block = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Custom emoji (NIP-30, local)</h3>"
f"<div>{''.join(em_lines) or _no_emoji}</div>"
)
emoji_inner = f"{_sec_title('Custom emoji · NIP-30')}" f"<div>{''.join(em_lines) or _no_emoji}</div>"
notes = self._db.list_events_by_pubkey(pk, kinds=_PROFILE_NOTE_KINDS, limit=40)
note_lines: list[str] = []
@ -235,62 +543,73 @@ class ProfilePage(QWidget): @@ -235,62 +543,73 @@ class ProfilePage(QWidget):
nip = cast(list[list[str]], ev["tags"]) if isinstance(ev.get("tags"), list) else None
snip = markdown_plain_summary(
ev.get("content") or "",
max_len=72,
max_len=96,
db=self._db,
nip30_tags=nip,
nip30_author_pubkey=str(ev.get("pubkey") or "") or None,
)
t_human = _fmt_event_time(ev.get("created_at"))
kind_lbl = int(ev["kind"])
esc_href = html.escape(href, quote=True)
note_lines.append(
f'<div style="margin:8px 0;padding:8px;border:1px solid {BORDER};border-radius:8px">'
f"<span style='color:{TEXT_MUTED};font-size:13px'>k{int(ev['kind'])} · {int(ev['created_at'])}</span><br>"
f'<a href="{html.escape(href, quote=True)}" style="color:{TEXT};font-weight:600;text-decoration:none">'
f"Open in feed</a>"
f"<div style='color:{TEXT_DIM};margin-top:6px;font-size:15px'>{html.escape(snip)}</div>"
"</div>"
f'<a href="{esc_href}" style="display:block;text-decoration:none;color:{TEXT};'
f"background:{BG_CARD};border:1px solid {BORDER};border-left:3px solid {ACCENT_SOFT};"
f'border-radius:11px;padding:12px 14px 12px 13px;margin-bottom:11px">'
f"<div style='color:{TEXT_MUTED};font-size:12px;font-weight:600'>"
f"Kind {kind_lbl} · {html.escape(t_human)}</div>"
f"<div style='color:{TEXT_DIM};margin-top:8px;font-size:15px;line-height:1.45'>"
f"{html.escape(snip)}</div>"
f"<div style='color:{ACCENT_SOFT};font-size:12px;margin-top:8px;font-weight:600'>"
f"Open in new tab →</div></a>"
)
_no_notes = f"<i style='color:{TEXT_DIM}'>No matching notes stored yet.</i>"
notes_block = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Recent notes (local DB)</h3>"
f"{''.join(note_lines) or _no_notes}"
_no_notes = "No matching notes stored yet."
card_style = (
f"background:{BG_CARD};border:1px solid {BORDER};border-radius:14px;"
f"padding:16px 18px;margin-bottom:14px"
)
left_parts: list[str] = []
if about_inner:
left_parts.append(f'<div style="{card_style}">{about_inner}</div>')
if pay_card:
left_parts.append(f'<div style="{card_style}">{pay_card}</div>')
left_parts.append(f'<div style="{card_style}">{relay_inner}</div>')
left_parts.append(f'<div style="{card_style}">{follow_inner}</div>')
left_parts.append(f'<div style="{card_style}">{emoji_inner}</div>')
left_parts.append(f'<div style="{card_style}">{json_inner}</div>')
k0_meta = ""
if k0_ev:
k0_meta = (
f"<p style='color:{TEXT_DIM};font-size:14px'>Kind 0 event id: <code>{html.escape(str(k0_ev['id']))}</code>"
f" · updated {created0}</p>"
)
left_doc = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body style='padding:14px 16px 28px 18px'>"
f"{''.join(left_parts)}</body></html>"
)
doc = (
n_notes = len(notes)
feed_intro = (
f'<div style="margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid {BORDER}">'
f'<div style="font-size:20px;font-weight:700;color:{TEXT};letter-spacing:-0.02em">Posts</div>'
f'<div style="color:{TEXT_DIM};font-size:14px;margin-top:6px;line-height:1.4">'
f"{n_notes} recent note{'' if n_notes == 1 else 's'} indexed on this device. "
f"Click a card to open the thread in a <b style='color:{TEXT}'>new tab</b>.</div></div>"
)
if note_lines:
feed_inner = "".join(note_lines)
else:
feed_inner = f"<p style='color:{TEXT_DIM};font-size:15px'><i>{html.escape(_no_notes)}</i></p>"
feed_doc = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body style='padding:12px 14px'>"
f"{banner_html}"
f"<div style='display:flex;align-items:flex-start;gap:14px;margin-bottom:8px'>"
f"{av}"
f"<div style='flex:1;min-width:0'>"
f"<div style='font-size:26px;font-weight:700;color:{TEXT}'>{disp}</div>"
f"<div style='color:{TEXT_MUTED};font-size:15px;margin-top:4px'>{html.escape(npub)}</div>"
f"<div style='color:{TEXT_DIM};font-size:14px;margin-top:2px'>{html.escape(pk[:24])}…</div>"
f"{nip05_html}"
f"</div></div>"
f"{k0_meta}"
f"{about_md}"
f"{pay_block}"
f"{relay_html}"
f"{follow_block}"
f"{emoji_block}"
f"{json_block}"
f"{notes_block}"
"</body></html>"
f"{FEED_DOC_CSS}</head><body style='padding:14px 14px 28px 12px'>"
f"{feed_intro}{feed_inner}</body></html>"
)
self._body.setHtml(doc)
self._left_body.setHtml(left_doc)
self._feed_body.setHtml(feed_doc)
tw = self.parentWidget()
if isinstance(tw, QTabWidget):
i = tw.indexOf(self)
if i >= 0:
tw.setTabText(i, self.tab_title())
def _on_anchor(self, url: QUrl) -> None:
def _dispatch_profile_anchor(self, url: QUrl) -> None:
s = url.toString()
if url.scheme() == "imwald" and url.host() == "pub":
tail = (url.path() or "").strip("/").lower()
@ -300,7 +619,8 @@ class ProfilePage(QWidget): @@ -300,7 +619,8 @@ class ProfilePage(QWidget):
if url.scheme() == "imwald" and url.host() == "note":
eid = (url.path() or "").strip("/")
if len(eid) == 64 and all(c in "0123456789abcdef" for c in eid.lower()):
self.open_note.emit(eid.lower())
self.open_note_new_tab.emit(eid.lower())
return
if s.startswith("https://") or s.startswith("http://"):
QDesktopServices.openUrl(url)
if not try_open_media_url_in_app(self.window(), url):
QDesktopServices.openUrl(url)

45
src/imwald/ui/theme.py

@ -113,24 +113,55 @@ QHeaderView::section {{ @@ -113,24 +113,55 @@ QHeaderView::section {{
}}
QTabWidget::pane {{
border: 1px solid {BORDER};
border-radius: 8px;
border-radius: 10px;
background-color: {BG_FIELD};
top: -1px;
padding: 2px;
}}
QTabBar {{
qproperty-drawBase: 0;
background-color: transparent;
}}
QTabBar::tab {{
background-color: {BG_CARD};
color: {TEXT_MUTED};
padding: 8px 16px;
margin-right: 2px;
color: {TEXT_DIM};
font-size: 15px;
min-height: 22px;
padding: 10px 22px 11px 20px;
margin: 8px 5px 0 0;
border: 1px solid {BORDER};
border-bottom: none;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}}
QTabBar::tab:selected {{
background-color: {BG_FIELD};
color: {ACCENT};
color: {TEXT};
font-weight: 600;
padding-bottom: 12px;
margin-bottom: -1px;
border-color: {BORDER};
border-bottom-color: {BG_FIELD};
}}
QTabBar::tab:!selected:hover {{
background-color: {BG_CODE};
color: {TEXT};
}}
QTabBar::close-button {{
subcontrol-origin: padding;
subcontrol-position: right;
width: 22px;
height: 22px;
margin: 0 2px 0 8px;
padding: 4px;
border-radius: 6px;
}}
QTabBar::close-button:hover {{
background-color: {BG_CARD};
border: 1px solid {ACCENT_SOFT};
}}
QTabBar::close-button:pressed {{
background-color: {BG_CODE};
}}
QComboBox {{
background-color: {BG_FIELD};

20
tests/test_forest_avatar.py

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
"""Forest placeholder art bytes."""
from imwald.core.forest_avatar import build_forest_avatar_png, build_forest_banner_png
def test_forest_avatar_png_magic() -> None:
b = build_forest_avatar_png()
assert b.startswith(b"\x89PNG\r\n\x1a\n")
def test_forest_banner_png_magic_and_seed_variation() -> None:
a = build_forest_banner_png(seed=1)
b = build_forest_banner_png(seed=2)
assert a.startswith(b"\x89PNG\r\n\x1a\n")
assert b.startswith(b"\x89PNG\r\n\x1a\n")
assert a != b
def test_forest_avatar_seed_changes_bytes() -> None:
assert build_forest_avatar_png(seed=1) != build_forest_avatar_png(seed=2)

16
tests/test_media_viewer.py

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
"""Media URL classification for the in-app viewer."""
from PySide6.QtCore import QUrl
from imwald.ui.media_viewer_dialog import classify_media_url
def test_classify_by_extension() -> None:
assert classify_media_url(QUrl("https://cdn/x/photo.webp")) == "image"
assert classify_media_url(QUrl("https://x.com/a/b.MP4?q=1")) == "video"
assert classify_media_url(QUrl("https://h/audio.opus")) == "audio"
def test_classify_unknown() -> None:
assert classify_media_url(QUrl("https://x.com/page")) is None
assert classify_media_url(QUrl("imwald://pub/abcd")) is None

21
tests/test_nip05_collect.py

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
"""NIP-05 identifier collection and parsing."""
from imwald.core.kind0_profile import collect_nip05_identifiers
from imwald.core.nip05 import parse_nip05_identifier
def test_parse_nip05_identifier() -> None:
assert parse_nip05_identifier("bob@Example.com") == ("bob", "example.com")
assert parse_nip05_identifier("not an id") is None
def test_collect_from_json_and_tags_and_scan() -> None:
content = '{"name":"x","nip05":"a@b.co"} extra@scan.org tail'
tags: list[list[str]] = [["nip05", "c@d.co"], ["nip-05", "e@f.co"], ["client", "x"]]
got = collect_nip05_identifiers(content, tags)
assert got == ["a@b.co", "c@d.co", "e@f.co", "extra@scan.org"]
def test_collect_dedupes() -> None:
content = '{"nip05":"Same@X.org"} same@x.org'
assert collect_nip05_identifiers(content, []) == ["Same@X.org"]
Loading…
Cancel
Save