[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
View File
+146
View File
@@ -0,0 +1,146 @@
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (17ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT 1
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
info: Microsoft.EntityFrameworkCore.Migrations[20411]
Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
info: Microsoft.EntityFrameworkCore.Migrations[20405]
No migrations were applied. The database is already up to date.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (10ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT EXISTS (
SELECT 1
FROM "Cities" AS c)
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT a."Id", a."AiApiKey", a."AiAutoApprove", a."AiEnabled", a."AiEndpoint", a."AiModel", a."AiSystemPrompt", a."AutoIngestEnabled", a."AutoPublishMinConfidence", a."BaleBotToken", a."BaleEnabled", a."DemoMode", a."DivarCity", a."DivarEnabled", a."DivarQueries", a."IngestIntervalMinutes", a."MedjobsEnabled", a."MedjobsMaxAds", a."Mode", a."NeshanMapKey", a."PushEnabled", a."SmsApiKey", a."SmsEnabled", a."SmsSender", a."SmsTemplate", a."TelegramChannels", a."TelegramEnabled", a."UpdatedAt", a."VapidPrivateKey", a."VapidPublicKey", a."VapidSubject", a."WebsiteUrls", a."WebsitesEnabled"
FROM "AppSettings" AS a
WHERE a."Id" = 1
LIMIT 1
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT EXISTS (
SELECT 1
FROM "Facilities" AS f
WHERE f."IsDemo")
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (15ms) [Parameters=[@p='?' (DbType = Int32), @today='?' (DbType = Date)], CommandType='Text', CommandTimeout='30']
UPDATE "Shifts" AS s
SET "Status" = @p
WHERE s."Status" = 0 AND s."Date" < @today
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (6ms) [Parameters=[@p='?' (DbType = Int32), @jobCutoff='?' (DbType = DateTime)], CommandType='Text', CommandTimeout='30']
UPDATE "JobOpenings" AS j
SET "Status" = @p
WHERE j."Status" = 0 AND j."CreatedAt" < @jobCutoff
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5077
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: F:\Projects\JobsMedical\src\JobsMedical.Web
warn: Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware[3]
Failed to determine the https port for redirect.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@p='?' (DbType = Int32), @today='?' (DbType = Date)], CommandType='Text', CommandTimeout='30']
UPDATE "Shifts" AS s
SET "Status" = @p
WHERE s."Status" = 0 AND s."Date" < @today
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@p='?' (DbType = Int32), @jobCutoff='?' (DbType = DateTime)], CommandType='Text', CommandTimeout='30']
UPDATE "JobOpenings" AS j
SET "Status" = @p
WHERE j."Status" = 0 AND j."CreatedAt" < @jobCutoff
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT a."Id", a."AiApiKey", a."AiAutoApprove", a."AiEnabled", a."AiEndpoint", a."AiModel", a."AiSystemPrompt", a."AutoIngestEnabled", a."AutoPublishMinConfidence", a."BaleBotToken", a."BaleEnabled", a."DemoMode", a."DivarCity", a."DivarEnabled", a."DivarQueries", a."IngestIntervalMinutes", a."MedjobsEnabled", a."MedjobsMaxAds", a."Mode", a."NeshanMapKey", a."PushEnabled", a."SmsApiKey", a."SmsEnabled", a."SmsSender", a."SmsTemplate", a."TelegramChannels", a."TelegramEnabled", a."UpdatedAt", a."VapidPrivateKey", a."VapidPublicKey", a."VapidSubject", a."WebsiteUrls", a."WebsitesEnabled"
FROM "AppSettings" AS a
WHERE a."Id" = 1
LIMIT 1
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT a."Id", a."AiApiKey", a."AiAutoApprove", a."AiEnabled", a."AiEndpoint", a."AiModel", a."AiSystemPrompt", a."AutoIngestEnabled", a."AutoPublishMinConfidence", a."BaleBotToken", a."BaleEnabled", a."DemoMode", a."DivarCity", a."DivarEnabled", a."DivarQueries", a."IngestIntervalMinutes", a."MedjobsEnabled", a."MedjobsMaxAds", a."Mode", a."NeshanMapKey", a."PushEnabled", a."SmsApiKey", a."SmsEnabled", a."SmsSender", a."SmsTemplate", a."TelegramChannels", a."TelegramEnabled", a."UpdatedAt", a."VapidPrivateKey", a."VapidPublicKey", a."VapidSubject", a."WebsiteUrls", a."WebsitesEnabled"
FROM "AppSettings" AS a
WHERE a."Id" = 1
LIMIT 1
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT a."Id", a."AiApiKey", a."AiAutoApprove", a."AiEnabled", a."AiEndpoint", a."AiModel", a."AiSystemPrompt", a."AutoIngestEnabled", a."AutoPublishMinConfidence", a."BaleBotToken", a."BaleEnabled", a."DemoMode", a."DivarCity", a."DivarEnabled", a."DivarQueries", a."IngestIntervalMinutes", a."MedjobsEnabled", a."MedjobsMaxAds", a."Mode", a."NeshanMapKey", a."PushEnabled", a."SmsApiKey", a."SmsEnabled", a."SmsSender", a."SmsTemplate", a."TelegramChannels", a."TelegramEnabled", a."UpdatedAt", a."VapidPrivateKey", a."VapidPublicKey", a."VapidSubject", a."WebsiteUrls", a."WebsitesEnabled"
FROM "AppSettings" AS a
WHERE a."Id" = 1
LIMIT 1
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (3ms) [Parameters=[@phone='?'], CommandType='Text', CommandTimeout='30']
SELECT u."Id", u."BanReason", u."CreatedAt", u."FullName", u."IsBanned", u."IsPhoneVerified", u."Phone", u."Role"
FROM "Users" AS u
WHERE u."Phone" = @phone
LIMIT 1
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@vid='?'], CommandType='Text', CommandTimeout='30']
SELECT v."Id", v."CreatedAt", v."LastSeenAt", v."UserId"
FROM "Visitors" AS v
WHERE v."Id" = @vid
LIMIT 1
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (2ms) [Parameters=[@p0='?', @p1='?' (DbType = DateTime), @p2='?' (DbType = DateTime), @p3='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Visitors" ("Id", "CreatedAt", "LastSeenAt", "UserId")
VALUES (@p0, @p1, @p2, @p3);
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (2ms) [Parameters=[@userId='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SELECT count(*)::int
FROM "Notifications" AS n
WHERE n."UserId" = @userId AND NOT (n."IsRead")
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT u."Id"
FROM "Users" AS u
WHERE NOT (u."IsBanned")
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (33ms) [Parameters=[@p0='?', @p1='?' (DbType = DateTime), @p2='?' (DbType = Boolean), @p3='?', @p4='?', @p5='?' (DbType = Int32), @p6='?', @p7='?' (DbType = DateTime), @p8='?' (DbType = Boolean), @p9='?', @p10='?', @p11='?' (DbType = Int32), @p12='?', @p13='?' (DbType = DateTime), @p14='?' (DbType = Boolean), @p15='?', @p16='?', @p17='?' (DbType = Int32), @p18='?', @p19='?' (DbType = DateTime), @p20='?' (DbType = Boolean), @p21='?', @p22='?', @p23='?' (DbType = Int32), @p24='?', @p25='?' (DbType = DateTime), @p26='?' (DbType = Boolean), @p27='?', @p28='?', @p29='?' (DbType = Int32), @p30='?', @p31='?' (DbType = DateTime), @p32='?' (DbType = Boolean), @p33='?', @p34='?', @p35='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Notifications" ("Body", "CreatedAt", "IsRead", "Title", "Url", "UserId")
VALUES (@p0, @p1, @p2, @p3, @p4, @p5)
RETURNING "Id";
INSERT INTO "Notifications" ("Body", "CreatedAt", "IsRead", "Title", "Url", "UserId")
VALUES (@p6, @p7, @p8, @p9, @p10, @p11)
RETURNING "Id";
INSERT INTO "Notifications" ("Body", "CreatedAt", "IsRead", "Title", "Url", "UserId")
VALUES (@p12, @p13, @p14, @p15, @p16, @p17)
RETURNING "Id";
INSERT INTO "Notifications" ("Body", "CreatedAt", "IsRead", "Title", "Url", "UserId")
VALUES (@p18, @p19, @p20, @p21, @p22, @p23)
RETURNING "Id";
INSERT INTO "Notifications" ("Body", "CreatedAt", "IsRead", "Title", "Url", "UserId")
VALUES (@p24, @p25, @p26, @p27, @p28, @p29)
RETURNING "Id";
INSERT INTO "Notifications" ("Body", "CreatedAt", "IsRead", "Title", "Url", "UserId")
VALUES (@p30, @p31, @p32, @p33, @p34, @p35)
RETURNING "Id";
info: JobsMedical.Web.Services.NotificationService[0]
Notified 6 users: تست زنده
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT a."Id", a."AiApiKey", a."AiAutoApprove", a."AiEnabled", a."AiEndpoint", a."AiModel", a."AiSystemPrompt", a."AutoIngestEnabled", a."AutoPublishMinConfidence", a."BaleBotToken", a."BaleEnabled", a."DemoMode", a."DivarCity", a."DivarEnabled", a."DivarQueries", a."IngestIntervalMinutes", a."MedjobsEnabled", a."MedjobsMaxAds", a."Mode", a."NeshanMapKey", a."PushEnabled", a."SmsApiKey", a."SmsEnabled", a."SmsSender", a."SmsTemplate", a."TelegramChannels", a."TelegramEnabled", a."UpdatedAt", a."VapidPrivateKey", a."VapidPublicKey", a."VapidSubject", a."WebsiteUrls", a."WebsitesEnabled"
FROM "AppSettings" AS a
WHERE a."Id" = 1
LIMIT 1
@@ -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;