Improve localized notifications and local time UX

This commit is contained in:
SimpleTest 2026-07-25 15:01:02 +03:00
parent 144167beae
commit ae663a745b
24 changed files with 2711 additions and 1709 deletions

View File

@ -345,7 +345,18 @@ const createAidMap = element => {
content.append(title) content.append(title)
const location = document.createElement("small") const location = document.createElement("small")
location.textContent = point.location || "" const capturedAt = point.captured_at ? new Date(point.captured_at) : null
const capturedAtLabel =
capturedAt && !Number.isNaN(capturedAt.getTime())
? new Intl.DateTimeFormat(undefined, {
dateStyle: "short",
timeStyle: "medium"
}).format(capturedAt)
: ""
location.textContent =
capturedAtLabel
? `${point.location_prefix || ""} ${capturedAtLabel}`.trim()
: point.location || ""
content.append(location) content.append(location)
if (point.id) { if (point.id) {
@ -1145,10 +1156,14 @@ export const Hooks = {
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) return if (Number.isNaN(date.getTime())) return
this.el.textContent = new Intl.DateTimeFormat(undefined, { const dateStyle = this.el.dataset.dateStyle || "medium"
dateStyle: "medium", const timeStyle = this.el.dataset.timeStyle || "short"
timeStyle: "short" const options = {}
}).format(date)
if (dateStyle !== "none") options.dateStyle = dateStyle
if (timeStyle !== "none") options.timeStyle = timeStyle
this.el.textContent = new Intl.DateTimeFormat(undefined, options).format(date)
this.el.title = date.toISOString() this.el.title = date.toISOString()
} }

View File

@ -35,6 +35,7 @@ test("nearby alerts, private notification inbox, preferences, and data export wo
const requestTitle = projectText("Nearby notification request", scope); const requestTitle = projectText("Nearby notification request", scope);
await gotoLiveView(subscriber.page, "/notifications"); await gotoLiveView(subscriber.page, "/notifications");
await subscriber.page.getByRole("link", { name: "Delivery settings" }).click();
const preferenceForm = subscriber.page.locator("#notification-preferences-form"); const preferenceForm = subscriber.page.locator("#notification-preferences-form");
await preferenceForm.getByLabel("Allow email notifications").check(); await preferenceForm.getByLabel("Allow email notifications").check();
await preferenceForm.getByLabel("Nearby requests by email").check(); await preferenceForm.getByLabel("Nearby requests by email").check();
@ -44,6 +45,7 @@ test("nearby alerts, private notification inbox, preferences, and data export wo
await preferenceForm.getByRole("button", { name: "Save preferences" }).click(); await preferenceForm.getByRole("button", { name: "Save preferences" }).click();
await expect(subscriber.page.getByText("Notification preferences saved.")).toBeVisible(); await expect(subscriber.page.getByText("Notification preferences saved.")).toBeVisible();
await subscriber.page.getByRole("link", { name: "Nearby alerts" }).click();
const subscriptionForm = subscriber.page.locator("#nearby-subscription-form"); const subscriptionForm = subscriber.page.locator("#nearby-subscription-form");
await subscriptionForm.getByLabel("Alert name").fill(alertName); await subscriptionForm.getByLabel("Alert name").fill(alertName);
await subscriptionForm.getByLabel("Private area label").fill(privateArea); await subscriptionForm.getByLabel("Private area label").fill(privateArea);
@ -96,6 +98,7 @@ test("nearby alerts, private notification inbox, preferences, and data export wo
await expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible(); await expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible();
const requestURL = requester.page.url(); const requestURL = requester.page.url();
await subscriber.page.getByRole("link", { name: "Inbox" }).click();
const inbox = subscriber.page const inbox = subscriber.page
.getByRole("heading", { name: "Inbox" }) .getByRole("heading", { name: "Inbox" })
.locator("xpath=ancestor::section"); .locator("xpath=ancestor::section");

View File

@ -886,7 +886,8 @@ defmodule WhoNeedHelp.Help do
request.id, request.id,
assignment.helper_id, assignment.helper_id,
"Request cancelled", "Request cancelled",
"The requester cancelled this request. Open Who Need Help for details." "The requester cancelled this request. Open Who Need Help for details.",
%{"event_variant" => "requester_cancelled"}
) )
end end
@ -908,7 +909,8 @@ defmodule WhoNeedHelp.Help do
request.id, request.id,
request.requester_id, request.requester_id,
"Helper withdrew after expiry", "Helper withdrew after expiry",
"The helper withdrew and the request is no longer open because it expired." "The helper withdrew and the request is no longer open because it expired.",
%{"event_variant" => "helper_withdrew_after_expiry"}
) )
end end

