Browse Source

bug-fixing

master
Silberengel 2 weeks ago
parent
commit
e1d2401d9e
  1. 2
      src/imwald/app.py
  2. 7
      src/imwald/core/author_html.py
  3. 2
      src/imwald/core/display_constants.py
  4. 71
      src/imwald/ui/feed_page.py
  5. 5
      src/imwald/ui/main_window.py
  6. 3
      src/imwald/ui/onboarding_wizard.py
  7. 75
      src/imwald/ui/profile_page.py
  8. 11
      tests/test_feed_views.py

2
src/imwald/app.py

@ -10,6 +10,7 @@ from PySide6.QtWidgets import QApplication
from imwald.config import db_path from imwald.config import db_path
from imwald.core.database import Database from imwald.core.database import Database
from imwald.core.display_constants import APP_DISPLAY_NAME
from imwald.core.nostr_engine import NostrEngine from imwald.core.nostr_engine import NostrEngine
from imwald.ui.asset_paths import favicon_png_path from imwald.ui.asset_paths import favicon_png_path
from imwald.ui.main_window import MainWindow from imwald.ui.main_window import MainWindow
@ -35,6 +36,7 @@ def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("imwald") app.setApplicationName("imwald")
app.setApplicationDisplayName(APP_DISPLAY_NAME)
app.setOrganizationName("imwald") app.setOrganizationName("imwald")
fav_pm = QPixmap(str(favicon_png_path())) fav_pm = QPixmap(str(favicon_png_path()))
if not fav_pm.isNull(): if not fav_pm.isNull():

7
src/imwald/core/author_html.py

