[Notify] Add live in-app notifications over SSE (Iran-friendly)
CI/CD / CI · dotnet build (push) Has been cancelled
CI/CD / Deploy · hamkadr (push) Has been cancelled

Web Push is delivered by the browser vendor's push service (Chrome to Google FCM), which is filtered in Iran, so background push is unreliable. Add a Server-Sent Events channel over our own origin that always reaches users while the tab/PWA is open: NotificationHub (in-memory pub/sub), a /notifications/stream SSE endpoint (auth-gated, keep-alive pings, nginx no-buffer), and NotificationService now publishes each saved notification to the hub. Client updates the bell badge instantly, shows a toast, and fires a local OS notification via the service worker when permission is granted (no push server). Web Push stays as best-effort for closed-app reach. Verified end-to-end: login, open stream, broadcast, event delivered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 15:42:16 +03:30
parent d2a7b18cb3
commit 716433ce20
7 changed files with 343 additions and 5 deletions
@@ -29,7 +29,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="همکادر" />
</head>
<body>
<body data-unread="@unreadCount" data-authed="@(User.Identity?.IsAuthenticated == true ? "1" : "0")">
<header class="site-header">
<div class="container header-inner">
<a class="brand" asp-page="/Index">
@@ -40,7 +40,7 @@
@* Always-visible bell on mobile (next to the burger) so notifications stay one tap away *@
@if (User.Identity?.IsAuthenticated == true)
{
<a class="bell-mobile" asp-page="/Me/Notifications" title="اعلان‌ها">🔔@if (unreadCount > 0) {<span class="bell-badge">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
<a class="bell-mobile js-bell" asp-page="/Me/Notifications" title="اعلان‌ها">🔔@if (unreadCount > 0) {<span class="bell-badge">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
}
<input type="checkbox" id="nav-toggle" class="nav-toggle" hidden />
@@ -70,7 +70,7 @@
{
<a class="nav-action" asp-page="/Employer/Index">پنل کارفرما</a>
}
<a class="nav-action bell-inline" asp-page="/Me/Notifications" title="اعلان‌ها"><span class="bell-ico">🔔</span><span class="bell-label">اعلان‌ها</span>@if (unreadCount > 0) {<span class="bell-badge">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
<a class="nav-action bell-inline js-bell" asp-page="/Me/Notifications" title="اعلان‌ها"><span class="bell-ico">🔔</span><span class="bell-label">اعلان‌ها</span>@if (unreadCount > 0) {<span class="bell-badge">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
<a class="nav-action" asp-page="/Me/Index">پنل کارجو</a>
<form method="post" asp-page="/Account/Logout" style="display:contents;">
<button type="submit" class="btn btn-outline btn-sm">خروج</button>
@@ -103,12 +103,73 @@
</div>
</footer>
<div id="toast-host" class="toast-host" aria-live="polite"></div>
@* Register the PWA service worker (offline + push notifications). *@
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () { navigator.serviceWorker.register('/sw.js').catch(function () {}); });
}
</script>
@* Live in-app notifications over SSE (our own origin — works in Iran, no Google push).
Updates the bell badge, shows a toast, and fires a local OS notification when allowed. *@
@if (User.Identity?.IsAuthenticated == true)
{
<script>
(function () {
var faDigits = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
function toFa(s){ return String(s).replace(/[0-9]/g, function(d){ return faDigits[+d]; }); }
var count = parseInt(document.body.getAttribute('data-unread') || '0', 10) || 0;
function paintBell() {
document.querySelectorAll('.js-bell').forEach(function (bell) {
var badge = bell.querySelector('.bell-badge');
if (count > 0) {
if (!badge) { badge = document.createElement('span'); badge.className = 'bell-badge'; bell.appendChild(badge); }
badge.textContent = toFa(count > 99 ? '99+' : count);
} else if (badge) { badge.remove(); }
});
}
function toast(n) {
var host = document.getElementById('toast-host');
if (!host) return;
var el = document.createElement('a');
el.className = 'toast';
el.href = n.url || '/';
el.innerHTML = '<span class="toast-ico">🔔</span><span class="toast-body"><strong></strong><span></span></span>';
el.querySelector('strong').textContent = n.title || 'همکادر';
el.querySelector('.toast-body span').textContent = n.body || '';
host.appendChild(el);
requestAnimationFrame(function(){ el.classList.add('show'); });
setTimeout(function(){ el.classList.remove('show'); setTimeout(function(){ el.remove(); }, 300); }, 6000);
}
function osNotify(n) {
if (!('Notification' in window) || Notification.permission !== 'granted' || !navigator.serviceWorker) return;
navigator.serviceWorker.ready.then(function (reg) {
reg.showNotification(n.title || 'همکادر', {
body: n.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png',
dir: 'rtl', lang: 'fa', tag: n.url || '/', data: { url: n.url || '/' }
});
}).catch(function(){});
}
if (!('EventSource' in window)) return;
var es;
function connect() {
es = new EventSource('/notifications/stream');
es.addEventListener('notice', function (ev) {
var n; try { n = JSON.parse(ev.data); } catch (_) { return; }
count++; paintBell(); toast(n); osNotify(n);
});
// EventSource auto-reconnects on transient errors; nothing else needed.
}
connect();
})();
</script>
}
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
+45
View File
@@ -26,6 +26,7 @@ builder.Services.AddSingleton<CaptchaService>();
builder.Services.AddScoped<SubmissionGuard>();
builder.Services.AddScoped<NotificationService>();
builder.Services.AddScoped<PushNotifier>();
builder.Services.AddSingleton<NotificationHub>(); // in-memory SSE broker (live in-app notifications)
// Listing parser: heuristic now; swap for an LLM-backed IListingParser later.
builder.Services.AddSingleton<IListingParser, HeuristicListingParser>();
@@ -163,6 +164,50 @@ app.MapPost("/push/subscribe", async (PushSubscriptionDto dto, AppDbContext db,
return Results.Ok();
});
// Live notification stream (Server-Sent Events). Runs over our own origin, so it reaches
// users in Iran (unlike Web Push, which goes via the browser's blocked push service).
// The browser keeps this open while the tab/PWA is alive; the client updates the bell,
// shows a toast, and fires a local OS notification (no push server) when permission is on.
app.MapGet("/notifications/stream", async (HttpContext ctx, NotificationHub hub) =>
{
var claim = ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(claim, out var uid)) { ctx.Response.StatusCode = 401; return; }
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
ctx.Response.Headers["X-Accel-Buffering"] = "no"; // tell nginx not to buffer the stream
ctx.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpResponseBodyFeature>()?.DisableBuffering();
var (reader, unsubscribe) = hub.Subscribe(uid);
var ct = ctx.RequestAborted;
try
{
await ctx.Response.WriteAsync(": connected\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
while (!ct.IsCancellationRequested)
{
var readTask = reader.WaitToReadAsync(ct).AsTask();
var keepAlive = Task.Delay(TimeSpan.FromSeconds(25), ct);
if (await Task.WhenAny(readTask, keepAlive) == keepAlive)
{
await ctx.Response.WriteAsync(": ping\n\n", ct); // comment line keeps the connection warm
await ctx.Response.Body.FlushAsync(ct);
continue;
}
if (!await readTask) break;
while (reader.TryRead(out var notice))
{
var json = System.Text.Json.JsonSerializer.Serialize(notice);
await ctx.Response.WriteAsync($"event: notice\ndata: {json}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
}
}
catch (OperationCanceledException) { /* client disconnected — normal */ }
finally { unsubscribe(); }
}).RequireAuthorization();
// User-submitted report against a listing (abuse/fake/wrong info).
app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext vc,
[FromForm] string targetType, [FromForm] int targetId, [FromForm] string reason,
@@ -0,0 +1,61 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
namespace JobsMedical.Web.Services;
/// <summary>
/// In-memory pub/sub for live notifications over Server-Sent Events (SSE).
///
/// Why SSE instead of Web Push here: Web Push is delivered by the *browser vendor's*
/// push service (Chrome → Google FCM), which is filtered in Iran. SSE streams over our
/// OWN origin (hamkadr.ir), so it always reaches users while the tab/PWA is open — no
/// Google dependency. The client then shows an in-page toast and (if permission is
/// granted) a LOCAL OS notification via the service worker — also no push server.
///
/// Singleton, process-local. Each open tab = one subscription. Web Push stays as the
/// best-effort closed-app channel for users who can reach the push endpoint.
/// </summary>
public class NotificationHub
{
private readonly ConcurrentDictionary<int, ConcurrentDictionary<Guid, Channel<LiveNotice>>> _subs = new();
/// <summary>Open a stream for a user. Returns the reader + an unsubscribe callback.</summary>
public (ChannelReader<LiveNotice> Reader, Action Unsubscribe) Subscribe(int userId)
{
// Bounded + DropOldest so a stalled/slow client can never grow memory unbounded.
var ch = Channel.CreateBounded<LiveNotice>(new BoundedChannelOptions(50)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false,
});
var id = Guid.NewGuid();
var map = _subs.GetOrAdd(userId, _ => new());
map[id] = ch;
void Unsub()
{
if (_subs.TryGetValue(userId, out var m))
{
m.TryRemove(id, out _);
if (m.IsEmpty) _subs.TryRemove(userId, out _);
}
ch.Writer.TryComplete();
}
return (ch.Reader, Unsub);
}
/// <summary>Fan a notice out to every open tab of the given user (no-op if none online).</summary>
public void Publish(int userId, LiveNotice notice)
{
if (_subs.TryGetValue(userId, out var m))
foreach (var ch in m.Values)
ch.Writer.TryWrite(notice);
}
/// <summary>True if the user has at least one open SSE stream right now.</summary>
public bool IsOnline(int userId) => _subs.ContainsKey(userId);
}
/// <summary>Payload pushed to the browser over SSE (serialized to the event's data line).</summary>
public record LiveNotice(string title, string? body, string url);
@@ -13,12 +13,14 @@ public class NotificationService
{
private readonly AppDbContext _db;
private readonly PushNotifier _push;
private readonly NotificationHub _hub;
private readonly ILogger<NotificationService> _log;
public NotificationService(AppDbContext db, PushNotifier push, ILogger<NotificationService> log)
public NotificationService(AppDbContext db, PushNotifier push, NotificationHub hub, ILogger<NotificationService> log)
{
_db = db;
_push = push;
_hub = hub;
_log = log;
}
@@ -81,7 +83,13 @@ public class NotificationService
await _db.SaveChangesAsync();
_log.LogInformation("Notified {Count} users: {Title}", userIds.Count, title);
// Also push to the lock screen for users who subscribed (best-effort).
// Live: stream to any open tab/PWA over SSE (our own origin — works in Iran).
// The browser updates the bell instantly + shows a local toast/OS notification.
var notice = new LiveNotice(title, body, url);
foreach (var uid in userIds) _hub.Publish(uid, notice);
// Also push to the lock screen for users who subscribed (best-effort; Web Push
// depends on the browser's push service, which is filtered in Iran for Chromium).
try { await _push.PushToUsersAsync(userIds, title, body, url); }
catch (Exception ex) { _log.LogWarning(ex, "Web push fan-out failed"); }
}
+17
View File
@@ -88,6 +88,23 @@ a { color: inherit; text-decoration: none; }
.nav-toggle:checked ~ .nav-burger span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
.bell-mobile { position: relative; font-size: 20px; margin-inline-start: auto; line-height: 1; }
/* ---------- Live notification toasts (SSE) ---------- */
.toast-host {
position: fixed; inset-block-end: 16px; inset-inline-start: 16px; z-index: 200;
display: flex; flex-direction: column; gap: 10px; max-width: min(360px, calc(100vw - 32px));
}
.toast {
display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px;
background: var(--surface); border: 1px solid var(--line); border-inline-start: 4px solid var(--primary);
border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.16); color: var(--text);
opacity: 0; transform: translateY(12px); transition: opacity .25s, transform .25s;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast-ico { font-size: 18px; line-height: 1.4; }
.toast-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.toast-body strong { font-size: 14px; }
.toast-body span { font-size: 13px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---------- Buttons ---------- */
.btn {
display: inline-flex; align-items: center; gap: 6px;