View File

@ -0,0 +1,153 @@
defmodule WhoNeedHelp.Notifications.Text do
@moduledoc """
Renders privacy-safe notification copy in the recipient's locale.
Notification rows retain their original title and body as a compatibility
fallback. Known product events are rendered from `kind` and structured
`data`, so the inbox and remote delivery can follow a user's current locale
without storing private conversation text or coordinates.
"""
use Gettext, backend: WhoNeedHelpWeb.Gettext
alias WhoNeedHelp.Notifications.Notification
@supported_locales ~w(en uk ru)
def render(%Notification{} = notification, locale) do
with_locale(locale, fn ->
notification
|> content()
|> Map.put(:locale, normalized_locale(locale))
end)
end
def localize(%Notification{} = notification, locale) do
%{title: title, body: body} = render(notification, locale)
%{notification | title: title, body: body}
end
defp content(%Notification{kind: :nearby_request}) do
%{
title: gettext("New help request nearby"),
body: gettext("A request matching one of your nearby-help alerts is available.")
}
end
defp content(%Notification{kind: :request_accepted}) do
%{
title: gettext("A helper responded"),
body: gettext("Open Who Need Help to see the request update.")
}
end
defp content(%Notification{kind: :request_reopened}) do
%{
title: gettext("Your request needs a new helper"),
body: gettext("The previous helper withdrew, so the request is open again.")
}
end
defp content(%Notification{
kind: :request_cancelled,
data: %{"event_variant" => "requester_cancelled"}
}) do
%{
title: gettext("Request cancelled"),
body: gettext("The requester cancelled this request. Open Who Need Help for details.")
}
end
defp content(%Notification{
kind: :request_cancelled,
data: %{"event_variant" => "helper_withdrew_after_expiry"}
}) do
%{
title: gettext("Request closed after the helper withdrew"),
body: gettext("The helper withdrew after the request expired, so it was not reopened.")
}
end
defp content(%Notification{kind: :request_cancelled}) do
%{
title: gettext("Request updated"),
body: gettext("Open Who Need Help to review the latest request status.")
}
end
defp content(%Notification{kind: :assignment_started}) do
%{
title: gettext("Your helper started"),
body: gettext("Open Who Need Help to follow the request status.")
}
end
defp content(%Notification{kind: :helper_arrived}) do
%{
title: gettext("Your helper arrived"),
body: gettext("Open Who Need Help to coordinate the handover safely.")
}
end
defp content(%Notification{kind: :handover_verified}) do
%{
title: gettext("Handover code verified"),
body: gettext("The helper verified the one-time handover code.")
}
end
defp content(%Notification{kind: :request_completed}) do
%{
title: gettext("Help completed"),
body: gettext("Both participants confirmed completion and the handover was verified.")
}
end
defp content(%Notification{kind: :message_created}) do
%{
title: gettext("New message"),
body: gettext("Open Who Need Help to read the conversation.")
}
end
defp content(%Notification{kind: :review_revealed}) do
%{
title: gettext("Your review is now visible"),
body: gettext("Both participants submitted a review. Open Who Need Help to see it.")
}
end
defp content(%Notification{
kind: :support_update,
data: %{"kind" => "report_updated", "status" => status}
}) do
%{
title: gettext("Your report was updated"),
body: gettext("The latest moderation status is %{status}.", status: status_label(status))
}
end
defp content(%Notification{kind: :support_update}) do
%{
title: gettext("Support request updated"),
body: gettext("Open Who Need Help to review the latest update.")
}
end
defp content(%Notification{} = notification) do
%{title: notification.title, body: notification.body}
end
defp status_label("open"), do: gettext("Open")
defp status_label("reviewing"), do: gettext("Reviewing")
defp status_label("resolved"), do: gettext("Resolved")
defp status_label("dismissed"), do: gettext("Dismissed")
defp status_label(status), do: status
defp with_locale(locale, fun) do
Gettext.with_locale(WhoNeedHelpWeb.Gettext, normalized_locale(locale), fun)
end
defp normalized_locale(locale) when locale in @supported_locales, do: locale
defp normalized_locale(_locale), do: "en"
end

View File

