Improve localized notifications and local time UX
This commit is contained in:
parent
144167beae
commit
ae663a745b
|
|
@ -345,7 +345,18 @@ const createAidMap = element => {
|
|||
content.append(title)
|
||||
|
||||
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)
|
||||
|
||||
if (point.id) {
|
||||
|
|
@ -1145,10 +1156,14 @@ export const Hooks = {
|
|||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return
|
||||
|
||||
this.el.textContent = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
}).format(date)
|
||||
const dateStyle = this.el.dataset.dateStyle || "medium"
|
||||
const timeStyle = this.el.dataset.timeStyle || "short"
|
||||
const options = {}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ test("nearby alerts, private notification inbox, preferences, and data export wo
|
|||
const requestTitle = projectText("Nearby notification request", scope);
|
||||
|
||||
await gotoLiveView(subscriber.page, "/notifications");
|
||||
await subscriber.page.getByRole("link", { name: "Delivery settings" }).click();
|
||||
const preferenceForm = subscriber.page.locator("#notification-preferences-form");
|
||||
await preferenceForm.getByLabel("Allow email notifications").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 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");
|
||||
await subscriptionForm.getByLabel("Alert name").fill(alertName);
|
||||
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();
|
||||
const requestURL = requester.page.url();
|
||||
|
||||
await subscriber.page.getByRole("link", { name: "Inbox" }).click();
|
||||
const inbox = subscriber.page
|
||||
.getByRole("heading", { name: "Inbox" })
|
||||
.locator("xpath=ancestor::section");
|
||||
|
|
|
|||
|
|
@ -886,7 +886,8 @@ defmodule WhoNeedHelp.Help do
|
|||
request.id,
|
||||
assignment.helper_id,
|
||||
"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
|
||||
|
||||
|
|
@ -908,7 +909,8 @@ defmodule WhoNeedHelp.Help do
|
|||
request.id,
|
||||
request.requester_id,
|
||||
"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
|
||||
|
||||
|
|
|
|||
153
lib/who_need_help/notifications/text.ex
Normal file
153
lib/who_need_help/notifications/text.ex
Normal 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
|
||||
|
|
@ -66,7 +66,7 @@ defmodule WhoNeedHelp.Push do
|
|||
})
|
||||
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
|
||||
Notifications.notify_user(recipient_id, %{
|
||||
kind: kind,
|
||||
|
|
@ -74,7 +74,11 @@ defmodule WhoNeedHelp.Push do
|
|||
body: body,
|
||||
path: "/requests/#{request_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
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ defmodule WhoNeedHelp.Push.DeviceDeliveryWorker do
|
|||
]
|
||||
|
||||
alias WhoNeedHelp.Notifications
|
||||
alias WhoNeedHelp.Notifications.{Notification, PushDevice}
|
||||
alias WhoNeedHelp.Notifications.{Notification, PushDevice, Text}
|
||||
alias WhoNeedHelp.Push
|
||||
alias WhoNeedHelp.Repo
|
||||
|
||||
|
|
@ -19,7 +19,14 @@ defmodule WhoNeedHelp.Push.DeviceDeliveryWorker do
|
|||
def perform(%Oban.Job{
|
||||
args: %{"notification_id" => notification_id, "device_id" => device_id}
|
||||
}) 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)
|
||||
|
||||
cond do
|
||||
|
|
@ -33,7 +40,7 @@ defmodule WhoNeedHelp.Push.DeviceDeliveryWorker do
|
|||
{:cancel, :ownership_mismatch}
|
||||
|
||||
true ->
|
||||
deliver(notification, device)
|
||||
deliver(Text.localize(notification, notification.user.locale), device)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ defmodule WhoNeedHelp.Push.NotificationDispatchWorker do
|
|||
unique: [period: :infinity, fields: [:args, :worker], keys: [:notification_id]]
|
||||
|
||||
alias WhoNeedHelp.Notifications
|
||||
alias WhoNeedHelp.Notifications.{Notification, Preference}
|
||||
alias WhoNeedHelp.Notifications.{Notification, Preference, Text}
|
||||
alias WhoNeedHelp.Push
|
||||
alias WhoNeedHelp.Push.{DeliveryWorker, DeviceDeliveryWorker, NotificationEmailWorker}
|
||||
alias WhoNeedHelp.Repo
|
||||
|
|
@ -14,8 +14,10 @@ defmodule WhoNeedHelp.Push.NotificationDispatchWorker do
|
|||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"notification_id" => notification_id}} = job) do
|
||||
with %Notification{} = notification <- Repo.get(Notification, notification_id) do
|
||||
notification = Repo.preload(notification, :user)
|
||||
if job.attempt == 1, do: Notifications.broadcast_created(notification)
|
||||
now = DateTime.utc_now(:second)
|
||||
localized_notification = Text.localize(notification, notification.user.locale)
|
||||
|
||||
preference =
|
||||
Repo.get_by(Preference, user_id: notification.user_id) ||
|
||||
|
|
@ -38,7 +40,7 @@ defmodule WhoNeedHelp.Push.NotificationDispatchWorker do
|
|||
{:snooze, seconds}
|
||||
|
||||
true ->
|
||||
case dispatch_push(notification, push_allowed?) do
|
||||
case dispatch_push(localized_notification, push_allowed?) do
|
||||
:ok -> dispatch_email(notification, email_allowed?)
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ defmodule WhoNeedHelp.Push.NotificationEmailWorker do
|
|||
unique: [period: :infinity, fields: [:args, :worker], keys: [:notification_id]]
|
||||
|
||||
alias WhoNeedHelp.Notifications
|
||||
alias WhoNeedHelp.Notifications.{EmailNotifier, Notification, Preference}
|
||||
alias WhoNeedHelp.Notifications.{EmailNotifier, Notification, Preference, Text}
|
||||
alias WhoNeedHelp.Repo
|
||||
|
||||
@impl Oban.Worker
|
||||
|
|
@ -26,7 +26,9 @@ defmodule WhoNeedHelp.Push.NotificationEmailWorker do
|
|||
%Preference{user_id: notification.user_id}
|
||||
|
||||
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
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -399,7 +399,11 @@ defmodule WhoNeedHelp.Trust do
|
|||
body: "The latest moderation status is #{report.status}.",
|
||||
path: "/reports",
|
||||
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
|
||||
|
|
|
|||
|
|
@ -472,6 +472,39 @@ defmodule WhoNeedHelpWeb.CoreComponents do
|
|||
"""
|
||||
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
|
||||
case Regex.run(~r/^(.*)\[([^\[\]]+)\]$/, name, capture: :all_but_first) do
|
||||
[prefix, field] -> "#{prefix}[_unused_#{field}]"
|
||||
|
|
|
|||
|
|
@ -28,10 +28,4 @@ defmodule WhoNeedHelpWeb.SupportHTML do
|
|||
def event_actor_label(:staff), do: gettext("Support team")
|
||||
def event_actor_label(:system), do: gettext("System")
|
||||
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
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@
|
|||
<h2 id="support-case-details" class="font-bold">{gettext("Your original request")}</h2>
|
||||
<p class="mt-2 whitespace-pre-wrap">{@request.details}</p>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
|
@ -43,7 +48,12 @@
|
|||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<strong class="text-sm">{message_sender_label(message.sender_role)}</strong>
|
||||
<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>
|
||||
</div>
|
||||
<p class="mt-2 whitespace-pre-wrap">{message.body}</p>
|
||||
|
|
@ -126,7 +136,13 @@
|
|||
></span>
|
||||
<p class="font-semibold">{status_label(event.to_status)}</p>
|
||||
<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>
|
||||
</li>
|
||||
<li
|
||||
|
|
|
|||
|
|
@ -256,7 +256,15 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
</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">
|
||||
<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>
|
||||
{gettext("by %{name}",
|
||||
name: activity.creator.display_name || gettext("Community member")
|
||||
|
|
|
|||
|
|
@ -377,13 +377,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
<div>
|
||||
<dt class="text-xs text-base-content/50">{gettext("Starts")}</dt>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/50">{gettext("Join requests close")}</dt>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -475,14 +481,11 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
{message.sender.display_name || gettext("Participant")}
|
||||
</.link>
|
||||
</div>
|
||||
<time
|
||||
<.local_time
|
||||
id={"activity-message-time-#{message.id}"}
|
||||
phx-hook="LocalTime"
|
||||
datetime={DateTime.to_iso8601(message.inserted_at)}
|
||||
value={message.inserted_at}
|
||||
class="ml-auto text-xs text-base-content/50"
|
||||
>
|
||||
{Calendar.strftime(message.inserted_at, "%d.%m.%Y, %H:%M UTC")}
|
||||
</time>
|
||||
/>
|
||||
<button
|
||||
:if={message.sender_id != @current_scope.user.id}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule WhoNeedHelpWeb.NotificationLive do
|
|||
alias WhoNeedHelp.Catalog
|
||||
alias WhoNeedHelp.Catalog.Category
|
||||
alias WhoNeedHelp.Notifications
|
||||
alias WhoNeedHelp.Notifications.NearbySubscription
|
||||
alias WhoNeedHelp.Notifications.{NearbySubscription, Text}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
|
|
@ -13,12 +13,24 @@ defmodule WhoNeedHelpWeb.NotificationLive do
|
|||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Notifications"))
|
||||
|> assign(:section, "inbox")
|
||||
|> assign(:categories, Catalog.list_categories())
|
||||
|> assign(:web_push_public_key, Application.get_env(:who_need_help, :web_push_public_key))
|
||||
|> assign(:subscription_form, new_subscription_form())
|
||||
|> load_notification_state()}
|
||||
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
|
||||
def handle_info({:notification_created, _notification}, socket) do
|
||||
{:noreply, load_notifications(socket)}
|
||||
|
|
@ -196,8 +208,8 @@ defmodule WhoNeedHelpWeb.NotificationLive do
|
|||
|
||||
defp coordinate_value(form, key), do: Map.get(form.params, key, "")
|
||||
|
||||
defp format_time(datetime) do
|
||||
Calendar.strftime(datetime, "%Y-%m-%d %H:%M UTC")
|
||||
defp notification_text(notification) do
|
||||
Text.render(notification, Gettext.get_locale(WhoNeedHelpWeb.Gettext))
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -218,7 +230,7 @@ defmodule WhoNeedHelpWeb.NotificationLive do
|
|||
</p>
|
||||
</div>
|
||||
<button
|
||||
:if={@unread_count > 0}
|
||||
:if={@section == "inbox" && @unread_count > 0}
|
||||
type="button"
|
||||
phx-click="mark-all-read"
|
||||
class="btn btn-outline btn-sm"
|
||||
|
|
@ -227,7 +239,41 @@ defmodule WhoNeedHelpWeb.NotificationLive do
|
|||
</button>
|
||||
</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">
|
||||
<h2 class="text-xl font-black">{gettext("Inbox")}</h2>
|
||||
<span class="badge badge-success badge-outline">
|
||||
|
|
@ -260,13 +306,18 @@ defmodule WhoNeedHelpWeb.NotificationLive do
|
|||
notification.read_at && "bg-transparent"
|
||||
]} />
|
||||
<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">
|
||||
{notification.body}
|
||||
</span>
|
||||
<span class="mt-2 block text-xs font-normal text-base-content/45">
|
||||
{format_time(notification.inserted_at)}
|
||||
{text.body}
|
||||
</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>
|
||||
<.icon name="hero-chevron-right" class="mt-2 size-4 shrink-0 opacity-45" />
|
||||
</button>
|
||||
|
|
@ -282,12 +333,12 @@ defmodule WhoNeedHelpWeb.NotificationLive do
|
|||
</button>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 lg:grid-cols-[.9fr_1.1fr]">
|
||||
<section :if={@section == "settings"}>
|
||||
<div
|
||||
id="push-device-registration"
|
||||
phx-hook="PushNotifications"
|
||||
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>
|
||||
<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">
|
||||
{device.device_label || gettext("Web browser")}
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -404,71 +461,77 @@ defmodule WhoNeedHelpWeb.NotificationLive do
|
|||
<.button class="btn btn-primary w-full">{gettext("Save preferences")}</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rounded-3xl border border-base-300 p-5 sm:p-6">
|
||||
<h2 class="text-xl font-black">{gettext("Your nearby alerts")}</h2>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
{gettext("The saved center is private and is used only to find matching requests.")}
|
||||
</p>
|
||||
<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>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
{gettext("The saved center is private and is used only to find matching requests.")}
|
||||
</p>
|
||||
|
||||
<div :if={@subscriptions == []} class="mt-4 rounded-2xl bg-base-200 p-4 text-sm">
|
||||
{gettext("No nearby alerts configured yet.")}
|
||||
</div>
|
||||
<div :if={@subscriptions == []} class="mt-4 rounded-2xl bg-base-200 p-4 text-sm">
|
||||
{gettext("No nearby alerts configured yet.")}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<div
|
||||
:for={subscription <- @subscriptions}
|
||||
class="rounded-2xl border border-base-300 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-bold">{subscription.name}</h3>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
{subscription.location_label} · {div(subscription.radius_meters, 1_000)} km
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
<span :if={subscription.push_enabled} class="badge badge-sm badge-outline">
|
||||
{gettext("Push")}
|
||||
</span>
|
||||
<span :if={subscription.email_enabled} class="badge badge-sm badge-outline">
|
||||
{gettext("Email")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div
|
||||
:for={subscription <- @subscriptions}
|
||||
class="rounded-2xl border border-base-300 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-bold">{subscription.name}</h3>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
{subscription.location_label} · {div(subscription.radius_meters, 1_000)} km
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
<span :if={subscription.push_enabled} class="badge badge-sm badge-outline">
|
||||
{gettext("Push")}
|
||||
</span>
|
||||
<span :if={subscription.email_enabled} class="badge badge-sm badge-outline">
|
||||
{gettext("Email")}
|
||||
</span>
|
||||
</div>
|
||||
<span class={[
|
||||
"badge",
|
||||
subscription.active && "badge-success",
|
||||
!subscription.active && "badge-ghost"
|
||||
]}>
|
||||
{if subscription.active, do: gettext("Active"), else: gettext("Paused")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle-subscription"
|
||||
phx-value-id={subscription.id}
|
||||
phx-value-active={to_string(!subscription.active)}
|
||||
class="btn btn-outline btn-xs"
|
||||
>
|
||||
{if subscription.active, do: gettext("Pause"), else: gettext("Resume")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete-subscription"
|
||||
phx-value-id={subscription.id}
|
||||
data-confirm={gettext("Delete this nearby alert?")}
|
||||
class="btn btn-outline btn-xs border-error text-base-content"
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</button>
|
||||
</div>
|
||||
<span class={[
|
||||
"badge",
|
||||
subscription.active && "badge-success",
|
||||
!subscription.active && "badge-ghost"
|
||||
]}>
|
||||
{if subscription.active, do: gettext("Active"), else: gettext("Paused")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle-subscription"
|
||||
phx-value-id={subscription.id}
|
||||
phx-value-active={to_string(!subscription.active)}
|
||||
class="btn btn-outline btn-xs"
|
||||
>
|
||||
{if subscription.active, do: gettext("Pause"), else: gettext("Resume")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete-subscription"
|
||||
phx-value-id={subscription.id}
|
||||
data-confirm={gettext("Delete this nearby alert?")}
|
||||
class="btn btn-outline btn-xs border-error text-base-content"
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
{gettext("Choose an area, categories, urgency, and the times when you are available.")}
|
||||
|
|
|
|||
|
|
@ -69,9 +69,13 @@ defmodule WhoNeedHelpWeb.ReportLive do
|
|||
<h2 class="text-sm font-bold">{gettext("Moderation response")}</h2>
|
||||
<p class="mt-1 whitespace-pre-wrap text-sm">{report.resolution_note}</p>
|
||||
</section>
|
||||
<time class="mt-4 block text-xs text-base-content/55">
|
||||
{format_timestamp(report.inserted_at)}
|
||||
</time>
|
||||
<.local_time
|
||||
id={"report-created-#{report.id}"}
|
||||
value={report.inserted_at}
|
||||
date_style="short"
|
||||
time_style="short"
|
||||
class="mt-4 block text-xs text-base-content/55"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<div
|
||||
|
|
@ -121,9 +125,4 @@ defmodule WhoNeedHelpWeb.ReportLive do
|
|||
do: gettext("Activity message")
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -474,10 +474,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
name: request.requester.display_name || gettext("Community member")
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{gettext("expires %{time}",
|
||||
time: Calendar.strftime(request.expires_at, "%d.%m.%Y, %H:%M UTC")
|
||||
)}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{gettext("Expires")}
|
||||
<.local_time
|
||||
id={"request-expiry-#{request.id}"}
|
||||
value={request.expires_at}
|
||||
date_style="short"
|
||||
time_style="short"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</.link>
|
||||
|
|
|
|||
|
|
@ -905,11 +905,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
exact: true,
|
||||
type: "live",
|
||||
title: live_location_title(user_id, request, assignment),
|
||||
location:
|
||||
gettext(
|
||||
"Last update: %{time}",
|
||||
time: Calendar.strftime(point.captured_at, "%d.%m.%Y, %H:%M:%S UTC")
|
||||
)
|
||||
location_prefix: gettext("Last update:"),
|
||||
captured_at: DateTime.to_iso8601(point.captured_at)
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
|
@ -1073,10 +1070,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/50">{gettext("Expires:")}</span>
|
||||
{Calendar.strftime(
|
||||
@request.expires_at,
|
||||
"%d.%m.%Y, %H:%M UTC"
|
||||
)}
|
||||
<.local_time id={"request-expiry-#{@request.id}"} value={@request.expires_at} />
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<span>{message.sender.display_name}</span>
|
||||
<time
|
||||
<.local_time
|
||||
id={"message-time-#{message.id}"}
|
||||
phx-hook="LocalTime"
|
||||
datetime={DateTime.to_iso8601(message.inserted_at)}
|
||||
>
|
||||
{Calendar.strftime(message.inserted_at, "%d.%m.%Y, %H:%M UTC")}
|
||||
</time>
|
||||
value={message.inserted_at}
|
||||
date_style="short"
|
||||
time_style="short"
|
||||
/>
|
||||
</div>
|
||||
<div class="chat-bubble whitespace-pre-wrap break-words">{message.body}</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
60
test/who_need_help/notifications/text_test.exs
Normal file
60
test/who_need_help/notifications/text_test.exs
Normal 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
|
||||
|
|
@ -82,15 +82,22 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
assert html =~ "Notifications"
|
||||
assert html =~ "Support request updated"
|
||||
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()
|
||||
assert render(view) =~ "0 unread"
|
||||
assert Notifications.unread_count(scope) == 0
|
||||
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
|
||||
|> form("#notification-preferences-form", %{
|
||||
"notification_preference" => %{
|
||||
|
|
@ -111,6 +118,12 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
|
||||
assert render(view) =~ "Notification preferences saved."
|
||||
|
||||
view
|
||||
|> element("a[href='/notifications?section=alerts']")
|
||||
|> render_click()
|
||||
|
||||
assert_patch(view, ~p"/notifications?section=alerts")
|
||||
|
||||
view
|
||||
|> form("#nearby-subscription-form", %{
|
||||
"nearby_subscription" => %{
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user