From ed756f96af4ab62f8da2459570f3856bd5be7e98 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Wed, 24 Jun 2026 19:34:41 +0200 Subject: [PATCH 1/2] feat: clickable attachment links, clean payload display, dedup SSE init Receiver feed improvements: - show the message's inner payload (text), not the raw stored JSON; attachment messages render a clickable link - attachment URLs are rewritten to a browser-reachable host: add chatli_public_path config (the internal chatli:8090 host isn't resolvable from the browser); normalize stored URLs at display time so existing rows work too - message_init replaces (#messages inner) instead of appending, so an SSE reconnect re-syncs instead of duplicating the whole batch Verified in headless Chrome. --- config/sys.config | 1 + config/sys.docker.config | 1 + priv/assets/css/ldf.css | 2 + src/controllers/ldf_receiver_controller.erl | 2 +- src/controllers/ldf_www_controller.erl | 51 ++++++++++++++++----- src/views/message_row.dtl | 2 +- 6 files changed, 46 insertions(+), 13 deletions(-) diff --git a/config/sys.config b/config/sys.config index b2b3dae..eae8bef 100644 --- a/config/sys.config +++ b/config/sys.config @@ -37,6 +37,7 @@ ]}, {ldf, [ {chatli_path, <<"http://chatli:8090/v1">>}, + {chatli_public_path, <<"http://localhost:8090/v1">>}, {ldf_callback, <<"http://ldf:8095/receiver">>} ]} %% Please change your app.src-file instead if you intend to add app-specific configurations diff --git a/config/sys.docker.config b/config/sys.docker.config index 64ec270..3001e25 100644 --- a/config/sys.docker.config +++ b/config/sys.docker.config @@ -38,6 +38,7 @@ ]}, {ldf, [ {chatli_path, <<"http://localhost:8090/v1">>}, + {chatli_public_path, <<"http://localhost:8090/v1">>}, {ldf_callback, <<"http://localhost:8095/receiver">>} ]} %% Please change your app.src-file instead if you intend to add app-specific configurations diff --git a/priv/assets/css/ldf.css b/priv/assets/css/ldf.css index c97c11a..b576df3 100644 --- a/priv/assets/css/ldf.css +++ b/priv/assets/css/ldf.css @@ -126,6 +126,8 @@ tbody tr:last-child td { border-bottom: none; } /* live message feed */ .feed td:first-child { color: var(--faint); white-space: nowrap; width: 1%; } .msg { font-family: var(--mono); color: var(--text); word-break: break-word; } +.msg a { color: var(--live); text-decoration: none; border-bottom: 1px dotted var(--live-dim); word-break: break-all; } +.msg a:hover { border-bottom-style: solid; } @keyframes flashin { 0% { background: rgba(46,230,166,.16); transform: translateY(-3px); opacity: 0; } 100% { background: transparent; transform: none; opacity: 1; } diff --git a/src/controllers/ldf_receiver_controller.erl b/src/controllers/ldf_receiver_controller.erl index 9f2d674..87eb2d2 100644 --- a/src/controllers/ldf_receiver_controller.erl +++ b/src/controllers/ldf_receiver_controller.erl @@ -8,7 +8,7 @@ create_message(#{json := #{<<"id">> := MessageId} = Json}) -> Message = case Json of #{<<"payload">> := #{<<"url">> := Url} = Payload} -> - {ok, ChatliPath} = application:get_env(ldf, chatli_path), + {ok, ChatliPath} = application:get_env(ldf, chatli_public_path), Payload2 = maps:update(<<"url">>, <>, Payload), maps:update(<<"payload">>, Payload2, Json); _ -> diff --git a/src/controllers/ldf_www_controller.erl b/src/controllers/ldf_www_controller.erl index b7ecd3e..60ef173 100644 --- a/src/controllers/ldf_www_controller.erl +++ b/src/controllers/ldf_www_controller.erl @@ -78,7 +78,7 @@ submit_history(Req0) -> %% --- published by the message API on each intercepted message ------------- publish_message(MessageId, Payload) -> - Row = render(message_row_dtl, [{ref, ref(MessageId)}, {payload, Payload}, {new, true}]), + Row = render(message_row_dtl, row_vars(ref(MessageId), Payload, true)), datastar_nova:publish(?MESSAGES, [ datastar:patch_elements(~"", #{selector => ~"#feed-empty", mode => remove}), datastar:patch_elements(Row, #{selector => ~"#messages", mode => append}) @@ -86,21 +86,50 @@ publish_message(MessageId, Payload) -> %% --- helpers -------------------------------------------------------------- +%% Replace (not append) the whole list on connect, so reconnects re-sync +%% instead of duplicating the batch. Live messages still append. message_init([]) -> - []; + [datastar:patch_elements(feed_empty(), #{selector => ~"#messages", mode => inner})]; message_init(Msgs) -> Rows = [ - render(message_row_dtl, [ - {ref, ref(maps:get(message_id, M, ~""))}, - {payload, maps:get(payload, M, ~"")}, - {new, false} - ]) + render( + message_row_dtl, + row_vars(ref(maps:get(message_id, M, ~"")), maps:get(payload, M, ~""), false) + ) || M <- Msgs ], - [ - datastar:patch_elements(~"", #{selector => ~"#feed-empty", mode => remove}), - datastar:patch_elements(Rows, #{selector => ~"#messages", mode => append}) - ]. + [datastar:patch_elements(Rows, #{selector => ~"#messages", mode => inner})]. + +feed_empty() -> + ~"Awaiting interception". + +%% The stored payload is the full encoded message; show its inner payload - +%% an attachment URL becomes a clickable link, plain text stays text. +row_vars(Ref, StoredPayload, New) -> + case display(StoredPayload) of + {link, Url} -> [{ref, Ref}, {new, New}, {link, Url}, {text, ~""}]; + {text, Text} -> [{ref, Ref}, {new, New}, {link, false}, {text, Text}] + end. + +display(StoredPayload) -> + try json:decode(StoredPayload) of + #{~"payload" := #{~"url" := Url}} when is_binary(Url) -> {link, public_url(Url)}; + #{~"payload" := Text} when is_binary(Text) -> {text, Text}; + _ -> {text, StoredPayload} + catch + _:_ -> {text, StoredPayload} + end. + +%% Rewrite an attachment URL onto the browser-reachable chatli host, +%% regardless of which host got baked in when it was stored. +public_url(Url) -> + case binary:split(Url, ~"/chat/") of + [_Host, Rest] -> + {ok, Public} = application:get_env(ldf, chatli_public_path), + <>; + _ -> + Url + end. broadcast_listeners() -> {ok, Listeners} = ldf_db:get_all_li(), diff --git a/src/views/message_row.dtl b/src/views/message_row.dtl index a5af2f3..73c29b1 100644 --- a/src/views/message_row.dtl +++ b/src/views/message_row.dtl @@ -1 +1 @@ -{{ ref }}{{ payload }} +{{ ref }}{% if link %}{{ link }}{% else %}{{ text }}{% endif %} From 07747bf8fa42c739f074b1c04a0089ae1daeb163 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Wed, 24 Jun 2026 19:39:09 +0200 Subject: [PATCH 2/2] fix: rename display/1 to avoid erlang:display BIF clash (elvis no_debug_call) --- src/controllers/ldf_www_controller.erl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/ldf_www_controller.erl b/src/controllers/ldf_www_controller.erl index 60ef173..f527ab9 100644 --- a/src/controllers/ldf_www_controller.erl +++ b/src/controllers/ldf_www_controller.erl @@ -106,12 +106,12 @@ feed_empty() -> %% The stored payload is the full encoded message; show its inner payload - %% an attachment URL becomes a clickable link, plain text stays text. row_vars(Ref, StoredPayload, New) -> - case display(StoredPayload) of + case display_payload(StoredPayload) of {link, Url} -> [{ref, Ref}, {new, New}, {link, Url}, {text, ~""}]; {text, Text} -> [{ref, Ref}, {new, New}, {link, false}, {text, Text}] end. -display(StoredPayload) -> +display_payload(StoredPayload) -> try json:decode(StoredPayload) of #{~"payload" := #{~"url" := Url}} when is_binary(Url) -> {link, public_url(Url)}; #{~"payload" := Text} when is_binary(Text) -> {text, Text};