Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 | 125x 133x 133x 133x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 125x 125x 125x 125x 125x 59x 59x 59x 59x 1x 1x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 1x 1x 1x 1x 1x 1x 56x 48x 1x 56x 56x 125x 125x 125x 125x 125x 125x 125x 125x 125x 56x 56x 56x 1x 1x 1x 1x 1x 1x 56x 56x 125x 125x 125x 125x 125x 48x 48x 56x 8x 8x 8x 8x 8x 8x 16x 16x 16x 16x 16x 16x 16x 16x 16x 1x 1x 1x 1x 1x 16x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 172x 172x 172x 125x 125x 125x 125x | import { css, html, TemplateResult } from 'lit';
import { ContentList, ContentListColumn } from './ContentList';
import { Icon } from '../Icons';
import { Msg } from '../interfaces';
import { attachmentAsString } from '../utils';
/**
* Message CRUDL list — drop-in replacement for the rapidpro
* `msgs/msg_list.html` table. Reverse-chronological; the message
* cell carries the body text with its attachment thumbnails right
* after it and the flow / label pills pushed to the trailing edge,
* with a timedate timestamp closing the row.
*/
export class MsgList extends ContentList<Msg> {
static get styles() {
return css`
${ContentList.styles}
/* The message cell holds the body text on the leading edge, with
its attachment thumbnails and the flow / label pills grouped
and pushed to the trailing edge. The text sizes to content and
ellipsizes when squeezed — resolved per row, so a busy row
doesn't widen a column for all of them. */
.msg-cell {
display: flex;
align-items: center;
gap: 12px;
}
.msg-text {
flex: 0 1 auto;
/* A small floor so the message keeps a few words even on a
row whose meta is wide — the message yields most of its
width to the attachments and pills before they clip. */
min-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Attachment thumbnails, immediately after the message text. */
.msg-attachments {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 6px;
}
/* Flow + label pills, pushed to the trailing edge of the cell. */
.cell-pills {
flex: 0 0 auto;
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
/* Attachment thumbnails are sized well below the 44px row so
they never grow its height — a small square preview rather
than the chat history's full-size thumbnail. --thumb-icon-
padding shrinks temba-thumbnail's non-image fallback (the
document/video icon box) to match. The max-height clamp is
a backstop for any type that still carries its own larger
intrinsic size (e.g. a location map). */
.msg-thumb {
--thumb-size: 24px;
--thumb-padding: 2px;
--thumb-icon-padding: 4px;
max-height: 36px;
overflow: hidden;
}
/* Sent cell — the date with an optional channel-log icon to
its right. The cell stays right-aligned (the column's own
alignment) and uses a flex row so the icon sits flush
against the date with a small gap. */
.sent-cell {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
/* Channel-log icon link inside the sent cell. */
.msg-log {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
padding: 2px;
border-radius: var(--r-sm);
color: var(--text-3);
text-decoration: none;
}
.msg-log:hover {
background: var(--sunken);
color: var(--text-1);
}
.msg-log temba-icon {
--icon-color: currentColor;
}
`;
}
constructor() {
super();
this.valueKey = 'id';
this.emptyMessage = 'No messages';
this.searchPlaceholder = 'Search messages';
// Messages page 100 at a time, matching rapidpro's msg list.
this.pageSize = 100;
// Auto layout so the Contact / Sent columns size to their content
// (no stranded width on the short Sent value), while the Message
// column is a `grow` column — under auto layout it claims zero
// intrinsic width and ellipsis-truncates against the leftover space
// rather than stretching the table. minTableWidth lets the list
// scroll horizontally once the container is too narrow to keep the
// columns usable, rather than clipping anything.
this.minTableWidth = '640px';
this.columns = [
{
key: 'contact',
label: 'Contact',
width: '130px',
pinned: true,
resizable: true
},
{ key: 'text', label: 'Message', grow: true },
// Sent is deliberately not resizable: it's the last column, so its
// only possible handle would sit on the table's outer edge (Message
// is the grow column and owns the boundary between them).
{
key: 'created_on',
label: 'Sent',
align: 'right'
}
];
this.bulkActions = [
{
key: 'label',
label: 'Label',
icon: Icon.label,
labelsEndpoint: '/api/v2/labels.json'
},
// Archive trails the reversible actions; Delete, when offered,
// always goes last.
{ key: 'archive', label: 'Archive', icon: Icon.archive },
{ key: 'delete', label: 'Delete', icon: Icon.delete, destructive: true }
];
}
/** Rows navigate to the message's contact. Returning the href here
* also marks the row `clickable`, so it carries the pointer cursor on
* hover. */
protected getRowHref(item: Msg): string | null {
const uuid = item.contact?.uuid;
return uuid ? `/contact/read/${uuid}/` : null;
}
protected renderCell(
item: Msg,
column: ContentListColumn
): TemplateResult | string {
switch (column.key) {
case 'contact': {
const contact = item.contact || {};
return contact.name || contact.urn || '';
}
case 'text':
return this.renderMessageCell(item);
case 'created_on':
return this.renderSentCell(item);
default:
return super.renderCell(item, column);
}
}
/** The message cell — body text on the leading edge with its
* attachment thumbnails immediately after it, and the flow / label
* pills pushed to the trailing edge. Each piece sizes to content, so
* the split is resolved independently for every row. A message with
* no text renders no text span at all — otherwise its min-width
* would strand an attachment-only row's thumbnails away from the
* leading edge. */
private renderMessageCell(item: Msg): TemplateResult {
const text = this.renderMessageText(item);
return html`
<div class="msg-cell">
${text ? html`<span class="msg-text">${text}</span>` : ''}
${this.renderAttachments(item)}${this.renderPills(item)}
</div>
`;
}
/** The sent cell — timedate timestamp with an optional channel-log
* icon to its right. The icon is rendered when the server includes
* a logs_url on the row (permission- and retention-gated
* server-side). stopPropagation keeps the row's contact navigation
* from also firing when the icon is clicked. */
private renderSentCell(item: Msg): TemplateResult | string {
if (!item.created_on) return '';
return html`
<div class="sent-cell">
<temba-date value=${item.created_on} display="timedate"></temba-date>
${item.logs_url && this.isSafeHref(item.logs_url)
? html`
<a
class="msg-log"
href=${item.logs_url}
@click=${(e: MouseEvent) => e.stopPropagation()}
aria-label="Channel log"
>
<temba-icon name=${Icon.log} size="0.95"></temba-icon>
</a>
`
: ''}
</div>
`;
}
/** The message body — plain text, or an opt-in pill when an
* opt-in request carries no text of its own. */
private renderMessageText(item: Msg): TemplateResult | string {
const isOptin = item.msg_type === 'optin' || item.type === 'optin';
if (!item.text && isOptin) {
return this.renderStatusPill('pending', 'opt-in request');
}
return item.text || '';
}
/** Attachment thumbnails for a row, immediately after the message
* text, or '' when the row carries none. */
private renderAttachments(item: Msg): TemplateResult | string {
const attachments = item.attachments || [];
if (!attachments.length) return '';
return html`
<div class="msg-attachments">
${attachments.map(
(a) => html`
<temba-thumbnail
class="msg-thumb"
attachment=${attachmentAsString(a)}
></temba-thumbnail>
`
)}
</div>
`;
}
/** Flow + label pills for a row, pushed to the trailing edge of
* the message cell, or '' when the row carries none. The flow pill
* opens its editor and each label pill opens that label's filtered
* message view — matching the rapidpro msg list. `clickable` gives
* the hover affordance; `goto` routes the click through the SPA and
* stops propagation so the row's own contact navigation doesn't also
* fire. */
private renderPills(item: Msg): TemplateResult | string {
const labels = item.labels || [];
if (!item.flow && !labels.length) return '';
return html`
<div class="cell-pills">
${item.flow
? html`<temba-label
type="flow"
icon=${Icon.flow}
href="/flow/editor/${item.flow.uuid}/"
onclick="goto(event)"
clickable
>${item.flow.name}</temba-label
>`
: null}
${labels.map(
(l) => html`
<temba-label
type="label"
icon=${Icon.label}
href="/msg/filter/${l.uuid}/"
onclick="goto(event)"
clickable
>${l.name}</temba-label
>
`
)}
</div>
`;
}
}
|