Add conversations modal
This commit is contained in:
@@ -945,6 +945,140 @@ body {
|
|||||||
color: var(--color-red);
|
color: var(--color-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== */
|
||||||
|
/* Conversations Modal */
|
||||||
|
/* ==================== */
|
||||||
|
|
||||||
|
.conversations-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations-overlay.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The picker fills the screen. The search field stays at the top
|
||||||
|
and the list scrolls below it. */
|
||||||
|
.conversations-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--s-1) var(--s1);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations-panel__title {
|
||||||
|
font-size: var(--s1);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#conversations-close {
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
#conversations-close:hover {
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations-panel__search {
|
||||||
|
padding: var(--s-1) var(--s1);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations-search {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--s2);
|
||||||
|
padding: 0 var(--s-1);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--s-1);
|
||||||
|
line-height: var(--s2);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations-search:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations-panel__list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--s-1) var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s-2);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s-1);
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.conversations__item:hover {
|
||||||
|
background: var(--color-gray1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations__item:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations__item--current {
|
||||||
|
background: var(--color-gray1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations__item-title {
|
||||||
|
font-size: var(--s0);
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations__item-updated {
|
||||||
|
font-size: var(--s-1);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations__empty {
|
||||||
|
padding: var(--s2);
|
||||||
|
font-size: var(--s0);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* ==================== */
|
/* ==================== */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -55,6 +55,27 @@ export class Api {
|
|||||||
|
|
||||||
// --- Conversations ---
|
// --- Conversations ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* listConversations returns every conversation of the user, most
|
||||||
|
* recently updated first. Each row holds id, title, created_at,
|
||||||
|
* and updated_at. It holds no messages.
|
||||||
|
* @returns {Promise<Array<Object>>}
|
||||||
|
*/
|
||||||
|
async listConversations() {
|
||||||
|
return this._json('/conversations');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getConversation returns one conversation row. Use it to verify
|
||||||
|
* that a conversation exists before the local selection moves to
|
||||||
|
* it. It returns no messages.
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async getConversation(id) {
|
||||||
|
return this._json(`/conversations/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* createConversation saves a new conversation with its initial
|
* createConversation saves a new conversation with its initial
|
||||||
* system message. An empty systemMessage makes the server use its
|
* system message. An empty systemMessage makes the server use its
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { Api, ApiError } from './api.js';
|
|||||||
import { Storage } from './storage.js';
|
import { Storage } from './storage.js';
|
||||||
import { STT, VOICE_MAP } from './stt.js';
|
import { STT, VOICE_MAP } from './stt.js';
|
||||||
import { TTSPlayer } from './tts.js';
|
import { TTSPlayer } from './tts.js';
|
||||||
import { extractText, UI } from './ui.js';
|
import { conversationLabel, extractText, UI } from './ui.js';
|
||||||
import { WakeLock } from './wakelock.js';
|
import { WakeLock } from './wakelock.js';
|
||||||
|
|
||||||
// The tool result saved when the user denies a tool call. A denied
|
// The tool result saved when the user denies a tool call. A denied
|
||||||
@@ -69,6 +69,9 @@ class Odidere {
|
|||||||
onSettingsOpen: () =>
|
onSettingsOpen: () =>
|
||||||
this.ui.setSystemMessageDraft(this.storage.getSystemMessage()),
|
this.ui.setSystemMessageDraft(this.storage.getSystemMessage()),
|
||||||
onSaveSystemMessage: (text) => this.storage.setSystemMessage(text),
|
onSaveSystemMessage: (text) => this.storage.setSystemMessage(text),
|
||||||
|
onConversationsOpen: () => this.loadConversations(),
|
||||||
|
onConversationSearch: (query) => this.filterConversations(query),
|
||||||
|
onSelectConversation: (id) => this.switchConversation(id),
|
||||||
onPushToTalkStart: () => this.handlePushToTalkStart(),
|
onPushToTalkStart: () => this.handlePushToTalkStart(),
|
||||||
onPushToTalkEnd: () => this.handlePushToTalkEnd(),
|
onPushToTalkEnd: () => this.handlePushToTalkEnd(),
|
||||||
onTranscribeStart: () => this.handleTranscribeStart(),
|
onTranscribeStart: () => this.handleTranscribeStart(),
|
||||||
@@ -106,6 +109,8 @@ class Odidere {
|
|||||||
|
|
||||||
// --- In-memory state ---
|
// --- In-memory state ---
|
||||||
this.conversationId = null;
|
this.conversationId = null;
|
||||||
|
// The rows the conversation picker shows, fetched on open.
|
||||||
|
this.conversations = [];
|
||||||
this.messages = new Map();
|
this.messages = new Map();
|
||||||
this.drafts = new Map();
|
this.drafts = new Map();
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
@@ -716,6 +721,99 @@ class Odidere {
|
|||||||
this.updateSendEnabled();
|
this.updateSendEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====================
|
||||||
|
// CONVERSATIONS
|
||||||
|
// ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* loadConversations fetches the list for the picker and renders
|
||||||
|
* it. The server sends the rows most recently updated first, so
|
||||||
|
* the picker shows them in that order without sorting.
|
||||||
|
*/
|
||||||
|
async loadConversations() {
|
||||||
|
try {
|
||||||
|
this.conversations = await this.api.listConversations();
|
||||||
|
} catch (error) {
|
||||||
|
this.ui.renderError(`Failed to list conversations: ${error.message}`);
|
||||||
|
this.ui.closeConversations();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ui.renderConversationList(this.conversations, {
|
||||||
|
currentId: this.conversationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* filterConversations renders the conversations whose label
|
||||||
|
* matches the search text. The label is the title, or the
|
||||||
|
* creation date when the conversation has none.
|
||||||
|
* @param {string} query
|
||||||
|
*/
|
||||||
|
filterConversations(query) {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
const rows = needle
|
||||||
|
? this.conversations.filter((conversation) =>
|
||||||
|
conversationLabel(conversation).toLowerCase().includes(needle),
|
||||||
|
)
|
||||||
|
: this.conversations;
|
||||||
|
this.ui.renderConversationList(rows, {
|
||||||
|
currentId: this.conversationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* switchConversation selects another conversation. The current
|
||||||
|
* one stays on the server; only the local view changes.
|
||||||
|
*
|
||||||
|
* The target is read first, so a conversation that another client
|
||||||
|
* deleted does not replace the one in view.
|
||||||
|
*
|
||||||
|
* A run in progress is detached, not stopped: the server keeps an
|
||||||
|
* accepted inference running after the client disconnects, and a
|
||||||
|
* later switch back re-attaches to it and replays what it saved.
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
async switchConversation(id) {
|
||||||
|
this.ui.closeConversations();
|
||||||
|
if (id === this.conversationId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.getConversation(id);
|
||||||
|
} catch (error) {
|
||||||
|
this.ui.renderError(`Failed to open conversation: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tts.stop();
|
||||||
|
// End delivery of the current streams. An accepted inference
|
||||||
|
// keeps running on the server; see the comment above.
|
||||||
|
this.syncController?.abort();
|
||||||
|
if (this.busy) {
|
||||||
|
this.runController?.abort();
|
||||||
|
if (this.currentRun) {
|
||||||
|
try {
|
||||||
|
await this.currentRun;
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the local state of the old selection. resync reads and
|
||||||
|
// renders the whole history of the new selection and caches it.
|
||||||
|
this.messages.clear();
|
||||||
|
this.clearDrafts();
|
||||||
|
this.totalTokens = 0;
|
||||||
|
this.lastVoice = '';
|
||||||
|
this.storage.clearConversation();
|
||||||
|
this.ui.setAutoScroll(false);
|
||||||
|
this.ui.clearChat();
|
||||||
|
this.ui.setPendingApprovals([]);
|
||||||
|
this.ui.updateContext(0, null);
|
||||||
|
this.conversationId = id;
|
||||||
|
await this.resync();
|
||||||
|
this.updateSendEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
// ====================
|
// ====================
|
||||||
// SYNC
|
// SYNC
|
||||||
// ====================
|
// ====================
|
||||||
|
|||||||
@@ -39,6 +39,27 @@ export function extractText(content) {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conversations created from this UI have no title. Their label
|
||||||
|
// falls back to the creation date, so the picker stays readable.
|
||||||
|
// Both dates render in the browser locale.
|
||||||
|
const CONVERSATION_DATE = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* conversationLabel returns the text that names a conversation in
|
||||||
|
* the picker: its title, or its creation date when it has none.
|
||||||
|
* @param {{title: string | null, created_at: string}} conversation
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function conversationLabel(conversation) {
|
||||||
|
return (
|
||||||
|
conversation.title ??
|
||||||
|
CONVERSATION_DATE.format(new Date(conversation.created_at))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export class UI {
|
export class UI {
|
||||||
/**
|
/**
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
@@ -88,6 +109,18 @@ export class UI {
|
|||||||
this.$systemMessageInput = document.getElementById('system-message-input');
|
this.$systemMessageInput = document.getElementById('system-message-input');
|
||||||
this.$saveSystemMessage = document.getElementById('save-system-message');
|
this.$saveSystemMessage = document.getElementById('save-system-message');
|
||||||
|
|
||||||
|
// Conversations modal.
|
||||||
|
this.$conversations = document.getElementById('conversations');
|
||||||
|
this.$conversationsOverlay = document.getElementById(
|
||||||
|
'conversations-overlay',
|
||||||
|
);
|
||||||
|
this.$conversationsClose = document.getElementById('conversations-close');
|
||||||
|
this.$conversationsSearch = document.getElementById('conversation-search');
|
||||||
|
this.$conversationsList = document.getElementById('conversation-list');
|
||||||
|
this.$tplConversationItem = document.getElementById(
|
||||||
|
'tpl-conversation-item',
|
||||||
|
);
|
||||||
|
|
||||||
// Compose settings modal.
|
// Compose settings modal.
|
||||||
this.$composeSettings = document.getElementById('compose-settings');
|
this.$composeSettings = document.getElementById('compose-settings');
|
||||||
this.$composeSettingsOverlay = document.getElementById(
|
this.$composeSettingsOverlay = document.getElementById(
|
||||||
@@ -824,6 +857,65 @@ export class UI {
|
|||||||
this.$composeSettingsClose.focus();
|
this.$composeSettingsClose.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====================
|
||||||
|
// CONVERSATIONS MODAL
|
||||||
|
// ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* openConversations shows the conversation picker and asks
|
||||||
|
* main.js for a fresh list. The search field starts empty and
|
||||||
|
* holds the caret, so typing filters the list at once.
|
||||||
|
*/
|
||||||
|
openConversations() {
|
||||||
|
this.$conversationsSearch.value = '';
|
||||||
|
this.$conversationsOverlay.classList.add('open');
|
||||||
|
this.$conversationsSearch.focus();
|
||||||
|
this.handlers.onConversationsOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** closeConversations hides the picker. */
|
||||||
|
closeConversations() {
|
||||||
|
this.$conversationsOverlay.classList.remove('open');
|
||||||
|
this.$conversations.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* renderConversationList fills the picker with the rows given,
|
||||||
|
* in the order given. main.js filters the rows; the row that
|
||||||
|
* matches currentId is marked as the conversation in view. The
|
||||||
|
* search field decides which empty message shows.
|
||||||
|
* @param {Array<{id: string, title: string | null, created_at: string, updated_at: string}>} rows
|
||||||
|
* @param {{currentId?: string | null}} [options]
|
||||||
|
*/
|
||||||
|
renderConversationList(rows, { currentId = null } = {}) {
|
||||||
|
this.$conversationsList.replaceChildren();
|
||||||
|
if (rows.length === 0) {
|
||||||
|
const $empty = this.document.createElement('div');
|
||||||
|
$empty.className = 'conversations__empty';
|
||||||
|
$empty.textContent = this.$conversationsSearch.value
|
||||||
|
? 'No conversations match the search.'
|
||||||
|
: 'No conversations yet.';
|
||||||
|
this.$conversationsList.appendChild($empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const conversation of rows) {
|
||||||
|
const $item =
|
||||||
|
this.$tplConversationItem.content.cloneNode(true).firstElementChild;
|
||||||
|
$item.dataset.conversationId = conversation.id;
|
||||||
|
$item.querySelector('.conversations__item-title').textContent =
|
||||||
|
conversationLabel(conversation);
|
||||||
|
$item.querySelector('.conversations__item-updated').textContent =
|
||||||
|
`Updated ${CONVERSATION_DATE.format(
|
||||||
|
new Date(conversation.updated_at),
|
||||||
|
)}`;
|
||||||
|
if (conversation.id === currentId) {
|
||||||
|
$item.classList.add('conversations__item--current');
|
||||||
|
$item.setAttribute('aria-current', 'true');
|
||||||
|
}
|
||||||
|
this.$conversationsList.appendChild($item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ====================
|
// ====================
|
||||||
// MUTE
|
// MUTE
|
||||||
// ====================
|
// ====================
|
||||||
@@ -1025,6 +1117,35 @@ export class UI {
|
|||||||
this.handlers.onVoiceChange(this.$voice.value),
|
this.handlers.onVoiceChange(this.$voice.value),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Conversations modal. The toolbar button opens it through the
|
||||||
|
// data-action route; these listeners close it and report the
|
||||||
|
// search text and the selected row.
|
||||||
|
this.$conversationsClose.addEventListener('click', () =>
|
||||||
|
this.closeConversations(),
|
||||||
|
);
|
||||||
|
this.$conversationsOverlay.addEventListener('click', (event) => {
|
||||||
|
if (event.target === this.$conversationsOverlay) {
|
||||||
|
this.closeConversations();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.document.addEventListener('keydown', (event) => {
|
||||||
|
if (
|
||||||
|
event.key === 'Escape' &&
|
||||||
|
this.$conversationsOverlay.classList.contains('open')
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.closeConversations();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.$conversationsSearch.addEventListener('input', () =>
|
||||||
|
this.handlers.onConversationSearch(this.$conversationsSearch.value),
|
||||||
|
);
|
||||||
|
this.$conversationsList.addEventListener('click', (event) => {
|
||||||
|
const $item = event.target.closest('[data-conversation-id]');
|
||||||
|
if (!$item) return;
|
||||||
|
this.handlers.onSelectConversation($item.dataset.conversationId);
|
||||||
|
});
|
||||||
|
|
||||||
// Compose settings modal.
|
// Compose settings modal.
|
||||||
this.$composeSettings.addEventListener('click', () =>
|
this.$composeSettings.addEventListener('click', () =>
|
||||||
this.handlers.onComposeSettings(),
|
this.handlers.onComposeSettings(),
|
||||||
@@ -1152,6 +1273,10 @@ export class UI {
|
|||||||
this._clearConfirm();
|
this._clearConfirm();
|
||||||
this.handlers.onLocationToggle();
|
this.handlers.onLocationToggle();
|
||||||
break;
|
break;
|
||||||
|
case 'conversations':
|
||||||
|
this._clearConfirm();
|
||||||
|
this.openConversations();
|
||||||
|
break;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
this._clearConfirm();
|
this._clearConfirm();
|
||||||
this._openSettings();
|
this._openSettings();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{{ template "main" . }}
|
{{ template "main" . }}
|
||||||
{{ template "templates" . }}
|
{{ template "templates" . }}
|
||||||
{{ template "modal/settings" . }}
|
{{ template "modal/settings" . }}
|
||||||
|
{{ template "modal/conversations" . }}
|
||||||
{{ template "modal/edit" . }}
|
{{ template "modal/edit" . }}
|
||||||
{{ template "modal/compose-settings" . }}
|
{{ template "modal/compose-settings" . }}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -9,6 +9,15 @@
|
|||||||
>
|
>
|
||||||
<svg class="icon"><use href="/static/icons.svg#settings"></use></svg>
|
<svg class="icon"><use href="/static/icons.svg#settings"></use></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="footer__toolbar-btn"
|
||||||
|
id="conversations"
|
||||||
|
data-action="conversations"
|
||||||
|
aria-label="Conversations"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#menu"></use></svg>
|
||||||
|
</button>
|
||||||
<span class="footer__toolbar-context" id="context" hidden></span>
|
<span class="footer__toolbar-context" id="context" hidden></span>
|
||||||
<div class="footer__toolbar-spacer"></div>
|
<div class="footer__toolbar-spacer"></div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{{ define "modal/conversations" }}
|
||||||
|
<div class="conversations-overlay" id="conversations-overlay">
|
||||||
|
<div class="conversations-panel" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="conversations-title">
|
||||||
|
<div class="conversations-panel__header">
|
||||||
|
<h2 class="conversations-panel__title" id="conversations-title">
|
||||||
|
Conversations
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="footer__toolbar-btn"
|
||||||
|
id="conversations-close"
|
||||||
|
aria-label="Close conversations"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#close"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="conversations-panel__search">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="conversations-search"
|
||||||
|
id="conversation-search"
|
||||||
|
placeholder="Search conversations..."
|
||||||
|
aria-label="Search conversations"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="conversations-panel__list" id="conversation-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template id="tpl-conversation-item">
|
||||||
|
<button type="button" class="conversations__item">
|
||||||
|
<span class="conversations__item-title"></span>
|
||||||
|
<span class="conversations__item-updated"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
{{ end }}
|
||||||
Reference in New Issue
Block a user