[Notify] Add live in-app notifications over SSE (Iran-friendly)
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:
@@ -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>
|
||||
|
||||
@@ -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"); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user