@ -66,7 +66,7 @@ defmodule WhoNeedHelp.Push do
}) })
end end
def enqueue_lifecycle(kind, event_id, request_id, recipient_id, title, body) def enqueue_lifecycle(kind, event_id, request_id, recipient_id, title, body, data \\ %{})
when is_atom(kind) do when is_atom(kind) do
Notifications.notify_user(recipient_id, %{ Notifications.notify_user(recipient_id, %{
kind: kind, kind: kind,
@ -74,7 +74,11 @@ defmodule WhoNeedHelp.Push do
body: body, body: body,
path: "/requests/#{request_id}", path: "/requests/#{request_id}",
idempotency_key: "#{kind}:#{event_id}:#{recipient_id}", idempotency_key: "#{kind}:#{event_id}:#{recipient_id}",
data: %{"kind" => to_string(kind), "request_id" => request_id} data:
Map.merge(
%{"kind" => to_string(kind), "request_id" => request_id},
Map.new(data, fn {key, value} -> {to_string(key), value} end)
)
}) })
end end

View File

@ -11,7 +11,7 @@ defmodule WhoNeedHelp.Push.DeviceDeliveryWorker do
] ]
alias WhoNeedHelp.Notifications alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Notifications.{Notification, PushDevice} alias WhoNeedHelp.Notifications.{Notification, PushDevice, Text}
alias WhoNeedHelp.Push alias WhoNeedHelp.Push
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
@ -19,7 +19,14 @@ defmodule WhoNeedHelp.Push.DeviceDeliveryWorker do
def perform(%Oban.Job{ def perform(%Oban.Job{
args: %{"notification_id" => notification_id, "device_id" => device_id} args: %{"notification_id" => notification_id, "device_id" => device_id}
}) do }) do
notification = Repo.get(Notification, notification_id) notification =
Notification
|> Repo.get(notification_id)
|> case do
nil -> nil
notification -> Repo.preload(notification, :user)
end
device = Repo.get(PushDevice, device_id) device = Repo.get(PushDevice, device_id)
cond do cond do
@ -33,7 +40,7 @@ defmodule WhoNeedHelp.Push.DeviceDeliveryWorker do
{:cancel, :ownership_mismatch} {:cancel, :ownership_mismatch}
true -> true ->
deliver(notification, device) deliver(Text.localize(notification, notification.user.locale), device)
end end
end end

View File

@ -6,7 +6,7 @@ defmodule WhoNeedHelp.Push.NotificationDispatchWorker do
unique: [period: :infinity, fields: [:args, :worker], keys: [:notification_id]] unique: [period: :infinity, fields: [:args, :worker], keys: [:notification_id]]
alias WhoNeedHelp.Notifications alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Notifications.{Notification, Preference} alias WhoNeedHelp.Notifications.{Notification, Preference, Text}
alias WhoNeedHelp.Push alias WhoNeedHelp.Push
alias WhoNeedHelp.Push.{DeliveryWorker, DeviceDeliveryWorker, NotificationEmailWorker} alias WhoNeedHelp.Push.{DeliveryWorker, DeviceDeliveryWorker, NotificationEmailWorker}
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
@ -14,8 +14,10 @@ defmodule WhoNeedHelp.Push.NotificationDispatchWorker do
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{args: %{"notification_id" => notification_id}} = job) do def perform(%Oban.Job{args: %{"notification_id" => notification_id}} = job) do
with %Notification{} = notification <- Repo.get(Notification, notification_id) do with %Notification{} = notification <- Repo.get(Notification, notification_id) do
notification = Repo.preload(notification, :user)
if job.attempt == 1, do: Notifications.broadcast_created(notification) if job.attempt == 1, do: Notifications.broadcast_created(notification)
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
localized_notification = Text.localize(notification, notification.user.locale)
preference = preference =
Repo.get_by(Preference, user_id: notification.user_id) || Repo.get_by(Preference, user_id: notification.user_id) ||
@ -38,7 +40,7 @@ defmodule WhoNeedHelp.Push.NotificationDispatchWorker do
{:snooze, seconds} {:snooze, seconds}
true -> true ->
case dispatch_push(notification, push_allowed?) do case dispatch_push(localized_notification, push_allowed?) do
:ok -> dispatch_email(notification, email_allowed?) :ok -> dispatch_email(notification, email_allowed?)
{:error, _reason} = error -> error {:error, _reason} = error -> error
end end

View File