@ -97,11 +97,12 @@ def format_nip05_chips_html(
if chip_bg: if chip_bg:
brd = chip_border or dim brd = chip_border or dim
wrap = ( wrap = (
"display:inline-flex;align-items:center;gap:7px;padding:6px 12px;margin:8px 8px 0 0;" "display:inline-flex;align-items:center;gap:8px;padding:8px 16px;"
"margin:0 18px 12px 0;"
f"border-radius:999px;background:{chip_bg};border:1px solid {brd}" f"border-radius:999px;background:{chip_bg};border:1px solid {brd}"
) )
else: else:
wrap = "display:inline-flex;align-items:center;gap:5px;margin:4px 14px 0 0" wrap = "display:inline-flex;align-items:center;gap:6px;padding:2px 6px;margin:0 12px 8px 0"
parts.append( parts.append(
f'<span style="{wrap}">' f'<span style="{wrap}">'
f"{icon}" f"{icon}"
@ -110,7 +111,7 @@ def format_nip05_chips_html(
) )
if not parts: if not parts:
return "" return ""
mt = "12px" if chip_bg else "4px" mt = "14px" if chip_bg else "6px"
return f'<div style="display:flex;flex-wrap:wrap;align-items:center;margin-top:{mt}">{"".join(parts)}</div>' return f'<div style="display:flex;flex-wrap:wrap;align-items:center;margin-top:{mt}">{"".join(parts)}</div>'

2
src/imwald/core/display_constants.py

@ -1,3 +1,5 @@
"""Shared display limits for HTML fragments and Qt rich text.""" """Shared display limits for HTML fragments and Qt rich text."""
APP_DISPLAY_NAME = "Imwald Desktop"
IMAGE_DISPLAY_MAX_WIDTH_PX = 400 IMAGE_DISPLAY_MAX_WIDTH_PX = 400

71
src/imwald/ui/feed_page.py

@ -52,6 +52,63 @@ FEED_KINDS = (1, 20, 21, 30023, 9802, 11)
_REACTION_WHOLE_SHORTCODE = re.compile(r"^:([a-zA-Z0-9_-]+):$") _REACTION_WHOLE_SHORTCODE = re.compile(r"^:([a-zA-Z0-9_-]+):$")
def relative_event_time_labels(
created_at: int,
*,
now: datetime | None = None,
) -> tuple[str, str]:
"""Short relative label and absolute UTC string (for HTML ``title`` tooltips)."""
n = now if now is not None else datetime.now(timezone.utc)
try:
ts = int(created_at)
then = datetime.fromtimestamp(ts, tz=timezone.utc)
except (TypeError, ValueError, OSError):
s = str(created_at)
return (s, s)
abs_s = then.strftime("%Y-%m-%d · %H:%M UTC")
sec = int((n - then).total_seconds())
if sec < -120:
ahead = -sec
if ahead < 3600:
m = max(1, (ahead + 29) // 60)
rel = f"in {m} min" + ("" if m == 1 else "s")
elif ahead < 86400:
h = max(1, (ahead + 1800) // 3600)
rel = f"in {h} hour" + ("" if h == 1 else "s")
elif ahead < 1209600:
d = max(1, (ahead + 43200) // 86400)
rel = f"in {d} day" + ("" if d == 1 else "s")
else:
y = max(1, ahead // (365 * 86400))
rel = f"in ~{y} year" + ("" if y == 1 else "s")
return (rel, abs_s)
if sec < 0:
return ("soon", abs_s)
if sec < 45:
return ("just now", abs_s)
if sec < 3600:
m = sec // 60
return ("1 min ago" if m < 2 else f"{m} mins ago", abs_s)
if sec < 7200:
return ("about 1 hour ago", abs_s)
if sec < 86400:
h = sec // 3600
return ("1 hour ago" if h < 2 else f"{h} hours ago", abs_s)
days = sec // 86400
if days == 1:
return ("1 day ago", abs_s)
if days < 7:
return (f"{days} days ago", abs_s)
if days < 60:
w = max(1, days // 7)
return ("1 week ago" if w == 1 else f"{w} weeks ago", abs_s)
if days < 365:
mo = max(1, days // 30)
return ("1 month ago" if mo == 1 else f"{mo} months ago", abs_s)
years = days // 365
return ("1 year ago" if years < 2 else f"{years} years ago", abs_s)
def _nip30_tags(ev_row: dict[str, Any]) -> list[list[str]] | None: def _nip30_tags(ev_row: dict[str, Any]) -> list[list[str]] | None:
t = ev_row.get("tags") t = ev_row.get("tags")
return cast(list[list[str]], t) if isinstance(t, list) else None return cast(list[list[str]], t) if isinstance(t, list) else None
@ -464,10 +521,12 @@ class FeedPage(QWidget):
) )
try: try:
cts = int(ev["created_at"]) cts = int(ev["created_at"])
t_human = datetime.fromtimestamp(cts, tz=timezone.utc).strftime("%Y-%m-%d · %H:%M UTC") rel_time, abs_time = relative_event_time_labels(cts)
except (TypeError, ValueError, OSError): except (TypeError, ValueError, OSError):
t_human = str(ev.get("created_at") or "") rel_time = str(ev.get("created_at") or "")
t_human_e = html.escape(t_human) abs_time = rel_time
rel_e = html.escape(rel_time, quote=False)
abs_title = html.escape(abs_time, quote=True)
eid_raw = str(ev["id"]) eid_raw = str(ev["id"])
if len(eid_raw) > 52: if len(eid_raw) > 52:
eid_display = html.escape(eid_raw[:22] + "" + eid_raw[-18:]) eid_display = html.escape(eid_raw[:22] + "" + eid_raw[-18:])
@ -484,9 +543,11 @@ class FeedPage(QWidget):
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">" "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body style=\"padding:16px 22px 28px 22px\">" f"{FEED_DOC_CSS}</head><body style=\"padding:16px 22px 28px 22px\">"
f"{author_block}" f"{author_block}"
f"<div style='color:{TEXT_DIM};font-size:14px;margin:0 0 16px 0;line-height:1.5'>" f"<div style='color:{TEXT_DIM};font-size:14px;margin:14px 0 16px 0;line-height:1.55'>"
f"<span style='color:{TEXT_MUTED};font-size:12px;font-weight:600'>Kind {int(ev['kind'])}</span>" f"<span style='color:{TEXT_MUTED};font-size:12px;font-weight:600'>Kind {int(ev['kind'])}</span>"
f"<span style='color:{TEXT_MUTED};margin:0 8px'>·</span>{t_human_e}</div>" f"<span style='color:{TEXT_MUTED};font-size:12px;font-weight:600'>&nbsp;&middot;&nbsp;</span>"
f"<span style='color:{TEXT_DIM};font-size:13px;cursor:default' title=\"{abs_title}\">{rel_e}</span>"
f"</div>"
f"{tr}" f"{tr}"
f'<div class="md" style="margin-top:4px">{md_body}</div>' f'<div class="md" style="margin-top:4px">{md_body}</div>'
f'<p style="color:{TEXT_DIM};font-size:12px;margin:22px 0 0 0;font-family:ui-monospace,monospace;' f'<p style="color:{TEXT_DIM};font-size:12px;margin:22px 0 0 0;font-family:ui-monospace,monospace;'

5
src/imwald/ui/main_window.py

@ -32,6 +32,7 @@ from PySide6.QtWidgets import (
from imwald.core.accounts_store import StoredAccount, load_accounts from imwald.core.accounts_store import StoredAccount, load_accounts
from imwald.core.database import Database from imwald.core.database import Database
from imwald.core.display_constants import APP_DISPLAY_NAME
from imwald.core.kind0_profile import parse_kind0_profile from imwald.core.kind0_profile import parse_kind0_profile
from imwald.core.nostr_engine import AUTHOR_METADATA_KINDS, NostrEngine from imwald.core.nostr_engine import AUTHOR_METADATA_KINDS, NostrEngine
from imwald.core.md_render import markdown_plain_summary from imwald.core.md_render import markdown_plain_summary
@ -107,7 +108,7 @@ class _AcctPicRunnable(QRunnable):
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self, *, db: Database, engine: NostrEngine, parent: QWidget | None = None) -> None: def __init__(self, *, db: Database, engine: NostrEngine, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("imwald") self.setWindowTitle(APP_DISPLAY_NAME)
self.resize(1200, 820) self.resize(1200, 820)
self._db = db self._db = db
@ -416,7 +417,7 @@ class MainWindow(QMainWindow):
a_about.triggered.connect( a_about.triggered.connect(
lambda: QMessageBox.about( lambda: QMessageBox.about(
self, self,
"About imwald", f"About {APP_DISPLAY_NAME}",
"Linux-native Nostr client — Qt (PySide6), SQLite, relay worker.", "Linux-native Nostr client — Qt (PySide6), SQLite, relay worker.",
) )
) )

3
src/imwald/ui/onboarding_wizard.py

@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
from imwald.core.accounts_store import StoredAccount, add_account_nsec_hex, generate_new_identity, save_accounts from imwald.core.accounts_store import StoredAccount, add_account_nsec_hex, generate_new_identity, save_accounts
from imwald.core.database import Database from imwald.core.database import Database
from imwald.core.display_constants import APP_DISPLAY_NAME
from imwald.core.nature_username import random_nature_username from imwald.core.nature_username import random_nature_username
from imwald.core.nostr_engine import NostrEngine from imwald.core.nostr_engine import NostrEngine
@ -193,7 +194,7 @@ def run_onboarding_wizard(
) -> bool: ) -> bool:
"""Return True if wizard finished (Accepted).""" """Return True if wizard finished (Accepted)."""
w = QWizard(parent) w = QWizard(parent)
w.setWindowTitle("imwald — onboarding") w.setWindowTitle(f"{APP_DISPLAY_NAME} — onboarding")
p0 = PageIntro() p0 = PageIntro()
p1 = PageProfile() p1 = PageProfile()
p2 = PageInterests() p2 = PageInterests()

75
src/imwald/ui/profile_page.py

@ -9,7 +9,7 @@ from typing import cast
from PySide6 import Shiboken from PySide6 import Shiboken
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal, QUrl from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal, QUrl
from PySide6.QtGui import QDesktopServices, QMouseEvent, QPainter, QPainterPath, QPixmap, QResizeEvent from PySide6.QtGui import QDesktopServices, QMouseEvent, QPainter, QPainterPath, QPixmap, QResizeEvent, QShowEvent
from PySide6.QtWidgets import QFrame, QLabel, QSizePolicy, QSplitter, QTabWidget, QVBoxLayout, QWidget from PySide6.QtWidgets import QFrame, QLabel, QSizePolicy, QSplitter, QTabWidget, QVBoxLayout, QWidget
from imwald.core.database import Database from imwald.core.database import Database
@ -366,18 +366,20 @@ class ProfilePage(QWidget):
left_lay.addWidget(self._left_body, 1) left_lay.addWidget(self._left_body, 1)
split = QSplitter(Qt.Orientation.Horizontal, self) split = QSplitter(Qt.Orientation.Horizontal, self)
split.setObjectName("ProfileSplit") split.setObjectName("ProfileSplit")
self._col_split = split
split.setChildrenCollapsible(False) split.setChildrenCollapsible(False)
split.addWidget(self._left_column) split.addWidget(self._left_column)
split.addWidget(self._feed_body) split.addWidget(self._feed_body)
split.addWidget(self._interact_body) split.addWidget(self._interact_body)
# Metadata (narrow) · posts (widest) · activity (medium). # Three main columns: equal stretch so resizes stay even; initial widths set in showEvent.
split.setStretchFactor(0, 2) split.setStretchFactor(0, 1)
split.setStretchFactor(1, 4) split.setStretchFactor(1, 1)
split.setStretchFactor(2, 2) split.setStretchFactor(2, 1)
split.setSizes([300, 640, 280]) equal_min = 220
self._left_column.setMinimumWidth(260) self._left_column.setMinimumWidth(equal_min)
self._feed_body.setMinimumWidth(240) self._feed_body.setMinimumWidth(equal_min)
self._interact_body.setMinimumWidth(220) self._interact_body.setMinimumWidth(equal_min)
self._profile_cols_equalized = False
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0) lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(split) lay.addWidget(split)
@ -397,11 +399,36 @@ class ProfilePage(QWidget):
# resetting placeholders + bumping fetch generations on every refresh() while ingest runs. # resetting placeholders + bumping fetch generations on every refresh() while ingest runs.
self._cover_banner_http_key: str | None = None self._cover_banner_http_key: str | None = None
self._cover_avatar_http_key: str | None = None self._cover_avatar_http_key: str | None = None
# LNURL-pay HTML: cache so ingest-driven refresh() does not reset to “Loading…” every tick.
self._lnurl_urls_key: str | None = None
self._lnurl_html_cache: str | None = None
self._lnurl_fetch_inflight = False
self._left_panel_sig: object | None = None self._left_panel_sig: object | None = None
self._feed_panel_sig: object | None = None self._feed_panel_sig: object | None = None
self._interact_panel_sig: object | None = None self._interact_panel_sig: object | None = None
self.refresh() self.refresh()
def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
self._maybe_equalize_profile_columns()
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
if not self._profile_cols_equalized:
self._maybe_equalize_profile_columns()
def _maybe_equalize_profile_columns(self) -> None:
if self._profile_cols_equalized:
return
sp = self._col_split
w = sp.width()
if w < 360:
return
third = max(w // 3, self._left_column.minimumWidth())
rem = max(w - 2 * third, self._interact_body.minimumWidth())
sp.setSizes([third, third, rem])
self._profile_cols_equalized = True
def tab_title(self) -> str: def tab_title(self) -> str:
row = self._db.get_latest_kind0_profile(self._pubkey) row = self._db.get_latest_kind0_profile(self._pubkey)
p = parse_kind0_profile(row["content"] if row else "") p = parse_kind0_profile(row["content"] if row else "")
@ -442,14 +469,34 @@ class ProfilePage(QWidget):
lud06_s = lud06_raw.strip() if isinstance(lud06_raw, str) else "" lud06_s = lud06_raw.strip() if isinstance(lud06_raw, str) else ""
lud16_s = lud16_raw.strip() if isinstance(lud16_raw, str) else "" lud16_s = lud16_raw.strip() if isinstance(lud16_raw, str) else ""
lnurls = collect_unique_lnurlp_urls(lud06_s or None, lud16_s or None) lnurls = collect_unique_lnurlp_urls(lud06_s or None, lud16_s or None)
lnurl_key = "\n".join(sorted(lnurls)) if lnurls else ""
live_lnurl = "" live_lnurl = ""
if from_lnurl and lnurl_html is not None: if from_lnurl and lnurl_html is not None:
self._lnurl_html_cache = lnurl_html
self._lnurl_fetch_inflight = False
if lnurl_key:
self._lnurl_urls_key = lnurl_key
live_lnurl = lnurl_html live_lnurl = lnurl_html
elif not from_lnurl and lnurls: elif not lnurls:
self._lnurl_gen += 1 self._lnurl_urls_key = None
gen = self._lnurl_gen self._lnurl_html_cache = None
self._lnurl_pool.start(_ProfileLnurlRunnable(lnurls, gen, self._lnurl_sigs)) self._lnurl_fetch_inflight = False
live_lnurl = f"<p style='color:{TEXT_DIM};font-size:14px;margin:8px 0 0 0'><i>Loading tip details…</i></p>" elif self._lnurl_html_cache is not None and self._lnurl_urls_key == lnurl_key:
live_lnurl = self._lnurl_html_cache
else:
if self._lnurl_urls_key != lnurl_key:
self._lnurl_urls_key = lnurl_key
self._lnurl_html_cache = None
self._lnurl_fetch_inflight = False
self._lnurl_gen += 1
live_lnurl = (
f"<p style='color:{TEXT_DIM};font-size:14px;margin:8px 0 0 0'><i>Loading tip details…</i></p>"
)
if not self._lnurl_fetch_inflight:
self._lnurl_fetch_inflight = True
self._lnurl_gen += 1
gen = self._lnurl_gen
self._lnurl_pool.start(_ProfileLnurlRunnable(lnurls, gen, self._lnurl_sigs))
tech_dl: list[str] = [] tech_dl: list[str] = []
if lud06_s: if lud06_s:
tech_dl.append( tech_dl.append(

11
tests/test_feed_views.py

@ -1,6 +1,7 @@
"""Feed candidate selection and feed_views persistence.""" """Feed candidate selection and feed_views persistence."""
import tempfile import tempfile
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
@ -29,3 +30,13 @@ def test_feed_candidates_exclude_viewed_then_include_when_all_seen() -> None:
assert empty == [] assert empty == []
again = db.feed_candidates(kinds, hide_nsfw=False, limit=50, viewer_pubkey=pk, exclude_viewed=False) again = db.feed_candidates(kinds, hide_nsfw=False, limit=50, viewer_pubkey=pk, exclude_viewed=False)
assert len(again) == 1 and again[0]["id"] == eid assert len(again) == 1 and again[0]["id"] == eid
def test_relative_event_time_labels_and_absolute_tooltip() -> None:
from imwald.ui.feed_page import relative_event_time_labels
fixed = datetime(2026, 4, 19, 14, 0, 0, tzinfo=timezone.utc)
ts = int(datetime(2026, 4, 19, 12, 49, 0, tzinfo=timezone.utc).timestamp())
rel, abs_s = relative_event_time_labels(ts, now=fixed)
assert abs_s == "2026-04-19 · 12:49 UTC"
assert "hour" in rel

Loading…
Cancel
Save