- <.icon name="hero-users" class="size-5 text-[var(--color-text-disabled)]" />
+
+
<%= if @can_edit do %>
<%= if @confirming_delete_id == rel.relationship.id do %>
diff --git a/test/kith/contacts/activity_stream_test.exs b/test/kith/contacts/activity_stream_test.exs
new file mode 100644
index 0000000..144dc6a
--- /dev/null
+++ b/test/kith/contacts/activity_stream_test.exs
@@ -0,0 +1,218 @@
+defmodule Kith.Contacts.ActivityStreamTest do
+ use Kith.DataCase, async: true
+
+ alias Kith.Contacts.ActivityStream
+
+ import Kith.AccountsFixtures
+ import Kith.ContactsFixtures
+
+ setup do
+ seed_reference_data!()
+ user = user_fixture()
+ scope = user_scope_fixture(user)
+ contact = contact_fixture(scope.account.id)
+
+ %{user: user, scope: scope, contact: contact, account_id: scope.account.id}
+ end
+
+ describe "list_activity/3" do
+ test "returns empty list when contact has no activity", ctx do
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert entries == []
+ end
+
+ test "returns notes in the stream", ctx do
+ note_fixture(ctx.contact, ctx.user.id, %{"body" => "
Hello world
"})
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :note, body: "
Hello world
"}] = entries
+ end
+
+ test "returns calls in the stream", ctx do
+ {:ok, _call} =
+ Kith.Activities.create_call(ctx.contact, %{
+ "occurred_at" => DateTime.utc_now(),
+ "duration_mins" => 15,
+ "notes" => "Talked about plans"
+ })
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :call}] = entries
+ end
+
+ test "returns life events in the stream", ctx do
+ [type | _] = Kith.Repo.all(Kith.Contacts.LifeEventType)
+
+ {:ok, _event} =
+ Kith.Activities.create_life_event(ctx.contact, %{
+ "occurred_on" => ~D[2025-06-15],
+ "life_event_type_id" => type.id,
+ "note" => "Big day"
+ })
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :life_event, body: "Big day"}] = entries
+ end
+
+ test "returns tasks in the stream", ctx do
+ {:ok, _task} =
+ Kith.Tasks.create_task(ctx.account_id, ctx.user.id, %{
+ "title" => "Buy birthday gift",
+ "contact_id" => ctx.contact.id
+ })
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :task, title: "Buy birthday gift"}] = entries
+ end
+
+ test "returns gifts in the stream", ctx do
+ {:ok, _gift} =
+ Kith.Gifts.create_gift(ctx.account_id, ctx.user.id, %{
+ "name" => "A book",
+ "direction" => "given",
+ "contact_id" => ctx.contact.id
+ })
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :gift, title: "A book"}] = entries
+ end
+
+ test "returns conversations in the stream", ctx do
+ {:ok, _conv} =
+ Kith.Conversations.create_conversation(ctx.account_id, ctx.user.id, %{
+ "subject" => "Weekend plans",
+ "platform" => "whatsapp",
+ "contact_id" => ctx.contact.id
+ })
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :conversation, title: "Weekend plans"}] = entries
+ end
+
+ test "returns photos in the stream", ctx do
+ photo_fixture(ctx.contact)
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :photo}] = entries
+ end
+
+ test "merges multiple types sorted by date descending", ctx do
+ # Create entries with staggered timestamps
+ note_fixture(ctx.contact, ctx.user.id, %{"body" => "
Old note
"})
+ Process.sleep(10)
+
+ {:ok, _call} =
+ Kith.Activities.create_call(ctx.contact, %{
+ "occurred_at" => DateTime.utc_now(),
+ "notes" => "Recent call"
+ })
+
+ Process.sleep(10)
+ note_fixture(ctx.contact, ctx.user.id, %{"body" => "
Newest note
"})
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 3
+ types = Enum.map(entries, & &1.type)
+ # Newest first
+ assert List.first(types) == :note
+ end
+
+ test "filters by type", ctx do
+ note_fixture(ctx.contact, ctx.user.id)
+ photo_fixture(ctx.contact)
+
+ {:ok, _call} =
+ Kith.Activities.create_call(ctx.contact, %{
+ "occurred_at" => DateTime.utc_now()
+ })
+
+ notes_only =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id,
+ current_user_id: ctx.user.id,
+ types: [:note]
+ )
+
+ assert length(notes_only) == 1
+ assert [%{type: :note}] = notes_only
+
+ notes_and_photos =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id,
+ current_user_id: ctx.user.id,
+ types: [:note, :photo]
+ )
+
+ assert length(notes_and_photos) == 2
+ types = Enum.map(notes_and_photos, & &1.type) |> MapSet.new()
+ assert MapSet.equal?(types, MapSet.new([:note, :photo]))
+ end
+
+ test "respects limit option", ctx do
+ for i <- 1..5 do
+ note_fixture(ctx.contact, ctx.user.id, %{"body" => "
Note #{i}
"})
+ end
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id,
+ current_user_id: ctx.user.id,
+ limit: 3
+ )
+
+ assert length(entries) == 3
+ end
+
+ test "each entry has required fields", ctx do
+ note_fixture(ctx.contact, ctx.user.id, %{"body" => "
Test
"})
+
+ [entry] =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert Map.has_key?(entry, :id)
+ assert Map.has_key?(entry, :type)
+ assert Map.has_key?(entry, :title)
+ assert Map.has_key?(entry, :body)
+ assert Map.has_key?(entry, :occurred_at)
+ assert Map.has_key?(entry, :record)
+ assert %DateTime{} = entry.occurred_at
+ end
+ end
+
+ describe "all_types/0" do
+ test "returns all 8 types" do
+ types = ActivityStream.all_types()
+ assert length(types) == 8
+ assert :note in types
+ assert :call in types
+ assert :life_event in types
+ assert :activity in types
+ assert :task in types
+ assert :gift in types
+ assert :conversation in types
+ assert :photo in types
+ end
+ end
+end
From cb5be9433e11af2c6285cc6907a4bc9a75e49522 Mon Sep 17 00:00:00 2001
From: Bashar Qassis <23612682+bashar-qassis@users.noreply.github.com>
Date: Sat, 4 Apr 2026 02:22:24 +0300
Subject: [PATCH 3/4] fix: apply mix format to show.ex after merge
---
lib/kith_web/live/contact_live/show.ex | 1 +
1 file changed, 1 insertion(+)
diff --git a/lib/kith_web/live/contact_live/show.ex b/lib/kith_web/live/contact_live/show.ex
index 11b4c50..ab44783 100644
--- a/lib/kith_web/live/contact_live/show.ex
+++ b/lib/kith_web/live/contact_live/show.ex
@@ -213,6 +213,7 @@ defmodule KithWeb.ContactLive.Show do
"Birthday #{label}"
end
end
+
defp filtered_tags(tags, contact_tags, search) do
contact_tag_ids = Enum.map(contact_tags, & &1.id) |> MapSet.new()
From e3d9b762fb5c9b3a2fec6c1307c1ba33aa5da61e Mon Sep 17 00:00:00 2001
From: Bashar Qassis <23612682+bashar-qassis@users.noreply.github.com>
Date: Sat, 4 Apr 2026 03:03:10 +0300
Subject: [PATCH 4/4] fix: phone import bias, contact page layout, and avatar
discoverability
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Remove +1 country code assumption for bare 10-digit phone numbers
- Widen desktop sidebar (300→340px) to reduce empty space
- Fix mobile timeline/contact-info alignment with overflow control
- Add gap and truncation to sidebar label/value rows
- Add clickable avatar overlay with upload and photo picker popover
---
lib/kith/contacts/phone_formatter.ex | 4 +-
lib/kith_web/live/contact_live/show.ex | 73 ++++++++-
lib/kith_web/live/contact_live/show.html.heex | 143 +++++++++++++-----
test/kith/contacts/phone_formatter_test.exs | 12 +-
4 files changed, 186 insertions(+), 46 deletions(-)
diff --git a/lib/kith/contacts/phone_formatter.ex b/lib/kith/contacts/phone_formatter.ex
index efdd7c4..1532732 100644
--- a/lib/kith/contacts/phone_formatter.ex
+++ b/lib/kith/contacts/phone_formatter.ex
@@ -30,9 +30,9 @@ defmodule Kith.Contacts.PhoneFormatter do
has_plus ->
{:ok, "+" <> digits}
- # US/Canada: bare 10-digit number
+ # Bare 10-digit number — could be many countries, store as-is
String.length(digits) == 10 ->
- {:ok, "+1" <> digits}
+ {:ok, digits}
# US/Canada: 11-digit starting with 1
String.length(digits) == 11 and String.starts_with?(digits, "1") ->
diff --git a/lib/kith_web/live/contact_live/show.ex b/lib/kith_web/live/contact_live/show.ex
index ab44783..6638b6e 100644
--- a/lib/kith_web/live/contact_live/show.ex
+++ b/lib/kith_web/live/contact_live/show.ex
@@ -21,7 +21,14 @@ defmodule KithWeb.ContactLive.Show do
|> assign(:show_more_drawer, false)
|> assign(:mobile_sidebar_tab, "basic-info")
|> assign(:duplicate_candidates, [])
- |> assign(:next_reminder, nil)}
+ |> assign(:next_reminder, nil)
+ |> assign(:show_avatar_picker, false)
+ |> assign(:avatar_photos, [])
+ |> allow_upload(:avatar,
+ accept: ~w(.jpg .jpeg .png .gif .webp),
+ max_entries: 1,
+ max_file_size: 10_000_000
+ )}
end
@impl true
@@ -145,6 +152,62 @@ defmodule KithWeb.ContactLive.Show do
|> assign(:tag_search, "")}
end
+ def handle_event("toggle-avatar-picker", _params, socket) do
+ show = !socket.assigns.show_avatar_picker
+ photos = if show, do: Contacts.list_photos(socket.assigns.contact.id), else: []
+ {:noreply, socket |> assign(:show_avatar_picker, show) |> assign(:avatar_photos, photos)}
+ end
+
+ def handle_event("pick-avatar-photo", %{"id" => id}, socket) do
+ contact = socket.assigns.contact
+ photo = Contacts.get_photo!(contact.account_id, String.to_integer(id))
+ {:ok, updated_contact} = Contacts.set_avatar(contact, photo)
+
+ updated_contact =
+ Kith.Repo.preload(updated_contact, [:tags, :gender, :first_met_through], force: true)
+
+ {:noreply,
+ socket
+ |> assign(:contact, updated_contact)
+ |> assign(:show_avatar_picker, false)
+ |> put_flash(:info, "Avatar updated.")}
+ end
+
+ def handle_event("validate-avatar", _params, socket) do
+ {:noreply, socket}
+ end
+
+ def handle_event("upload-avatar", _params, socket) do
+ contact = socket.assigns.contact
+
+ [photo] =
+ consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry ->
+ key = "contacts/#{contact.id}/photos/#{entry.uuid}-#{entry.client_name}"
+ {:ok, _} = Kith.Storage.upload(path, key)
+
+ {:ok, photo} =
+ Contacts.create_photo(contact, %{
+ file_name: entry.client_name,
+ storage_key: key,
+ file_size: entry.client_size,
+ content_type: entry.client_type
+ })
+
+ {:ok, photo}
+ end)
+
+ {:ok, updated_contact} = Contacts.set_avatar(contact, photo)
+
+ updated_contact =
+ Kith.Repo.preload(updated_contact, [:tags, :gender, :first_met_through], force: true)
+
+ {:noreply,
+ socket
+ |> assign(:contact, updated_contact)
+ |> assign(:show_avatar_picker, false)
+ |> put_flash(:info, "Avatar updated.")}
+ end
+
def handle_event("remove-tag", %{"tag_id" => tag_id}, socket) do
account_id = socket.assigns.account_id
contact = socket.assigns.contact
@@ -160,7 +223,13 @@ defmodule KithWeb.ContactLive.Show do
@impl true
def handle_info({:avatar_updated, updated_contact}, socket) do
- {:noreply, assign(socket, :contact, updated_contact)}
+ updated_contact =
+ Kith.Repo.preload(updated_contact, [:tags, :gender, :first_met_through], force: true)
+
+ {:noreply,
+ socket
+ |> assign(:contact, updated_contact)
+ |> assign(:avatar_photos, Contacts.list_photos(updated_contact.id))}
end
def handle_info({:contact_updated, updated_contact}, socket) do
diff --git a/lib/kith_web/live/contact_live/show.html.heex b/lib/kith_web/live/contact_live/show.html.heex
index 3d19afc..98b6513 100644
--- a/lib/kith_web/live/contact_live/show.html.heex
+++ b/lib/kith_web/live/contact_live/show.html.heex
@@ -37,8 +37,72 @@
<%!-- ========== HERO BANNER ========== --%>
- <%!-- Avatar --%>
-
+ <%!-- Avatar with change overlay --%>
+
+
+ <%= if can?(assigns, :update, :contact) do %>
+
+ <% end %>
+ <%!-- Avatar picker popover --%>
+ <%= if @show_avatar_picker do %>
+
+
+ Change avatar
+
+
+ <%= if @avatar_photos != [] do %>
+
+ Or choose from photos
+
+
+ <%= for photo <- @avatar_photos do %>
+
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
<%!-- Info --%>
@@ -182,9 +246,9 @@
<%!-- Basic Info --%>
-
-
- Birthday
-
-
+
+
- Birthday
+ -
-
-
- Gender
-
- {@contact.gender.name}
+
+
- Gender
+ - {@contact.gender.name}
-
-
- Occupation
-
- {@contact.occupation}
+
+
- Occupation
+ - {@contact.occupation}
-
-
- Company
-
- {@contact.company}
+
+
- Company
+ - {@contact.company}
-
-
- Last contact
-
-
+
+
- Last contact
+ -
@@ -294,18 +358,18 @@
<%!-- ========== TWO COLUMN LAYOUT ========== --%>
-
+
<%!-- ===== LEFT SIDEBAR (hidden on mobile — shown as tabs above) ===== --%>
-