@ -7,7 +7,7 @@ defmodule WhoNeedHelp.Push.NotificationEmailWorker do
unique: [period: :infinity, fields: [:args, :worker], keys: [:notification_id]] unique: [period: :infinity, fields: [:args, :worker], keys: [:notification_id]]
alias WhoNeedHelp.Notifications alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Notifications.{EmailNotifier, Notification, Preference} alias WhoNeedHelp.Notifications.{EmailNotifier, Notification, Preference, Text}
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
@impl Oban.Worker @impl Oban.Worker
@ -26,7 +26,9 @@ defmodule WhoNeedHelp.Push.NotificationEmailWorker do
%Preference{user_id: notification.user_id} %Preference{user_id: notification.user_id}
if Notifications.email_allowed?(preference, notification) do if Notifications.email_allowed?(preference, notification) do
case EmailNotifier.deliver(notification.user, notification) do localized_notification = Text.localize(notification, notification.user.locale)
case EmailNotifier.deliver(notification.user, localized_notification) do
{:ok, _metadata} -> :ok {:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
end end

View File

@ -399,7 +399,11 @@ defmodule WhoNeedHelp.Trust do
body: "The latest moderation status is #{report.status}.", body: "The latest moderation status is #{report.status}.",
path: "/reports", path: "/reports",
idempotency_key: "report-moderated:#{report.id}:#{report.status}", idempotency_key: "report-moderated:#{report.id}:#{report.status}",
data: %{"kind" => "report_updated", "report_id" => report.id} data: %{
"kind" => "report_updated",
"report_id" => report.id,
"status" => to_string(report.status)
}
}) })
result result

View File

@ -472,6 +472,39 @@ defmodule WhoNeedHelpWeb.CoreComponents do
""" """
end end
@doc """
Renders a timestamp in the browser's locale and time zone.
UTC remains the durable server value and the no-JavaScript fallback. The
`LocalTime` hook replaces only the visible text, so the semantic `time`
element keeps its machine-readable ISO timestamp.
"""
attr :id, :string, required: true
attr :value, :any, required: true
attr :class, :string, default: nil
attr :date_style, :string, values: ~w(full long medium short none), default: "medium"
attr :time_style, :string, values: ~w(full long medium short none), default: "short"
def local_time(assigns) do
assigns =
assigns
|> assign(:iso_value, DateTime.to_iso8601(assigns.value))
|> assign(:fallback, Calendar.strftime(assigns.value, "%Y-%m-%d %H:%M UTC"))
~H"""
<time
id={@id}
phx-hook="LocalTime"
datetime={@iso_value}
data-date-style={@date_style}
data-time-style={@time_style}
class={@class}
>
{@fallback}
</time>
"""
end
defp unused_input_name(name) do defp unused_input_name(name) do
case Regex.run(~r/^(.*)\[([^\[\]]+)\]$/, name, capture: :all_but_first) do case Regex.run(~r/^(.*)\[([^\[\]]+)\]$/, name, capture: :all_but_first) do
[prefix, field] -> "#{prefix}[_unused_#{field}]" [prefix, field] -> "#{prefix}[_unused_#{field}]"

View File

@ -28,10 +28,4 @@ defmodule WhoNeedHelpWeb.SupportHTML do
def event_actor_label(:staff), do: gettext("Support team") def event_actor_label(:staff), do: gettext("Support team")
def event_actor_label(:system), do: gettext("System") def event_actor_label(:system), do: gettext("System")
def event_actor_label(value), do: to_string(value) def event_actor_label(value), do: to_string(value)
def format_timestamp(%DateTime{} = value) do
Calendar.strftime(value, "%Y-%m-%d %H:%M UTC")
end
def format_timestamp(value), do: to_string(value)
end end

View File

@ -13,7 +13,12 @@
<h2 id="support-case-details" class="font-bold">{gettext("Your original request")}</h2> <h2 id="support-case-details" class="font-bold">{gettext("Your original request")}</h2>
<p class="mt-2 whitespace-pre-wrap">{@request.details}</p> <p class="mt-2 whitespace-pre-wrap">{@request.details}</p>
<time class="mt-3 block text-xs text-base-content/60"> <time class="mt-3 block text-xs text-base-content/60">
{format_timestamp(@request.inserted_at)} <.local_time
id={"support-request-created-#{@request.id}"}
value={@request.inserted_at}
date_style="short"
time_style="short"
/>
</time> </time>
</section> </section>
@ -43,7 +48,12 @@
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<strong class="text-sm">{message_sender_label(message.sender_role)}</strong> <strong class="text-sm">{message_sender_label(message.sender_role)}</strong>
<time class="text-xs text-base-content/55"> <time class="text-xs text-base-content/55">
{format_timestamp(message.inserted_at)} <.local_time
id={"support-message-created-#{message.id}"}
value={message.inserted_at}
date_style="short"
time_style="short"
/>
</time> </time>
</div> </div>
<p class="mt-2 whitespace-pre-wrap">{message.body}</p> <p class="mt-2 whitespace-pre-wrap">{message.body}</p>
@ -126,7 +136,13 @@
></span> ></span>
<p class="font-semibold">{status_label(event.to_status)}</p> <p class="font-semibold">{status_label(event.to_status)}</p>
<p class="mt-0.5 text-xs text-base-content/60"> <p class="mt-0.5 text-xs text-base-content/60">
{event_actor_label(event.actor_role)} · {format_timestamp(event.inserted_at)} {event_actor_label(event.actor_role)} ·
<.local_time
id={"support-event-created-#{event.id}"}
value={event.inserted_at}
date_style="short"
time_style="short"
/>
</p> </p>
</li> </li>
<li <li

