How to handle localisation in live view?

Copy of https://github.com/elixir-cldr/cldr/issues/134

What’s the recommended way to use cldr / gettext with a liveview app? Right now I’m passign the locale into the session with Cldr.Plug.SetLocale plug and then set the locale again in the liveview’s mount/3 callback:

# in the router

plug Cldr.Plug.SetLocale,
  apps: [:cldr, :gettext],
  cldr: App.Cldr,
  from: [:query, :accept_language]

plug :put_locale_into_session

@doc false
def put_locale_into_session(conn, _opts) do
  %Cldr.LanguageTag{cldr_locale_name: locale} = conn.private.cldr_locale
   put_session(conn, "locale", locale)
end

live "/some_live_path", AppLive

# in the live view
def mount(_params, %{"locale" => locale}, socket) do
  App.Cldr.put_locale(locale)
  # ...
end

because otherwise it doesn’t seem to work correctly with the locales being different in the ssr’ed page and when the live view mounts: in ssr the locale is correct (e.g. en):

%Cldr.LanguageTag{
  canonical_locale_name: "en-Latn-US",
  cldr_locale_name: "en",
  extensions: %{},
  gettext_locale_name: "en",
  language: "en",
  language_subtags: [],
  language_variant: nil,
  locale: %{},
  private_use: [],
  rbnf_locale_name: "en",
  requested_locale_name: "en",
  script: "Latn",
  territory: "US",
  transform: %{}
}

but then in live view it becomes en-001 (the default):

%Cldr.LanguageTag{
  canonical_locale_name: "en-Latn-001",
  cldr_locale_name: "en-001",
  extensions: %{},
  gettext_locale_name: nil,
  language: "en",
  language_subtags: [],
  language_variant: nil,
  locale: %{},
  private_use: [],
  rbnf_locale_name: "en",
  requested_locale_name: "en-001",
  script: "Latn",
  territory: "001",
  transform: %{}
}

So I wonder if anyone has used liveview cldr and how they resolved this problem and if there’s a less repetitive approach (something like plug macro would be great).

shipped first production liveview last week - I ended up wrapping everything in a liveview that holds state with locale etc - and then render pages (liveview components) depending on the action - so it’s kinda a router as well…

to make matters interesting it’s cookieless - so no passing the locale in the session… had to do:

in my custom plug:
%{req_headers: req_headers} = conn

browser_accept_lang = 
  with {_key, value} <- Enum.find(req_headers, fn {key, _val} -> key == "accept-language" end),
      {:ok, cldr} <- Cldr.AcceptLanguage.best_match(value, MyApp.Cldr)
  do
    cldr.language  
  else
    _err -> "en"
  end

conn
|> assign(:browser_accept_lang, browser_accept_lang)
|> assign(:conn_lang, Cldr.Plug.SetLocale.get_cldr_locale(conn).language)

browser_accept_lang is the browser locale - conn_lang could be different if the user is on /:locale/some_page - the diff is needed to conditionally rewrite uris with /:locale

then in root template: (remember we have no session to pass things through easily)

    <meta name="waccept-lang" content="<%= assigns[:conn_lang] %>">
    <meta name="browser_accept_lang" content="<%= assigns[:browser_accept_lang] %>">

in the liveview: so that the initial render is in correct lang - and then socket connects with locale params, so we also have them on “the socket” - see below:

  @impl true
  def mount(_params, _session, socket) do
    # get js client connect params
    connect_params = get_connect_params(socket)
    {conn_assigns, _} = socket.private.assign_new

    js_lang =
      if connect_params do
        Map.get(connect_params, "conn_lang")
      else
        nil
      end

    lang = Map.get(conn_assigns, :conn_lang) || js_lang || "en"

    browser_accept_lang =
      if connect_params do
        Map.get(connect_params, "browser_accept_lang")
      else
        nil
      end

    browser_accept_lang = Map.get(conn_assigns, :browser_accept_lang) || browser_accept_lang || "en"

    latest_post = MyApp.Journal.list_posts(lang) |> List.first()

    {:ok,
     assign(socket, navigate_counter: 0, original_lang: lang, lang: lang, browser_accept_lang: browser_accept_lang, latest_post: latest_post)}
  end

in the js:

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let conn_lang = document.querySelector("meta[name='waccept-lang']").getAttribute("content")

let browser_accept_lang = document.querySelector("meta[name='browser_accept_lang']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken, conn_lang: conn_lang, browser_accept_lang: browser_accept_lang}})
2 Likes

Putting the locale in the session and later retrieving it in mount from the session works fine for me.

In the cookieless case, an option could also be using the uri in handle_params if your paths are localised: /en/something & /fr/... and extract it from there.

Or, passing it from the client-side using get_connect_params should work as well, I suppose, though I haven’t tried it.

One thing I’ve noticed is that if you are using live_patch to change the locale, handle_params will be called as per the flow, you retrieve the new locale, Gettext.put_locale etc., but your gettext calls won’t get re-rendered and diff’ed unless you forcefully track them. I do it like this for the moment: @locale && gettext('text to translate').

This is likely a side effect of liveviews being separate processes. Cldr.Plug.SetLocale sets the locale for the current process which, for a non-liveview request, is the process that executes the controller code.

Therefore, as @idi527 is doing, the idea of setting the locale for the liveview process in the mount/3 call seems one way to do it. Or more functionally, add the locale to socket.private.assign and use it directly.

I haven’t dived into liveview yet so anyone willing to partner with me on this, please let me know?

@whatyouhide, do you have some thoughts on how to apply the locale of the original HTTP request to a liveview context - I think this conversation relates to Gettext in the same way.

2 Likes

I think this is probably the right approach. Then perhaps its as simple as:

def mount(_params, %{"locale" => locale} = session, socket) do
  Cldr.put_locale(locale)

  ....
end

Again, not something I’ve dived into yet so guidance definitely appreciated - I’m all in to make this as simple as possible.

1 Like

Folks have already well covered the options here with great advice. I wanted to point our that LV master here has a small section in the docs:

5 Likes

Thanks Chris, glad we’re on the right track. I think I’ll add a helper to ex_cldr to make it easy to leverage the session in this way for liveview (since in the case of ex_cldr the locale it ultimately a struct and therefore some key munging will be required).

1 Like

The problem with this approach is that put_locale has then to be called in each live view mount which is what I’m trying to avoid in OP.

Since each cldr function takes a :locale parameter its not (and actually never) a requirement to use Cldr.put_locale/2. So as long as the locale is put in the session and/or the socket, it’s still available for use - but you would need to add the locale: locale option to MyApp.Cldr.Number.to_string/2 or other calls.

This seems like duplication as well …

There is no other option. :slight_smile: The docs linked by Chris also suggests a shared helper to be invoked on every LiveView, which is the approach I am also going with.

1 Like

For reference, https://github.com/phoenixframework/phoenix_live_view/blob/5a4e7f66c455815d020f831885ad9c3ec1fdfe04/lib/phoenix_live_view.ex#L787

1 Like