Skip to content
Merged
153 changes: 121 additions & 32 deletions ankihub/gui/ankiweb.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,31 @@
from .utils import error_icon, is_email

EMAIL_INSTRUCTIONS = (
"Didn't receive an email?<ul><li>Check your spam folder. "
"Didn't receive an email?<ul><li>Check the spam folder. "
"Emails can end up there.</li><li>Resend the email when the countdown ends.</li></ul>"
)
# Client-side only; the verification-resend API reports daily throttling via
# `throttled`, not a per-request cooldown. Matches the web verification-sent page.
VERIFICATION_EMAIL_RESEND_COOLDOWN_SECS = 60
ERROR_DIALOG_LINK = "#error-dialog"


def fit_wrapped_labels(root: QWidget, fallback_width: int = 437) -> None:
"""Pin word-wrapped QLabels to their real height at the current width.

QLabel sizeHint under-reports height for wrapped/rich text (especially
lists), which clips the last line(s). Same approach as
bulk_suggestion_summary_dialog._fit_to_content.
"""
for label in root.findChildren(QLabel):
if not label.wordWrap():
continue
width = label.width() if label.width() > 0 else fallback_width
height = label.heightForWidth(width)
if height > 0:
label.setMinimumHeight(height)


class AnkiwebLinkIds(Enum):
LOGIN_CODE = "#sign-in-code"
LOGIN_PASSWORD = "#sign-in-password"
Expand Down Expand Up @@ -120,17 +139,19 @@ def timer_is_active(timer: Countdown | None) -> bool:


class ResendCooldownTracker:
"""Remembers each email's magic-code resend cooldown independently of any
widget, so that closing and reopening the AnkiWeb dialog within the same
Anki session keeps showing the correct remaining countdown instead of
resetting it.
"""Remembers each email's resend cooldown independently of any widget, so
that closing and reopening the AnkiWeb dialog within the same Anki session
keeps showing the correct remaining countdown instead of resetting it.

Used for magic-code resends (server provides the TTL) and for password-
signup verification emails (fixed client-side TTL).

