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 @@ -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: @@ -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():

7
src/imwald/core/author_html.py

@ -97,11 +97,12 @@ def format_nip05_chips_html( @@ -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'<span style="{wrap}">'
f"{icon}"
@ -110,7 +111,7 @@ def format_nip05_chips_html( @@ -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'<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 @@ @@ -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

71
src/imwald/ui/feed_page.py

@ -52,6 +52,63 @@ FEED_KINDS = (1, 20, 21, 30023, 9802, 11) @@ -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): @@ -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): @@ -484,9 +543,11 @@ class FeedPage(QWidget):
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body style=\"padding:16px 22px 28px 22px\">"
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};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'<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;'

5
src/imwald/ui/main_window.py

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

3
src/imwald/ui/onboarding_wizard.py

@ -20,6 +20,7 @@ from PySide6.QtWidgets import ( @@ -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.database import Database
from imwald.core.display_constants import APP_DISPLAY_NAME
from imwald.core.nature_username import random_nature_username
from imwald.core.nostr_engine import NostrEngine
@ -193,7 +194,7 @@ def run_onboarding_wizard( @@ -193,7 +194,7 @@ def run_onboarding_wizard(
) -> 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()

75
src/imwald/ui/profile_page.py

@ -9,7 +9,7 @@ from typing import cast @@ -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): @@ -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): @@ -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): @@ -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"<p style='color:{TEXT_DIM};font-size:14px;margin:8px 0 0 0'><i>Loading tip details…</i></p>"
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"<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] = []
if lud06_s:
tech_dl.append(

11
tests/test_feed_views.py

@ -1,6 +1,7 @@ @@ -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: @@ -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

Loading…
Cancel
Save