View File

@ -256,7 +256,15 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
</div> </div>
<div class="mt-5 flex flex-wrap gap-x-5 gap-y-2 border-t border-base-300 pt-4 text-xs text-base-content/65"> <div class="mt-5 flex flex-wrap gap-x-5 gap-y-2 border-t border-base-300 pt-4 text-xs text-base-content/65">
<span>📍 {activity.location_label}</span> <span>📍 {activity.location_label}</span>
<span>🕒 {Calendar.strftime(activity.starts_at, "%d.%m.%Y, %H:%M UTC")}</span> <span class="inline-flex items-center gap-1">
<.icon name="hero-clock" class="size-4" />
<.local_time
id={"activity-start-#{activity.id}"}
value={activity.starts_at}
date_style="medium"
time_style="short"
/>
</span>
<span> <span>
{gettext("by %{name}", {gettext("by %{name}",
name: activity.creator.display_name || gettext("Community member") name: activity.creator.display_name || gettext("Community member")

View File

@ -377,13 +377,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
<div> <div>
<dt class="text-xs text-base-content/50">{gettext("Starts")}</dt> <dt class="text-xs text-base-content/50">{gettext("Starts")}</dt>
<dd class="font-semibold"> <dd class="font-semibold">
{Calendar.strftime(@activity.starts_at, "%d.%m.%Y, %H:%M UTC")} <.local_time
id={"activity-start-#{@activity.id}"}
value={@activity.starts_at}
/>
</dd> </dd>
</div> </div>
<div> <div>
<dt class="text-xs text-base-content/50">{gettext("Join requests close")}</dt> <dt class="text-xs text-base-content/50">{gettext("Join requests close")}</dt>
<dd class="font-semibold"> <dd class="font-semibold">
{Calendar.strftime(@activity.join_deadline, "%d.%m.%Y, %H:%M UTC")} <.local_time
id={"activity-join-deadline-#{@activity.id}"}
value={@activity.join_deadline}
/>
</dd> </dd>
</div> </div>
<div> <div>
@ -475,14 +481,11 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
{message.sender.display_name || gettext("Participant")} {message.sender.display_name || gettext("Participant")}
</.link> </.link>
</div> </div>
<time <.local_time
id={"activity-message-time-#{message.id}"} id={"activity-message-time-#{message.id}"}
phx-hook="LocalTime" value={message.inserted_at}
datetime={DateTime.to_iso8601(message.inserted_at)}
class="ml-auto text-xs text-base-content/50" class="ml-auto text-xs text-base-content/50"
> />
{Calendar.strftime(message.inserted_at, "%d.%m.%Y, %H:%M UTC")}
</time>
<button <button
:if={message.sender_id != @current_scope.user.id} :if={message.sender_id != @current_scope.user.id}
type="button" type="button"

View File

@ -4,7 +4,7 @@ defmodule WhoNeedHelpWeb.NotificationLive do
alias WhoNeedHelp.Catalog alias WhoNeedHelp.Catalog
alias WhoNeedHelp.Catalog.Category alias WhoNeedHelp.Catalog.Category
alias WhoNeedHelp.Notifications alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Notifications.NearbySubscription alias WhoNeedHelp.Notifications.{NearbySubscription, Text}
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -13,12 +13,24 @@ defmodule WhoNeedHelpWeb.NotificationLive do
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Notifications")) |> assign(:page_title, gettext("Notifications"))
|> assign(:section, "inbox")
|> assign(:categories, Catalog.list_categories()) |> assign(:categories, Catalog.list_categories())
|> assign(:web_push_public_key, Application.get_env(:who_need_help, :web_push_public_key)) |> assign(:web_push_public_key, Application.get_env(:who_need_help, :web_push_public_key))
|> assign(:subscription_form, new_subscription_form()) |> assign(:subscription_form, new_subscription_form())
|> load_notification_state()} |> load_notification_state()}
end end
@impl true
def handle_params(params, _uri, socket) do
section =
case params["section"] do
section when section in ~w(inbox alerts settings) -> section
_other -> "inbox"
end
{:noreply, assign(socket, :section, section)}
end
@impl true @impl true
def handle_info({:notification_created, _notification}, socket) do def handle_info({:notification_created, _notification}, socket) do
{:noreply, load_notifications(socket)} {:noreply, load_notifications(socket)}
@ -196,8 +208,8 @@ defmodule WhoNeedHelpWeb.NotificationLive do
defp coordinate_value(form, key), do: Map.get(form.params, key, "") defp coordinate_value(form, key), do: Map.get(form.params, key, "")
defp format_time(datetime) do defp notification_text(notification) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M UTC") Text.render(notification, Gettext.get_locale(WhoNeedHelpWeb.Gettext))
end end
@impl true @impl true
@ -218,7 +230,7 @@ defmodule WhoNeedHelpWeb.NotificationLive do
</p> </p>
</div> </div>
<button <button
:if={@unread_count > 0} :if={@section == "inbox" && @unread_count > 0}
type="button" type="button"
phx-click="mark-all-read" phx-click="mark-all-read"
class="btn btn-outline btn-sm" class="btn btn-outline btn-sm"
@ -227,7 +239,41 @@ defmodule WhoNeedHelpWeb.NotificationLive do
</button> </button>
</header> </header>
<section class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-6"> <nav
class="grid grid-cols-3 gap-1 rounded-2xl border border-base-300 bg-base-200 p-1"
aria-label={gettext("Notification sections")}
>
<.link
:for={
{section, label, icon} <- [
{"inbox", gettext("Inbox"), "hero-inbox"},
{"alerts", gettext("Nearby alerts"), "hero-map-pin"},
{"settings", gettext("Delivery settings"), "hero-adjustments-horizontal"}
]
}
patch={~p"/notifications?section=#{section}"}
aria-current={if @section == section, do: "page", else: nil}
class={[
"flex min-w-0 items-center justify-center gap-1.5 rounded-xl px-2 py-3 text-sm font-bold transition sm:gap-2",
@section == section && "bg-base-100 text-primary shadow-sm",
@section != section && "text-base-content/65 hover:bg-base-100/70"
]}
>
<.icon name={icon} class="size-5 shrink-0" />
<span class="truncate">{label}</span>
<span
:if={section == "inbox" && @unread_count > 0}
class="badge badge-primary badge-xs shrink-0"
>
{@unread_count}
</span>
</.link>
</nav>
<section
:if={@section == "inbox"}
class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-6"
>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<h2 class="text-xl font-black">{gettext("Inbox")}</h2> <h2 class="text-xl font-black">{gettext("Inbox")}</h2>
<span class="badge badge-success badge-outline"> <span class="badge badge-success badge-outline">
@ -260,13 +306,18 @@ defmodule WhoNeedHelpWeb.NotificationLive do
notification.read_at && "bg-transparent" notification.read_at && "bg-transparent"
]} /> ]} />
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="block">{notification.title}</span> <% text = notification_text(notification) %>
<span class="block">{text.title}</span>
<span class="mt-1 block text-sm font-normal text-base-content/65"> <span class="mt-1 block text-sm font-normal text-base-content/65">
{notification.body} {text.body}
</span>
<span class="mt-2 block text-xs font-normal text-base-content/45">
{format_time(notification.inserted_at)}
</span> </span>
<.local_time
id={"notification-time-#{notification.id}"}
value={notification.inserted_at}
date_style="short"
time_style="short"
class="mt-2 block text-xs font-normal text-base-content/45"
/>
</span> </span>
<.icon name="hero-chevron-right" class="mt-2 size-4 shrink-0 opacity-45" /> <.icon name="hero-chevron-right" class="mt-2 size-4 shrink-0 opacity-45" />
</button> </button>
@ -282,12 +333,12 @@ defmodule WhoNeedHelpWeb.NotificationLive do
</button> </button>
</section> </section>
<section class="grid gap-6 lg:grid-cols-[.9fr_1.1fr]"> <section :if={@section == "settings"}>
<div <div
id="push-device-registration" id="push-device-registration"
phx-hook="PushNotifications" phx-hook="PushNotifications"
data-vapid-public-key={@web_push_public_key || ""} data-vapid-public-key={@web_push_public_key || ""}
class="rounded-3xl border border-base-300 p-5 sm:p-6" class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-6"
> >
<h2 class="text-xl font-black">{gettext("Delivery channels and quiet hours")}</h2> <h2 class="text-xl font-black">{gettext("Delivery channels and quiet hours")}</h2>
<p class="mt-2 text-sm text-base-content/60"> <p class="mt-2 text-sm text-base-content/60">
@ -328,7 +379,13 @@ defmodule WhoNeedHelpWeb.NotificationLive do
<p class="truncate text-sm font-bold"> <p class="truncate text-sm font-bold">
{device.device_label || gettext("Web browser")} {device.device_label || gettext("Web browser")}
</p> </p>
<p class="text-xs text-base-content/55">{format_time(device.last_seen_at)}</p> <.local_time
id={"push-device-last-seen-#{device.id}"}
value={device.last_seen_at}
date_style="short"
time_style="short"
class="text-xs text-base-content/55"
/>
</div> </div>
<button <button
type="button" type="button"
@ -404,8 +461,12 @@ defmodule WhoNeedHelpWeb.NotificationLive do
<.button class="btn btn-primary w-full">{gettext("Save preferences")}</.button> <.button class="btn btn-primary w-full">{gettext("Save preferences")}</.button>
</.form> </.form>
</div> </div>
</section>
<div class="rounded-3xl border border-base-300 p-5 sm:p-6"> <section
:if={@section == "alerts"}
class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-6"
>
<h2 class="text-xl font-black">{gettext("Your nearby alerts")}</h2> <h2 class="text-xl font-black">{gettext("Your nearby alerts")}</h2>
<p class="mt-2 text-sm text-base-content/60"> <p class="mt-2 text-sm text-base-content/60">
{gettext("The saved center is private and is used only to find matching requests.")} {gettext("The saved center is private and is used only to find matching requests.")}
@ -465,10 +526,12 @@ defmodule WhoNeedHelpWeb.NotificationLive do
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
<section class="rounded-3xl border border-base-300 p-5 sm:p-6"> <section
:if={@section == "alerts"}
class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-6"
>
<h2 class="text-2xl font-black">{gettext("Create a nearby alert")}</h2> <h2 class="text-2xl font-black">{gettext("Create a nearby alert")}</h2>
<p class="mt-2 text-sm text-base-content/60"> <p class="mt-2 text-sm text-base-content/60">
{gettext("Choose an area, categories, urgency, and the times when you are available.")} {gettext("Choose an area, categories, urgency, and the times when you are available.")}

