{% load common_extras humanize %}{% comment %} The refreshable half of the contact list: the table, the empty state and the pager. Everything else on the page — the toolbar, the filter builder, the bulk bar — stays put across a swap. It re-fetches itself on `contactsChanged`, which every mutation's toast carries, the same way `_tag_rows.html` answers `tagsChanged`. Fetching the whole page and picking one div out of it with hx-select also works and costs a full shell render — sidebar, workspace switcher, every context-processor query — per keystroke-sized edit. Everything rendered here is attacker-controlled: names, emails and platform usernames all arrive from strangers (SECURITY-BASELINE §2). Django autoescapes, nothing is marked safe, and no value from a contact reaches an href or a src. {% endcomment %}
{% if list_error %}

This filter could not be applied, so no contacts are shown: {{ list_error }}

{% endif %} {% if page.object_list %} {% comment %} The table lives on a card now:
stopped being one big white panel when the redesign moved the page ground to --surface-page, so each screen supplies its own surfaces. {% endcomment %}
{% for contact in page.object_list %} {% endfor %}
{% comment %} Selects this page, not the whole filter. A "select every matching contact" affordance would promise a bulk action over a set the endpoint caps at 500 — targeting a whole segment is a broadcast's shape (issue #23), not a checkbox on a list. {% endcomment %} Contact Channels Tags Last active Status
{% comment %} A round initial rather than the 4px platform square the row used to lead with: the channel belongs in the Channels column, and what a reader scans a contact list for is people. {% endcomment %} {{ contact.display_name|make_list|first|upper }} {{ contact.display_name }} {% if contact.email %}{{ contact.email }} {% elif contact.phone %}{{ contact.phone }} {% else %}No email or phone yet{% endif %} {% for platform in contact.channel_platforms %} {% include "partials/_platform_icon.html" with platform=platform size="sm" %} {% empty %} None {% endfor %} {% for tag in contact.tags.all %}{{ tag.name }}{% empty %}None{% endfor %} {% if contact.last_interaction_at %}{{ contact.last_interaction_at|timesince }} ago{% else %}Never{% endif %} {% comment %} Whether they may be messaged, as a status pill (HANDOFF §3, Contacts) — the same words the Filter popover's Status group uses. The column used to be headed "Opt-in" and then "Contactable"; what a reader wants to know is whether they may message this person. Still two independent facts, never an if/elif chain. `has_opt_in` and `has_opted_out` can both be true — a contact can have consented on Telegram and opted out on SMS — and `annotate_reachability`'s docstring says collapsing them "would have to pick which one to lie about". An elif did exactly that: it hid a still-reachable channel behind the opt-out and read as though the contact were unreachable. The mixed case gets its own sentence instead. {% endcomment %} {% if contact.has_opt_in and contact.has_opted_out %} On some channels {% elif contact.has_opt_in %} Subscribed {% elif contact.has_opted_out %} Opted out {% else %} Not yet {% endif %}

Page {{ page.number }} of {{ page.paginator.num_pages }}. {{ page.paginator.count }} contact{{ page.paginator.count|pluralize }} in all. {% if page.has_previous %} Previous {% endif %} {% if page.has_next %} Next {% endif %}

{% else %} {% if query.is_filtered or query.segment %} {% url 'contacts:list' workspace_id=request.workspace.id as everyone_url %} {% include "components/empty_state.html" with icon="search" title="No contacts match this filter." body="Try a different word, or clear the filters." action_label="Clear filters" action_url=everyone_url %} {% else %} {% include "components/empty_state.html" with icon="contacts" title="No contacts yet" body="They arrive when someone messages a connected channel, or when you import a CSV." %} {% endif %} {% endif %}