Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -2425,3 +2425,98 @@ body:has(.comment-toolbar) .main-content {
color: var(--color-text-muted);
font-size: 0.9em;
}

/* Web Push encouragement banner — bottom-right toast that appears after a
user posts a comment, inviting them to enable browser notifications.
Stays above the comment toolbar (z-index 50) and any thread popovers. */
.web-push-banner {
position: fixed;
right: var(--space-lg);
/* Sit above the comment toolbar (when present) without overlapping. */
bottom: calc(var(--space-lg) + 3.5rem);
z-index: 60;
display: flex;
align-items: flex-start;
gap: var(--space-md);
width: min(420px, calc(100vw - var(--space-lg) * 2));
padding: var(--space-md) var(--space-lg);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
animation: web-push-banner-in 220ms ease-out;
}

body:not(:has(.comment-toolbar)) .web-push-banner {
bottom: var(--space-lg);
}

@keyframes web-push-banner-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}

.web-push-banner[hidden] {
display: none;
}

.web-push-banner__icon {
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface-muted);
color: var(--color-text);
border-radius: 999px;
}

.web-push-banner__main {
flex: 1;
min-width: 0;
}

.web-push-banner__title {
font-weight: 600;
font-size: var(--text-sm);
color: var(--color-text);
margin-bottom: 2px;
}

.web-push-banner__hint {
font-size: var(--text-sm);
color: var(--color-text-muted);
line-height: 1.4;
}

.web-push-banner__hint a {
color: var(--color-text);
text-decoration: underline;
}

.web-push-banner__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
margin-top: var(--space-md);
}

.web-push-banner__btn {
/* Slightly chunkier than the default btn--sm so the CTA reads as a real
decision point rather than a footnote. */
padding: 0.4rem 0.9rem;
font-weight: 600;
}