View File

@ -69,9 +69,13 @@ defmodule WhoNeedHelpWeb.ReportLive do
<h2 class="text-sm font-bold">{gettext("Moderation response")}</h2> <h2 class="text-sm font-bold">{gettext("Moderation response")}</h2>
<p class="mt-1 whitespace-pre-wrap text-sm">{report.resolution_note}</p> <p class="mt-1 whitespace-pre-wrap text-sm">{report.resolution_note}</p>
</section> </section>
<time class="mt-4 block text-xs text-base-content/55"> <.local_time
{format_timestamp(report.inserted_at)} id={"report-created-#{report.id}"}
</time> value={report.inserted_at}
date_style="short"
time_style="short"
class="mt-4 block text-xs text-base-content/55"
/>
</article> </article>
<div <div
@ -121,9 +125,4 @@ defmodule WhoNeedHelpWeb.ReportLive do
do: gettext("Activity message") do: gettext("Activity message")
defp target_label(_report), do: gettext("Reported content") defp target_label(_report), do: gettext("Reported content")
defp format_timestamp(%DateTime{} = value),
do: Calendar.strftime(value, "%Y-%m-%d %H:%M UTC")
defp format_timestamp(value), do: to_string(value)
end end

View File

@ -474,10 +474,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
name: request.requester.display_name || gettext("Community member") name: request.requester.display_name || gettext("Community member")
)} )}
</span> </span>
<span> <span class="inline-flex items-center gap-1">
{gettext("expires %{time}", {gettext("Expires")}
time: Calendar.strftime(request.expires_at, "%d.%m.%Y, %H:%M UTC") <.local_time
)} id={"request-expiry-#{request.id}"}
value={request.expires_at}
date_style="short"
time_style="short"
/>
</span> </span>
</div> </div>
</.link> </.link>

