[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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user