@media (max-width: 600px) {
.web-push-banner {
right: var(--space-md);
left: var(--space-md);
width: auto;
}

.web-push-banner__actions .btn {
flex: 1;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,38 @@ export default class extends Controller {
this._docClickHandler = this.handleDocumentClick.bind(this)
this._inputHandler = this.handleInput.bind(this)
this._keydownHandler = this.handleKeydown.bind(this)
this._submitEndHandler = this.handleSubmitEnd.bind(this)

this.element.addEventListener("input", this._inputHandler)
// keydown listens with capture so the picker handles Enter/Arrows
// before the legacy submitOnEnter action fires.
this.element.addEventListener("keydown", this._keydownHandler, true)
document.addEventListener("click", this._docClickHandler)

// Notify the global Web Push encouragement banner that this user just
// posted a comment — gives the banner a chance to surface itself.
this._submitForm = this.element.closest("form")
if (this._submitForm) {
this._submitForm.addEventListener("turbo:submit-end", this._submitEndHandler)
}
}

disconnect() {
this.element.removeEventListener("input", this._inputHandler)
this.element.removeEventListener("keydown", this._keydownHandler, true)
document.removeEventListener("click", this._docClickHandler)
if (this._submitForm) {
this._submitForm.removeEventListener("turbo:submit-end", this._submitEndHandler)
}
this.closePicker()
if (this._debounce) clearTimeout(this._debounce)
}

handleSubmitEnd(event) {
if (!event.detail?.success) return
document.dispatchEvent(new CustomEvent("coplan:web-push-banner:nudge"))
}

// Legacy keep-alive entry point (still wired in views via data-action).
// Real Enter handling now happens in handleKeydown.
submitOnEnter(_event) {}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Controller } from "@hotwired/stimulus"
import * as WebPush from "coplan/web_push"

// Encouragement banner. Listens for `coplan:web-push-banner:nudge` events
// (dispatched by the comment form on successful submit) and shows a small
// toast-style banner inviting the user to enable browser notifications.
//
// Eligibility (all must hold):
// - browser supports Notification + PushManager + we have a VAPID key
// - permission isn't already "denied"
// - not already subscribed on this device
// - the user hasn't dismissed N times (default 3)
// - the user hasn't already enabled via the banner before
//
// Dismissals and the permanent-off flag live in localStorage so they're
// per-device, which matches how subscriptions work.
const DISMISS_KEY = "coplan.web_push_banner.dismissals"
const PERMANENT_KEY = "coplan.web_push_banner.permanent_off"

export default class extends Controller {
static targets = ["title", "hint", "actions"]
static values = {
maxDismissals: { type: Number, default: 3 },
settingsUrl: { type: String, default: "" }
}

connect() {
this._onNudge = this._onNudge.bind(this)
document.addEventListener("coplan:web-push-banner:nudge", this._onNudge)
}

disconnect() {
document.removeEventListener("coplan:web-push-banner:nudge", this._onNudge)
}

async _onNudge() {
if (!(await this._eligible())) return
this.element.hidden = false
}

async _eligible() {
if (localStorage.getItem(PERMANENT_KEY) === "1") return false
if (!WebPush.isSupported()) return false
if (WebPush.permission() === "denied") return false
if (await WebPush.isSubscribed()) return false
if (this._dismissCount() >= this.maxDismissalsValue) return false
return true
}

async enable() {
this._setMessage("Enabling…", "")
try {
await WebPush.subscribe()
this._setMessage(
"All set!",
"You'll get a desktop notification when someone replies."
)
this.actionsTarget.hidden = true
// Mark permanent so we never nag again on this device — they're already in.
localStorage.setItem(PERMANENT_KEY, "1")
setTimeout(() => { this.element.hidden = true }, 4000)
} catch (err) {
this._setMessage(
"Couldn't enable notifications.",
this._friendlyError(err)
)
}
}

dismiss() {
const next = this._dismissCount() + 1
localStorage.setItem(DISMISS_KEY, String(next))
if (next >= this.maxDismissalsValue) {
localStorage.setItem(PERMANENT_KEY, "1")
}
this.element.hidden = true
}

_dismissCount() {
const raw = localStorage.getItem(DISMISS_KEY)
const n = parseInt(raw || "0", 10)
return Number.isFinite(n) ? n : 0
}

_setMessage(title, hint) {
if (this.hasTitleTarget) this.titleTarget.textContent = title
if (this.hasHintTarget) this.hintTarget.textContent = hint
}

_friendlyError(err) {
const msg = err?.message || String(err)
if (/permission/i.test(msg)) return "Permission was not granted."
return msg
}
}
37 changes: 37 additions & 0 deletions engine/app/views/coplan/shared/_web_push_banner.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<%# Toast-style nudge that appears after a signed-in user posts a comment, %>
<%# encouraging them to enable browser notifications on this device. The %>
<%# Stimulus controller decides whether to actually show it (browser support, %>
<%# existing subscription, dismissal count) — the partial just provides the %>
<%# DOM in a hidden state. %>
<% return unless signed_in? && CoPlan.configuration.web_push_configured? %>

<div class="web-push-banner"
data-controller="coplan--web-push-banner"
data-coplan--web-push-banner-max-dismissals-value="3"
data-coplan--web-push-banner-settings-url-value="<%= coplan.settings_root_path %>"
hidden>
<div class="web-push-banner__icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
</div>
<div class="web-push-banner__main">
<div class="web-push-banner__title" data-coplan--web-push-banner-target="title">
Want to know when someone replies?
</div>
<div class="web-push-banner__hint" data-coplan--web-push-banner-target="hint">
Get a desktop notification on this device. You can change this anytime in
<a href="<%= coplan.settings_root_path %>">Settings</a>.
</div>
<div class="web-push-banner__actions" data-coplan--web-push-banner-target="actions">
<button type="button"
class="btn btn--secondary btn--sm web-push-banner__btn"
data-action="coplan--web-push-banner#dismiss">
Not now
</button>
<button type="button"
class="btn btn--primary btn--sm web-push-banner__btn"
data-action="coplan--web-push-banner#enable">
Yes, notify me
</button>
</div>
</div>
</div>
1 change: 1 addition & 0 deletions engine/app/views/layouts/coplan/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,6 @@
<% end %>
<%= yield %>
</main>
<%= render "coplan/shared/web_push_banner" %>
</body>
</html>
Loading