View File

@ -905,11 +905,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
exact: true, exact: true,
type: "live", type: "live",
title: live_location_title(user_id, request, assignment), title: live_location_title(user_id, request, assignment),
location: location_prefix: gettext("Last update:"),
gettext( captured_at: DateTime.to_iso8601(point.captured_at)
"Last update: %{time}",
time: Calendar.strftime(point.captured_at, "%d.%m.%Y, %H:%M:%S UTC")
)
} }
end) end)
end end
@ -1073,10 +1070,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
</div> </div>
<div> <div>
<span class="text-base-content/50">{gettext("Expires:")}</span> <span class="text-base-content/50">{gettext("Expires:")}</span>
{Calendar.strftime( <.local_time id={"request-expiry-#{@request.id}"} value={@request.expires_at} />
@request.expires_at,
"%d.%m.%Y, %H:%M UTC"
)}
</div> </div>
<div> <div>
<span class="text-base-content/50">{gettext("Location:")}</span> <span class="text-base-content/50">{gettext("Location:")}</span>
@ -1168,13 +1162,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
> >
<div class="chat-header flex items-center gap-2 text-xs"> <div class="chat-header flex items-center gap-2 text-xs">
<span>{message.sender.display_name}</span> <span>{message.sender.display_name}</span>
<time <.local_time
id={"message-time-#{message.id}"} id={"message-time-#{message.id}"}
phx-hook="LocalTime" value={message.inserted_at}
datetime={DateTime.to_iso8601(message.inserted_at)} date_style="short"
> time_style="short"
{Calendar.strftime(message.inserted_at, "%d.%m.%Y, %H:%M UTC")} />
</time>
</div> </div>
<div class="chat-bubble whitespace-pre-wrap break-words">{message.body}</div> <div class="chat-bubble whitespace-pre-wrap break-words">{message.body}</div>
<div <div

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,60 @@
defmodule WhoNeedHelp.Notifications.TextTest do
use ExUnit.Case, async: true
alias WhoNeedHelp.Notifications.{Notification, Text}
test "renders known lifecycle events in the selected locale" do
notification = notification(:request_accepted)
assert Text.render(notification, "en") == %{
title: "A helper responded",
body: "Open Who Need Help to see the request update.",
locale: "en"
}
assert Text.render(notification, "ru") == %{
title: "Помощник откликнулся",
body: "Откройте Who Need Help, чтобы посмотреть обновление заявки.",
locale: "ru"
}
assert Text.render(notification, "uk") == %{
title: "Помічник відгукнувся",
body: "Відкрийте Who Need Help, щоб переглянути оновлення запиту.",
locale: "uk"
}
end
test "renders structured variants without relying on stored English copy" do
notification =
notification(:request_cancelled, %{
"event_variant" => "helper_withdrew_after_expiry"
})
assert Text.render(notification, "ru").title == "Заявка закрыта после отказа помощника"
assert Text.render(notification, "uk").body ==
"Помічник відмовився після завершення строку запиту, тому його не відкрито повторно."
end
test "localizes structured moderation status and falls back to English locale" do
notification =
notification(:support_update, %{
"kind" => "report_updated",
"status" => "reviewing"
})
assert Text.render(notification, "ru").body == "Текущий статус модерации: На рассмотрении."
assert Text.render(notification, "uk").body == "Поточний статус модерації: На розгляді."
assert Text.render(notification, "unsupported").locale == "en"
end
defp notification(kind, data \\ %{}) do
%Notification{
kind: kind,
title: "Stored fallback title",
body: "Stored fallback body",
data: data
}
end
end

