diff --git a/src/imwald/app.py b/src/imwald/app.py index e09456c..5f9d129 100644 --- a/src/imwald/app.py +++ b/src/imwald/app.py @@ -10,6 +10,7 @@ from PySide6.QtWidgets import QApplication from imwald.config import db_path from imwald.core.database import Database +from imwald.core.display_constants import APP_DISPLAY_NAME from imwald.core.nostr_engine import NostrEngine from imwald.ui.asset_paths import favicon_png_path 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") app = QApplication(sys.argv) app.setApplicationName("imwald") + app.setApplicationDisplayName(APP_DISPLAY_NAME) app.setOrganizationName("imwald") fav_pm = QPixmap(str(favicon_png_path())) if not fav_pm.isNull(): diff --git a/src/imwald/core/author_html.py b/src/imwald/core/author_html.py index 44dfba2..d525c8c 100644 --- a/src/imwald/core/author_html.py +++ b/src/imwald/core/author_html.py @@ -97,11 +97,12 @@ def format_nip05_chips_html( if chip_bg: brd = chip_border or dim 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}" ) 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( f'' f"{icon}" @@ -110,7 +111,7 @@ def format_nip05_chips_html( ) if not parts: return "" - mt = "12px" if chip_bg else "4px" + mt = "14px" if chip_bg else "6px" return f'
{"".join(parts)}
' diff --git a/src/imwald/core/display_constants.py b/src/imwald/core/display_constants.py index deb3726..2302145 100644 --- a/src/imwald/core/display_constants.py +++ b/src/imwald/core/display_constants.py @@ -1,3 +1,5 @@ """Shared display limits for HTML fragments and Qt rich text.""" +APP_DISPLAY_NAME = "Imwald Desktop" + IMAGE_DISPLAY_MAX_WIDTH_PX = 400 diff --git a/src/imwald/ui/feed_page.py b/src/imwald/ui/feed_page.py index eec05c2..c5c82b3 100644 --- a/src/imwald/ui/feed_page.py +++ b/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_-]+):$") +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: t = ev_row.get("tags") return cast(list[list[str]], t) if isinstance(t, list) else None @@ -464,10 +521,12 @@ class FeedPage(QWidget): ) try: 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): - t_human = str(ev.get("created_at") or "") - t_human_e = html.escape(t_human) + rel_time = str(ev.get("created_at") or "") + 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"]) if len(eid_raw) > 52: eid_display = html.escape(eid_raw[:22] + "…" + eid_raw[-18:]) @@ -484,9 +543,11 @@ class FeedPage(QWidget): "" f"{FEED_DOC_CSS}" f"{author_block}" - f"
" + f"
" f"Kind {int(ev['kind'])}" - f"·{t_human_e}
" + f" · " + f"{rel_e}" + f"
" f"{tr}" f'
{md_body}
' f'

bool: """Return True if wizard finished (Accepted).""" w = QWizard(parent) - w.setWindowTitle("imwald — onboarding") + w.setWindowTitle(f"{APP_DISPLAY_NAME} — onboarding") p0 = PageIntro() p1 = PageProfile() p2 = PageInterests() diff --git a/src/imwald/ui/profile_page.py b/src/imwald/ui/profile_page.py index c8b6f05..cab5950 100644 --- a/src/imwald/ui/profile_page.py +++ b/src/imwald/ui/profile_page.py @@ -9,7 +9,7 @@ from typing import cast from PySide6 import Shiboken 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 imwald.core.database import Database @@ -366,18 +366,20 @@ class ProfilePage(QWidget): left_lay.addWidget(self._left_body, 1) split = QSplitter(Qt.Orientation.Horizontal, self) split.setObjectName("ProfileSplit") + self._col_split = split split.setChildrenCollapsible(False) split.addWidget(self._left_column) split.addWidget(self._feed_body) split.addWidget(self._interact_body) - # Metadata (narrow) · posts (widest) · activity (medium). - split.setStretchFactor(0, 2) - split.setStretchFactor(1, 4) - split.setStretchFactor(2, 2) - split.setSizes([300, 640, 280]) - self._left_column.setMinimumWidth(260) - self._feed_body.setMinimumWidth(240) - self._interact_body.setMinimumWidth(220) + # Three main columns: equal stretch so resizes stay even; initial widths set in showEvent. + split.setStretchFactor(0, 1) + split.setStretchFactor(1, 1) + split.setStretchFactor(2, 1) + equal_min = 220 + self._left_column.setMinimumWidth(equal_min) + self._feed_body.setMinimumWidth(equal_min) + self._interact_body.setMinimumWidth(equal_min) + self._profile_cols_equalized = False lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.addWidget(split) @@ -397,11 +399,36 @@ class ProfilePage(QWidget): # resetting placeholders + bumping fetch generations on every refresh() while ingest runs. self._cover_banner_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._feed_panel_sig: object | None = None self._interact_panel_sig: object | None = None 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: row = self._db.get_latest_kind0_profile(self._pubkey) 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 "" lud16_s = lud16_raw.strip() if isinstance(lud16_raw, str) else "" lnurls = collect_unique_lnurlp_urls(lud06_s or None, lud16_s or None) + lnurl_key = "\n".join(sorted(lnurls)) if lnurls else "" live_lnurl = "" 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 - elif not from_lnurl and lnurls: - self._lnurl_gen += 1 - gen = self._lnurl_gen - self._lnurl_pool.start(_ProfileLnurlRunnable(lnurls, gen, self._lnurl_sigs)) - live_lnurl = f"

Loading tip details…

" + elif not lnurls: + self._lnurl_urls_key = None + self._lnurl_html_cache = None + self._lnurl_fetch_inflight = False + 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"

Loading tip details…

" + ) + 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] = [] if lud06_s: tech_dl.append( diff --git a/tests/test_feed_views.py b/tests/test_feed_views.py index 560ff03..d6e0443 100644 --- a/tests/test_feed_views.py +++ b/tests/test_feed_views.py @@ -1,6 +1,7 @@ """Feed candidate selection and feed_views persistence.""" import tempfile +from datetime import datetime, timezone from pathlib import Path from typing import cast @@ -29,3 +30,13 @@ def test_feed_candidates_exclude_viewed_then_include_when_all_seen() -> None: assert empty == [] 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 + + +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