feat: mini apps customizados com importacao por arquivo/URL, paleta de cores, avatar autenticado e documentacao
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2026-09-18 15:11:12 -03:00
parent 94632e0611
commit 1f692b1e4e
18 changed files with 2186 additions and 2 deletions

View File

@@ -0,0 +1,478 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Account Data</title>
<style>
:root {
--accent: #1F8EF1;
--bg: #ffffff;
--fg: #1a1a1a;
--card: #f6f6f6;
--border: #e3e3e3;
--muted: #757575;
--danger: #d93025;
--ok: #00D084;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #121212;
--fg: #e3e3e3;
--card: #1e1e1e;
--border: #333333;
--muted: #9e9e9e;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--fg);
min-height: 100vh;
}
main { max-width: 800px; margin: 0 auto; padding: 20px 16px 32px; }
header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
header .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); }
header h1 { font-size: 1.3rem; font-weight: 700; }
/* Profile card */
#profile {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card);
margin-bottom: 20px;
}
#profile img {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
background: var(--border);
}
#profile .placeholder {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent);
color: #fff;
font-weight: 700;
font-size: 1.3rem;
}
#profile .info { min-width: 0; }
#profile .name { font-weight: 700; font-size: 1.05rem; word-break: break-word; }
#profile .meta { color: var(--muted); font-size: .85rem; word-break: break-word; }
/* Toolbar */
#toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; gap: 8px; flex-wrap: wrap; }
#toolbar h2 { font-size: 1rem; font-weight: 600; }
#toolbar .actions { display: flex; gap: 8px; }
button {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card);
color: var(--fg);
padding: 8px 14px;
font-size: .88rem;
cursor: pointer;
font-family: inherit;
}
button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { opacity: .9; }
button.danger { color: var(--danger); }
#addKeyBtn { flex-shrink: 0; }
/* Key cards */
#list { list-style: none; }
.key-card {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card);
margin-bottom: 10px;
overflow: hidden;
}
.key-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
cursor: pointer;
user-select: none;
}
.key-head .key {
flex: 1;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .85rem;
word-break: break-all;
}
.key-head .chevron {
color: var(--muted);
font-size: .8rem;
transition: transform .15s;
}
.key-card.open .chevron { transform: rotate(90deg); }
.key-head .size {
color: var(--muted);
font-size: .75rem;
white-space: nowrap;
}
.key-body {
display: none;
border-top: 1px solid var(--border);
padding: 12px 14px;
}
.key-card.open .key-body { display: block; }
textarea {
width: 100%;
min-height: 160px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--fg);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .8rem;
resize: vertical;
outline: none;
}
textarea:focus { border-color: var(--accent); }
.key-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.status {
display: none;
margin-top: 10px;
padding: 8px 12px;
border-radius: 8px;
font-size: .85rem;
}
.status.ok { display: block; background: rgba(0, 208, 132, .15); color: var(--ok); }
.status.err { display: block; background: rgba(217, 48, 37, .12); color: var(--danger); }
.empty { text-align: center; padding: 48px 0 24px; color: var(--muted); }
.spinner {
margin: 60px auto;
width: 32px; height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Add key modal */
#modalBg {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,.5);
align-items: center; justify-content: center;
padding: 16px; z-index: 10;
}
#modalBg.open { display: flex; }
#modal {
width: 100%; max-width: 560px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px;
max-height: 90vh;
overflow-y: auto;
}
#modal h3 { margin-bottom: 6px; font-size: 1rem; }
#modal p.hint { color: var(--muted); font-size: .82rem; margin-bottom: 14px; }
#modal label { display: block; font-size: .85rem; margin-bottom: 4px; }
#newKeyType {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card);
color: var(--fg);
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: .82rem;
margin-bottom: 12px;
outline: none;
}
#modal .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
</style>
</head>
<body>
<main>
<header>
<div class="dot"></div>
<h1>Account Data</h1>
</header>
<div id="profile"></div>
<div id="toolbar">
<h2>Keys</h2>
<div class="actions">
<button id="reloadBtn" title="Reload">&#x21bb; Reload</button>
<button id="addKeyBtn" class="primary">+ Add key</button>
</div>
</div>
<ul id="list"></ul>
<div id="modalBg">
<div id="modal">
<h3>Add new key</h3>
<p class="hint">The content must be a valid JSON object.</p>
<label for="newKeyType">Key type</label>
<input id="newKeyType" placeholder="e.g. com.example.data" />
<label for="newKeyContent">Content (JSON)</label>
<textarea id="newKeyContent" style="min-height:120px">{}</textarea>
<div class="modal-actions">
<button id="modalCancel">Cancel</button>
<button id="modalSave" class="primary">Save</button>
</div>
</div>
</div>
</main>
<script>
const APP_ID = 'accountdata';
// ----- Bridge (same protocol as tasks.html).
const _pending = new Map();
let _requestCounter = 0;
function bridgeRequest(action, data) {
return new Promise(function (resolve, reject) {
const requestId = 'r' + (++_requestCounter);
_pending.set(requestId, { resolve: resolve, reject: reject });
window.parent.postMessage({
api: 'fromMiniApp',
widgetId: APP_ID,
requestId: requestId,
action: action,
data: data
}, '*');
});
}
window.addEventListener('message', function (event) {
const msg = event.data;
if (!msg || msg.api !== 'fromMiniApp' || !msg.requestId) return;
if (!_pending.has(msg.requestId)) return;
const pending = _pending.get(msg.requestId);
_pending.delete(msg.requestId);
const response = msg.response || {};
if (response.error) {
pending.reject(new Error(response.error));
} else {
pending.resolve(response.data);
}
});
// ----- App state and DOM.
const profileEl = document.getElementById('profile');
const listEl = document.getElementById('list');
const modalBg = document.getElementById('modalBg');
const newKeyType = document.getElementById('newKeyType');
const newKeyContent = document.getElementById('newKeyContent');
const KEY_DENYLIST = ['m.cross_signing.user_signing', 'm.cross_signing.master'];
document.getElementById('reloadBtn').addEventListener('click', init);
document.getElementById('addKeyBtn').addEventListener('click', openModal);
document.getElementById('modalCancel').addEventListener('click', closeModal);
modalBg.addEventListener('click', function (e) {
if (e.target === modalBg) closeModal();
});
document.getElementById('modalSave').addEventListener('click', saveNewKey);
function openModal() {
newKeyType.value = '';
newKeyContent.value = '{}';
modalBg.classList.add('open');
newKeyType.focus();
}
function closeModal() {
modalBg.classList.remove('open');
}
async function saveNewKey() {
const type = newKeyType.value.trim();
if (!type) { alert('Enter a key type.'); return; }
let content;
try {
content = JSON.parse(newKeyContent.value);
} catch (e) {
alert('Invalid JSON: ' + e.message);
return;
}
if (!content || typeof content !== 'object' || Array.isArray(content)) {
alert('Content must be a JSON object.');
return;
}
try {
await bridgeRequest('io.tatuchat.accountdata.set', {
key: type,
content: content
});
closeModal();
init();
} catch (e) {
alert('Failed to save: ' + e.message);
}
}
async function init() {
listEl.innerHTML = '<div class="spinner"></div>';
try {
const [identity, data] = await Promise.all([
bridgeRequest('io.tatuchat.identity.get'),
bridgeRequest('io.tatuchat.accountdata.list')
]);
renderProfile(identity || {});
renderList(data || {});
} catch (e) {
listEl.innerHTML = '<div class="empty">Failed to load: ' + escapeHtml(String(e.message || e)) + '</div>';
}
}
function renderProfile(info) {
const displayName = info.displayName || info.userId || 'Anonymous';
const initials = displayName.trim().charAt(0).toUpperCase() || '?';
const avatar = (info.avatarDataUri || info.avatarHttpUrl)
? '<img src="' + escapeHtml(info.avatarDataUri || info.avatarHttpUrl) + '" alt="avatar">'
: '<div class="placeholder">' + escapeHtml(initials) + '</div>';
const meta = [];
if (info.userId) meta.push(escapeHtml(info.userId));
if (info.homeserver) meta.push(escapeHtml(info.homeserver));
profileEl.innerHTML =
avatar +
'<div class="info">' +
'<div class="name">' + escapeHtml(displayName) + '</div>' +
'<div class="meta">' + meta.join(' &middot; ') + '</div>' +
'</div>';
}
function renderList(data) {
const keys = Object.keys(data || {}).sort();
listEl.textContent = '';
if (keys.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'No account data yet';
listEl.appendChild(empty);
return;
}
keys.forEach(function (key) {
listEl.appendChild(buildCard(key, data[key]));
});
}
function buildCard(key, content) {
const card = document.createElement('li');
card.className = 'key-card';
const head = document.createElement('div');
head.className = 'key-head';
const keySpan = document.createElement('span');
keySpan.className = 'key';
keySpan.textContent = key;
const chevron = document.createElement('span');
chevron.className = 'chevron';
chevron.textContent = '\u25B8';
const size = document.createElement('span');
size.className = 'size';
let sizeText;
try {
sizeText = (JSON.stringify(content) || '').length + ' B';
} catch (e) {
sizeText = '?';
}
size.textContent = sizeText;
head.append(keySpan, size, chevron);
const body = document.createElement('div');
body.className = 'key-body';
const textarea = document.createElement('textarea');
textarea.spellcheck = false;
try {
textarea.value = JSON.stringify(content, null, 2);
} catch (e) {
textarea.value = '// not serializable';
}
const actions = document.createElement('div');
actions.className = 'key-actions';
const status = document.createElement('div');
status.className = 'status';
const saveBtn = document.createElement('button');
saveBtn.className = 'primary';
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', async function () {
status.className = 'status';
if (KEY_DENYLIST.includes(key)) {
status.className = 'status err';
status.textContent = 'This key is protected and cannot be edited.';
return;
}
let value;
try {
value = JSON.parse(textarea.value);
} catch (e) {
status.className = 'status err';
status.textContent = 'Invalid JSON: ' + e.message;
return;
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
status.className = 'status err';
status.textContent = 'Content must be a JSON object.';
return;
}
try {
await bridgeRequest('io.tatuchat.accountdata.set', { key: key, content: value });
status.className = 'status ok';
status.textContent = 'Saved. Reloading...';
await init();
} catch (e) {
status.className = 'status err';
status.textContent = 'Save failed: ' + (e.message || e);
}
});
const resetBtn = document.createElement('button');
resetBtn.textContent = 'Reset';
resetBtn.addEventListener('click', function () {
try {
textarea.value = JSON.stringify(content, null, 2);
} catch (e) {}
});
actions.append(saveBtn, resetBtn);
body.append(textarea, actions, status);
head.addEventListener('click', function () {
card.classList.toggle('open');
});
// Don't toggle when clicking buttons inside the body.
body.addEventListener('click', function (e) { e.stopPropagation(); });
card.append(head, body);
return card;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
init();
</script>
</body>
</html>