View File

@ -82,15 +82,22 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Notifications" assert html =~ "Notifications"
assert html =~ "Support request updated" assert html =~ "Support request updated"
assert html =~ "1 unread" assert html =~ "1 unread"
assert html =~ ~s(data-enable-label="Enable push on this device")
assert html =~ ~s(data-enabled-label="Push enabled on this device")
assert has_element?(view, "[data-disable-device='#{push_device.id}']")
view |> element("button[phx-click='mark-all-read']") |> render_click() view |> element("button[phx-click='mark-all-read']") |> render_click()
assert render(view) =~ "0 unread" assert render(view) =~ "0 unread"
assert Notifications.unread_count(scope) == 0 assert Notifications.unread_count(scope) == 0
assert Repo.get!(WhoNeedHelp.Notifications.Notification, notification.id).read_at assert Repo.get!(WhoNeedHelp.Notifications.Notification, notification.id).read_at
view
|> element("a[href='/notifications?section=settings']")
|> render_click()
assert_patch(view, ~p"/notifications?section=settings")
html = render(view)
assert html =~ ~s(data-enable-label="Enable push on this device")
assert html =~ ~s(data-enabled-label="Push enabled on this device")
assert has_element?(view, "[data-disable-device='#{push_device.id}']")
view view
|> form("#notification-preferences-form", %{ |> form("#notification-preferences-form", %{
"notification_preference" => %{ "notification_preference" => %{
@ -111,6 +118,12 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert render(view) =~ "Notification preferences saved." assert render(view) =~ "Notification preferences saved."
view
|> element("a[href='/notifications?section=alerts']")
|> render_click()
assert_patch(view, ~p"/notifications?section=alerts")
view view
|> form("#nearby-subscription-form", %{ |> form("#nearby-subscription-form", %{
"nearby_subscription" => %{ "nearby_subscription" => %{