This is a UI convenience only, not an enforcement mechanism: the tracker
lives in memory for the current session, so restarting Anki clears it and
lets the resend button appear enabled again immediately. That's fine
because the actual cooldown is enforced server-side; this class only
avoids a redundant request (and confusing UI) in the common case where the
dialog is closed and reopened without restarting Anki.
because abuse limits are enforced server-side; this class only avoids a
redundant request (and confusing UI) in the common case where the dialog
is closed and reopened without restarting Anki.
"""

def __init__(self) -> None:
Expand Down Expand Up @@ -369,6 +390,9 @@ def _setup_ui(self, description: str, rows: list[FormRow]) -> None:
vbox.addWidget(description_label)

form_layout = QFormLayout()
# Bare QWidgets (e.g. multi-line instruction labels) go on the vbox —
# QFormLayout often under-reports their height and clips rich text.
trailing_widgets: list[QWidget] = []
for row in rows:
if isinstance(row, tuple):
label_text, field = row
Expand All @@ -377,11 +401,16 @@ def _setup_ui(self, description: str, rows: list[FormRow]) -> None:
font.setBold(True)
label.setFont(font)
form_layout.addRow(label, field)
else:
elif isinstance(row, QLayout):
form_layout.addRow(row)
else:
trailing_widgets.append(row)
form_layout.setSpacing(8)
form_layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
vbox.addLayout(form_layout)
if form_layout.rowCount():
vbox.addLayout(form_layout)
for widget in trailing_widgets:
vbox.addWidget(widget)

self.setLayout(vbox)

Expand Down Expand Up @@ -424,6 +453,7 @@ def _setup_ui(self, initial_widget: BaseAnkiwebWidget) -> None:
vbox.setContentsMargins(20, 20, 20, 20)
self.setLayout(vbox)
self.setWindowTitle(initial_widget.title)
self._schedule_fit_wrapped_labels()

def replace_widget(self, widget: BaseAnkiwebWidget) -> None:
self.layout().replaceWidget(self._widget, widget)
Expand All @@ -432,6 +462,20 @@ def replace_widget(self, widget: BaseAnkiwebWidget) -> None:
self._widget = widget
self.setWindowTitle(widget.title)
self.adjustSize()
self._schedule_fit_wrapped_labels()

def _schedule_fit_wrapped_labels(self) -> None:
# Defer until after the layout has a real width; sizeHint alone clips
# the last line(s) of word-wrapped / rich-text labels.
widget = self._widget

def fit() -> None:
if sip.isdeleted(self) or sip.isdeleted(widget):
return
fit_wrapped_labels(widget)
self.adjustSize()

QTimer.singleShot(0, fit)

def show_progress(self, widget: InlineProgressWidget) -> None:
self._widget.setVisible(False)
Expand Down Expand Up @@ -465,12 +509,13 @@ def __init__(
bottom_label: str,
dialog: AnkiwebDialog,
extra_bottom_button: QPushButton | None = None,
show_cancel: bool = True,
parent: QWidget | None = None,
):
self._dialog = dialog
self._timer: Countdown | None = None
super().__init__(parent=parent)
self._setup_ui(heading, main_description, form_widget, bottom_label, extra_bottom_button)
self._setup_ui(heading, main_description, form_widget, bottom_label, extra_bottom_button, show_cancel)

def _setup_ui(
self,
Expand All @@ -479,6 +524,7 @@ def _setup_ui(
form_widget: FormWidget,
bottom_label: str,
extra_bottom_button: QPushButton | None,
show_cancel: bool,
) -> None:
vbox = QVBoxLayout()

Expand All @@ -504,7 +550,6 @@ def _setup_ui(
signup_link = LabelWithLink(bottom_label, self._dialog)
bottom_hbox.addWidget(signup_link)

cancel_button = CancelButton(self._dialog)
buttons_hbox = QHBoxLayout()
buttons_hbox.setAlignment(Qt.AlignmentFlag.AlignRight)
if form_widget.back_to:
Expand All @@ -514,7 +559,8 @@ def _setup_ui(
lambda: self._dialog.replace_widget(widget_for_link(form_widget.back_to)(self._dialog)),
)
buttons_hbox.addWidget(back_button)
buttons_hbox.addWidget(cancel_button)
if show_cancel:
buttons_hbox.addWidget(CancelButton(self._dialog))
if extra_bottom_button:
buttons_hbox.addWidget(extra_bottom_button)
bottom_hbox.addLayout(buttons_hbox)
Expand Down Expand Up @@ -766,6 +812,7 @@ def __init__(
bottom_label: str,
dialog: AnkiwebDialog,
extra_bottom_button: QPushButton | None = None,
show_cancel: bool = True,
):
super().__init__(
heading=heading,
Expand All @@ -774,6 +821,7 @@ def __init__(
bottom_label=bottom_label,
dialog=dialog,
extra_bottom_button=extra_bottom_button,
show_cancel=show_cancel,
)


Expand Down Expand Up @@ -804,6 +852,8 @@ def _create_form_widget(self, error: str) -> FormWidget:


class SignupEmailVerificationWidget(BaseSignupWidget):
_COOLDOWN_SCOPE = "signup_verification"

def __init__(self, email: str, host_key: str, dialog: AnkiwebDialog):
self.email = email
self.host_key = host_key
Expand All @@ -817,46 +867,85 @@ def __init__(self, email: str, host_key: str, dialog: AnkiwebDialog):
bottom_label="",
dialog=dialog,
extra_bottom_button=login_button,
show_cancel=False,
)
# Signup already sent the verification email. Shouldn't call resend here.
self.description_label.setText(f"📮 If {self.email} exists, we sent a verification link to its inbox.<br>")
# Start the UI cooldown so resend isn't clickable yet (don't call the resend API here).
remaining = _resend_cooldowns.remaining_seconds(self._COOLDOWN_SCOPE, email)
if remaining <= 0:
_resend_cooldowns.start(self._COOLDOWN_SCOPE, email, VERIFICATION_EMAIL_RESEND_COOLDOWN_SECS)
remaining = VERIFICATION_EMAIL_RESEND_COOLDOWN_SECS
self._start_cooldown(remaining)

def _create_form_widget(self) -> FormWidget:
self.description_label = description_label = QLabel("")
description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
description_label.setWordWrap(True)
self.resend_button = resend_button = QPushButton("Resend verification email")
qconnect(resend_button.clicked, self._resend)
instructions_label = QLabel(EMAIL_INSTRUCTIONS)
instructions_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
form_widget = FormWidget(
description="",
rows=[
description_label,
resend_button,
instructions_label,
],
# Separate plain labels: a single QLabel with <ul> under-reports height
# and clips the last bullet even with wordWrap enabled.
instructions = QWidget()
instructions_layout = QVBoxLayout(instructions)
instructions_layout.setContentsMargins(0, 0, 0, 0)
instructions_layout.setSpacing(4)
heading = QLabel("Didn't receive an email?")
heading.setAlignment(Qt.AlignmentFlag.AlignCenter)
instructions_layout.addWidget(heading)
for text in (
"Check the spam folder. Emails can end up there.",
"Resend the email when the countdown ends.",
):
item = QLabel(f"• {text}")
item.setAlignment(Qt.AlignmentFlag.AlignCenter)
instructions_layout.addWidget(item)
description = (
f"📮 We sent a verification link to <b>{self.email}</b>. "
f"<br/>If the email is not correct, "
f"{html_link(AnkiwebLinkIds.SIGNUP_PASSWORD.value, 'please change it')}."
)
return FormWidget(
description=description,
rows=[resend_button, instructions],
dialog=self._dialog,
back_to=AnkiwebLinkIds.SIGNUP_PASSWORD,
)

return form_widget
def _start_cooldown(self, remaining_secs: int) -> None:
def on_timeout(remaining_secs: int) -> None:
resend_available_status = (
f"Resend available in {remaining_secs}s" if remaining_secs else "Resend available."
)
self.status_label.setText(
f"If {self.email} account exists, we sent a message to its inbox.<br>" + resend_available_status
)
if not remaining_secs:
self.resend_button.setEnabled(True)

self.init_timer(on_timeout, remaining_secs)
self.resend_button.setEnabled(False)

def _resend(self) -> None:
# Disable immediately so a second click can't fire while the request is in flight.
self.resend_button.setEnabled(False)

def on_success(throttled: bool) -> None:
if throttled:
self.form_widget.error_label.set_error("Sorry, no more emails can be sent to that address today.")
self.description_label.setText("")
self.status_label.setText("")
else:
# TODO: use /verify-email to get actual status
self.description_label.setText(
f"📮 If {self.email} exists, we sent a verification link to its inbox.<br>"
)
self.form_widget.error_label.set_error("")
_resend_cooldowns.start(self._COOLDOWN_SCOPE, self.email, VERIFICATION_EMAIL_RESEND_COOLDOWN_SECS)
self._start_cooldown(VERIFICATION_EMAIL_RESEND_COOLDOWN_SECS)

def on_failure(exc: Exception) -> None:
self.form_widget.error_label.set_exception(exc)
if not timer_is_active(self._timer):
self.resend_button.setEnabled(True)

AddonQueryOp(
parent=self,
op=lambda _: AnkiHubClient().ankiweb_resend_verification(self.host_key).throttled,
success=on_success,
).failure(lambda exc: self.form_widget.error_label.set_exception(exc)).run_in_background()
).failure(on_failure).run_in_background()

def _on_login(self) -> None:
self._dialog.replace_widget(LoginWithPasswordWidget(self._dialog))
Expand Down
61 changes: 58 additions & 3 deletions tests/addon/test_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1858,9 +1858,6 @@ def test_signup_with_password_success_shows_email_verification_widget(self, qtbo
signup_mock = mocker.patch.object(
AddonAnkiHubClient, "ankiweb_signup", return_value=Mock(host_key="hostkey123")
)
# SignupEmailVerificationWidget kicks off a resend-verification request as soon
# as it's constructed, to start the "resend available in Ns" countdown.
mocker.patch.object(AddonAnkiHubClient, "ankiweb_resend_verification")

dialog = AnkiwebSignupDialog()
qtbot.addWidget(dialog)
Expand All @@ -1876,6 +1873,64 @@ def test_signup_with_password_success_shows_email_verification_widget(self, qtbo
signup_mock.assert_called_once_with("user@example.com", "password123", True)
assert isinstance(dialog._widget, SignupEmailVerificationWidget)
assert dialog._widget.host_key == "hostkey123"
# Signup already sent the verification email, so resend starts on cooldown.
assert dialog._widget.resend_button.isEnabled() is False
assert "user@example.com" in dialog._widget.status_label.text()
assert "Resend available in" in dialog._widget.status_label.text()
assert dialog._widget.form_widget.back_to is not None
assert dialog._widget.resend_button.text() == "Resend verification email"

def test_signup_email_verification_resend_restarts_cooldown(self, qtbot: QtBot, mocker: MockerFixture):
from ankihub.gui import ankiweb as ankiweb_mod
from ankihub.gui.ankiweb import SignupEmailVerificationWidget

ankiweb_mod._resend_cooldowns._deadlines.clear()
mocker.patch.object(AddonAnkiHubClient, "ankiweb_resend_verification", return_value=Mock(throttled=False))

dialog = AnkiwebSignupDialog()
qtbot.addWidget(dialog)
widget = SignupEmailVerificationWidget(
email="resend-cooldown@example.com", host_key="hostkey123", dialog=dialog
)
dialog.replace_widget(widget)

# Drain the initial post-signup cooldown so resend becomes clickable.
for _ in range(widget._timer.remaining_seconds + 1):
widget._timer._on_timeout()
assert widget.resend_button.isEnabled() is True

widget._resend()

assert widget.resend_button.isEnabled() is False
assert "Resend available in 60s" in widget.status_label.text()

for _ in range(widget._timer.remaining_seconds + 1):
widget._timer._on_timeout()

assert widget.resend_button.isEnabled() is True
assert "Resend available." in widget.status_label.text()

def test_signup_email_verification_resend_throttled_keeps_button_disabled(
self, qtbot: QtBot, mocker: MockerFixture
):
from ankihub.gui import ankiweb as ankiweb_mod
from ankihub.gui.ankiweb import SignupEmailVerificationWidget

ankiweb_mod._resend_cooldowns._deadlines.clear()
mocker.patch.object(AddonAnkiHubClient, "ankiweb_resend_verification", return_value=Mock(throttled=True))

dialog = AnkiwebSignupDialog()
qtbot.addWidget(dialog)
widget = SignupEmailVerificationWidget(email="throttled@example.com", host_key="hostkey123", dialog=dialog)
dialog.replace_widget(widget)

for _ in range(widget._timer.remaining_seconds + 1):
widget._timer._on_timeout()

widget._resend()

assert widget.resend_button.isEnabled() is False
assert "no more emails" in widget.form_widget.error_label.status.text()

def test_login_with_code_request_exception_shows_generic_message(self, qtbot: QtBot, mocker: MockerFixture):
mocker.patch("ankihub.gui.ankiweb._error_reporting_enabled", return_value=False)
Expand Down
Loading