209
assets/miniapps/tasks.html Normal file
View File

@@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Tasks</title>
<style>
:root {
--accent: #00D084;
--bg: #ffffff;
--fg: #1a1a1a;
--card: #f6f6f6;
--border: #e3e3e3;
--muted: #757575;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #121212;
--fg: #e3e3e3;
--card: #1e1e1e;
--border: #333333;
--muted: #9e9e9e;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--fg);
min-height: 100vh;
}
main { max-width: 640px; margin: 0 auto; padding: 20px 16px 32px; }
header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
header .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); }
header h1 { font-size: 1.3rem; font-weight: 700; }
#addForm { display: flex; gap: 8px; margin-bottom: 20px; }
#taskInput {
flex: 1;
padding: 11px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card);
color: var(--fg);
font-size: 0.95rem;
outline: none;
}
#taskInput:focus { border-color: var(--accent); }
#addBtn {
width: 44px;
border: none;
border-radius: 10px;
background: var(--accent);
color: #fff;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
}
ul { list-style: none; }
li {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
margin-bottom: 10px;
background: var(--card);
}
li.done span { text-decoration: line-through; opacity: .55; }
li input[type="checkbox"] {
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
li span { flex: 1; word-break: break-word; }
li button {
flex-shrink: 0;
background: none;
border: none;
color: var(--muted);
font-size: 0.9rem;
cursor: pointer;
padding: 4px;
}
li button:hover { color: var(--fg); }
.empty { text-align: center; padding: 48px 0 24px; color: var(--muted); }
</style>
</head>
<body>
<main>
<header>
<div class="dot"></div>
<h1>Tasks</h1>
</header>
<form id="addForm" autocomplete="off">
<input id="taskInput" placeholder="What do you need to do?" />
<button id="addBtn" type="submit">+</button>
</form>
<ul id="list"></ul>
</main>
<script>
const APP_ID = 'tasks';
// ----- Bridge: the app talks to the TatuChat client through postMessage.
// The host replies with {api, widgetId, requestId, response:{data,error}}.
const _pending = new Map();
let _requestCounter = 0;
function bridgeRequest(action, data) {
return new Promise(function (resolve, reject) {
const requestId = 'r' + (++_requestCounter);
_pending.set(requestId, { resolve: resolve, reject: reject });
window.parent.postMessage({
api: 'fromMiniApp',
widgetId: APP_ID,
requestId: requestId,
action: action,
data: data
}, '*');
});
}
window.addEventListener('message', function (event) {
const msg = event.data;
if (!msg || msg.api !== 'fromMiniApp' || !msg.requestId) return;
if (!_pending.has(msg.requestId)) return;
const pending = _pending.get(msg.requestId);
_pending.delete(msg.requestId);
const response = msg.response || {};
if (response.error) {
pending.reject(new Error(response.error));
} else {
pending.resolve(response.data);
}
});
// ----- Tasks application logic.
let tasks = [];
const listEl = document.getElementById('list');
const addForm = document.getElementById('addForm');
const taskInput = document.getElementById('taskInput');
addForm.addEventListener('submit', function (event) {
event.preventDefault();
const text = taskInput.value.trim();
if (!text) return;
tasks.push({ id: String(Date.now()), text: text, done: false });
taskInput.value = '';
render();
save();
});
function toggleTask(id) {
const task = tasks.find(function (t) { return t.id === id; });
if (!task) return;
task.done = !task.done;
render();
save();
}
function deleteTask(id) {
tasks = tasks.filter(function (t) { return t.id !== id; });
render();
save();
}
function render() {
listEl.textContent = '';
if (tasks.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'No tasks yet';
listEl.appendChild(empty);
return;
}
tasks.forEach(function (task) {
const li = document.createElement('li');
if (task.done) li.className = 'done';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = task.done;
checkbox.addEventListener('change', function () { toggleTask(task.id); });
const span = document.createElement('span');
span.textContent = task.text;
const del = document.createElement('button');
del.textContent = '\u2715';
del.setAttribute('aria-label', 'Delete task');
del.addEventListener('click', function () { deleteTask(task.id); });
li.append(checkbox, span, del);
listEl.appendChild(li);
});
}
function load() {
return bridgeRequest('io.tatuchat.storage.load').then(function (data) {
if (data && Array.isArray(data.tasks)) tasks = data.tasks;
}).catch(function () {});
}
function save() {
return bridgeRequest('io.tatuchat.storage.save', { tasks: tasks }).catch(function () {});
}
load().finally(render);
</script>
</body>
</html>