add better loading for profile settings and user profiles & add details bar
All checks were successful
Android Build / publish (push) Successful in 54s
Linux Build / publish (push) Successful in 53s

This commit is contained in:
olcxja 2026-07-04 13:55:23 +02:00
commit 271c573fb4
16 changed files with 504 additions and 174 deletions

View file

@ -120,6 +120,9 @@
"title.captcha.code": "captcha code",
"title.invitation.code": "invitation code",
"title.back.to.register": "back to registration",
"title.details": "details",
"title.members": "cats",
"placeholder.username": "cat name",
"placeholder.captcha.code": "captcha code",
"desc.messages.loading": "loading meows...",
@ -151,6 +154,7 @@
"title.profile": "cat profile",
"title.settings": "cat settings",
"title.logout": "log out",
"title.loading": "sniffing...",
"action.edit.profile": "edit cat profile",
"action.open.dm": "open direct meowchat",
"action.invite.accept": "accept meow invite",

View file

@ -151,7 +151,10 @@
"title.profile": "Profile",
"title.settings": "Settings",
"title.logout": "Log out",
"title.loading": "Loading...",
"title.details": "Details",
"title.members": "Members",
"action.edit.profile": "Edit profile",
"action.open.dm": "Open DM",
"action.invite.accept": "Accept invite",

View file

@ -8,8 +8,9 @@
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<script src="crypto-pq.js"></script>
<script src="https://cdn.jsdelivr.net/npm/eruda"></script> <!-- debug -->
<script>eruda.init();</script>
<!-- debug
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script> -->
</head>
<body>

View file

@ -1266,7 +1266,7 @@ var currentRoomContentHtml = null;
async function switchRoomContent(title, content, showRoomBar, icon = "", skipMobileSlide = false) {
if (currentRoomContentTitle === title && currentRoomContentHtml === content) {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 52 * rem && !skipMobileSlide) {
if (window.innerWidth <= 55 * rem && !skipMobileSlide) {
if (title != "title.splash") { //do not show splash on mobile
mainScreen.classList.remove('mobile-details');
mainScreen.classList.add('mobile-content');
@ -1286,7 +1286,7 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
roomsTopBarTransition.style.opacity = "0";
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 52 * rem && !skipMobileSlide) {
if (window.innerWidth <= 55 * rem && !skipMobileSlide) {
if (title != "title.splash") //do not show splash on mobile
{
mainScreen.classList.remove('mobile-details');
@ -1298,8 +1298,9 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
let detailsBtnHtml = showRoomBar ? detailsBtn : '';
let pinCallBtns = showRoomBar ? `${pinBtnHtml}${callBtnHtml}` : '';
roomsTopBarTransition.innerHTML = `${backBtnHtml}${icon}<space></space><inherit>${processBlah(title)}</inherit><div class="flex-spacer"></div>${detailsBtnHtml}`;
roomsTopBarTransition.innerHTML = `${backBtnHtml}${icon}<space></space><inherit>${processBlah(title)}</inherit><div class="flex-spacer"></div>${pinCallBtns}${detailsBtnHtml}`;
roomDetailsBar.style.display = showRoomBar ? "flex" : "none";
let parser = new DOMParser();
@ -1350,12 +1351,13 @@ async function switchDetailsContent(title, content) {
roomsTopBarTransition.style.opacity = "0";
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 52 * rem) {
if (window.innerWidth > 55 * rem) {
await delay(200);
}
roomsTopBarTransition.innerHTML = processBlah(title);
let pinCallBtns = `${pinBtnHtml}${callBtnHtml}`;
roomsTopBarTransition.innerHTML = `<inherit>${processBlah(title)}</inherit><div class="flex-spacer"></div>${pinCallBtns}`;
let parser = new DOMParser();
let doc = parser.parseFromString(content, "text/html");
@ -1461,8 +1463,8 @@ document.addEventListener("click", (e) => {
if (mainScreen && mainScreen.classList.contains('mobile-details')) {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 52 * rem) {
if (!roomDetailsBar.contains(e.target) && !e.target.closest('.mobile-nav-btn.right')) {
if (window.innerWidth <= 55 * rem) {
if (!roomDetailsBar.contains(e.target) && !e.target.closest('.mobile-nav-btn.right') && !e.target.closest('.profile-popup-container')) {
mobileNavBack();
}
}
@ -1527,15 +1529,7 @@ async function openProfile(accountId) {
let popupContainer = document.getElementById("profile-popup-container");
let popupInner = document.getElementById("profile-popup-inner");
//show loading
popupInner.innerHTML = `<div style="display: flex; justify-content: center; align-items: center; height: 100%;"><p>${processBlah('action.profile.loading')}</p></div>`;
popupContainer.style.display = "flex";
//force reflow
popupContainer.offsetHeight;
popupContainer.classList.add("show");
isProfileOpen = true;
showAction('action.profile.loading', 'profile-loading');
try {
if (!accountId) accountId = window.id; //var fallback
@ -1644,8 +1638,15 @@ async function openProfile(accountId) {
${actionBtnHtml}
</div>
`;
clearAction('profile-loading');
popupContainer.style.display = "flex";
popupContainer.offsetHeight;
popupContainer.classList.add("show");
isProfileOpen = true;
} catch (e) {
popupInner.innerHTML = `<div style="display: flex; justify-content: center; align-items: center; height: 100%;"><p>${processBlah('error.profile.load.failed')}</p></div>`;
clearAction('profile-loading');
showNotification(processBlah('error.profile.load.failed'), 'error');
console.error(e);
}
}
@ -1884,7 +1885,7 @@ async function gotoInbox() {
await switchRoomsBar("title.inbox", inboxRoomBar);
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 52 * rem) {
if (window.innerWidth > 55 * rem) {
gotoInvites();
}
}
@ -2055,7 +2056,7 @@ document.addEventListener('contextmenu', e => {
function handleMobileSwipe() {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 52 * rem) return;
if (window.innerWidth > 55 * rem) return;
let diffX = touchEndX - touchStartX;
let diffY = touchEndY - touchStartY;
@ -2151,6 +2152,38 @@ function escapeHtml(unsafe) {
.replaceAll(/'/g, "&#039;");
}
async function generateDmMembersHtml(targetId, targetUsername) {
let myIdStr = id + ";" + host;
let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`);
let myPfp = await getAvatarUrl(myIdStr, myUsername);
let targetPfp = await getAvatarUrl(targetId, targetUsername);
let targetDisplayName = targetUsername.split(':')[0];
let myDisplayName = myUsername.split(':')[0];
return `
<div class="list-container no-flex-grow" style="padding: 0 0.5rem; width: 100%;">
<div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%;">
<span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span>
</div>
<button class="room-entry" onclick="openProfile('${targetId}')" style="margin-bottom: 0;">
<img src="${targetPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${targetDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${targetUsername}</span>
</div>
</button>
<button class="room-entry" onclick="openProfile('${myIdStr}')" style="margin-bottom: 0;">
<img src="${myPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${myDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${myUsername}</span>
</div>
</button>
</div>
`;
}
async function openDm(dmId, username, targetId) {
try {
showAction("action.dm.opening", "dmopen");
@ -2172,6 +2205,8 @@ async function openDm(dmId, username, targetId) {
}
await switchRoomContent(username.split(':')[0], chatScreen, true, iconHtml);
let membersHtml = await generateDmMembersHtml(targetId, username);
await switchDetailsContent("title.details", membersHtml);
let msgContainer = document.getElementById("chat-messages");
if (msgContainer) {
@ -2914,37 +2949,44 @@ async function gotoSettings(tab = 'account') {
await switchRoomsBar("title.settings", settingsBar);
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 52 * rem) {
if (window.innerWidth > 55 * rem) {
switchSettingsTab(tab);
}
}
function switchSettingsTab(tabName) {
async function switchSettingsTab(tabName) {
setActiveRoombarItem('tab-settings-' + tabName);
if (tabName === 'account') {
switchRoomContent("title.account", accountSettings, false);
await switchRoomContent("title.account", accountSettings, false);
let userEl = document.getElementById("settings-username");
if (userEl && window.username) userEl.value = window.username;
} else if (tabName === 'profile') {
switchRoomContent("title.profile", profileSettings, false);
setTimeout(async () => {
showAction('title.loading', 'settings-loading');
try {
let pfpUrl = await getAvatarUrl(window.id, window.username + ":" + window.host);
let bannerUrl = `${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.banner`;
if (window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`;
let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.bio`);
clearAction('settings-loading');
await switchRoomContent("title.profile", profileSettings, false);
let pfpEl = document.getElementById("settings-avatar-preview");
if (pfpEl) pfpEl.src = pfpUrl;
let bannerUrl = `${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.banner`;
if (window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`;
let bannerEl = document.getElementById("settings-banner-preview");
if (bannerEl) bannerEl.src = bannerUrl;
let bioEl = document.getElementById("settings-bio");
if (bioEl) {
let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.bio`);
if (!bio.startsWith("error:")) bioEl.value = bio;
}
}, 210);
if (bioEl && !bio.startsWith("error:")) bioEl.value = bio;
} catch (e) {
clearAction('settings-loading');
await switchRoomContent("title.profile", profileSettings, false);
}
} else if (tabName === 'appearance') {
switchRoomContent("title.appearance", appearanceSettings, false);
setTimeout(populateLanguageDropdown, 210);
await switchRoomContent("title.appearance", appearanceSettings, false);
populateLanguageDropdown();
}
}
@ -3096,10 +3138,12 @@ function initCropper() {
// Set aspect ratio
let targetWidth = cropperType === 'avatar' ? 512 : 494;
let targetHeight = cropperType === 'avatar' ? targetWidth : 200;
let aspectRatio = targetWidth / targetHeight;
container.style.height = 'auto';
container.style.maxHeight = 'calc(85vh - 16rem)';
container.style.aspectRatio = `${targetWidth} / ${targetHeight}`;
container.style.maxWidth = `min(30rem, calc((85vh - 16rem) * ${aspectRatio}))`;
// Force layout recalculation
void container.offsetHeight;
@ -3171,63 +3215,119 @@ async function confirmCropper() {
let b64;
let newSrc;
let isAnimated = false;
let gifFrames = null;
let webpDecoded = null;
let parsedGif = null;
if (cropperOriginalType === 'image/gif') {
isAnimated = true;
} else if (cropperOriginalType === 'image/webp') {
showAction("action.processing", "uploadmedia");
try {
let res = await fetch(cropperOriginalB64);
let buffer = await res.arrayBuffer();
let parsedGif = gifuct.parseGIF(buffer);
let frames = gifuct.decompressFrames(parsedGif, true);
let webpFrames = [];
let webpWorker = new Worker('webp-worker.js', { type: 'module' });
webpDecoded = await new Promise((resolve, reject) => {
webpWorker.onmessage = (e) => {
if (e.data.success) resolve(e.data.data);
else reject(new Error(e.data.error));
webpWorker.terminate();
};
webpWorker.onerror = (err) => { reject(err); webpWorker.terminate(); };
webpWorker.postMessage({ action: 'decode', data: new Uint8Array(buffer) });
});
if (webpDecoded && webpDecoded.length > 1) {
isAnimated = true;
}
} catch (e) {
console.error("WebP decode check failed", e);
}
}
if (isAnimated) {
showAction("action.processing", "uploadmedia");
try {
let webpFrames = [];
let tempCanvas = document.createElement('canvas');
let tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCanvas.width = parsedGif.lsd.width;
tempCanvas.height = parsedGif.lsd.height;
let gifCanvas = document.createElement('canvas');
let gifCtx = gifCanvas.getContext('2d', { willReadFrequently: true });
gifCanvas.width = targetWidth;
gifCanvas.height = targetHeight;
let frameImageData = null;
let previousImageData = null;
for (let i = 0; i < frames.length; i++) {
let frame = frames[i];
if (frame.disposalType === 3) {
previousImageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
}
if (!frameImageData || frameImageData.width !== frame.dims.width || frameImageData.height !== frame.dims.height) {
frameImageData = tempCtx.createImageData(frame.dims.width, frame.dims.height);
}
frameImageData.data.set(frame.patch);
let patchCanvas = document.createElement('canvas');
patchCanvas.width = frame.dims.width;
patchCanvas.height = frame.dims.height;
patchCanvas.getContext('2d').putImageData(frameImageData, 0, 0);
tempCtx.drawImage(patchCanvas, frame.dims.left, frame.dims.top);
gifCtx.fillStyle = '#000';
gifCtx.fillRect(0, 0, targetWidth, targetHeight);
gifCtx.drawImage(tempCanvas, drawX, drawY, drawW, drawH);
let frameImgData = gifCtx.getImageData(0, 0, targetWidth, targetHeight);
webpFrames.push({
data: frameImgData.data,
duration: Math.max(20, frame.delay || 0),
config: { lossless: 0, quality: 90 }
});
if (frame.disposalType === 2) {
tempCtx.clearRect(frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height);
} else if (frame.disposalType === 3 && previousImageData) {
tempCtx.putImageData(previousImageData, 0, 0);
if (cropperOriginalType === 'image/gif') {
let res = await fetch(cropperOriginalB64);
let buffer = await res.arrayBuffer();
parsedGif = gifuct.parseGIF(buffer);
gifFrames = gifuct.decompressFrames(parsedGif, true);
tempCanvas.width = parsedGif.lsd.width;
tempCanvas.height = parsedGif.lsd.height;
let frameImageData = null;
let previousImageData = null;
for (let i = 0; i < gifFrames.length; i++) {
let frame = gifFrames[i];
if (frame.disposalType === 3) {
previousImageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
}
if (!frameImageData || frameImageData.width !== frame.dims.width || frameImageData.height !== frame.dims.height) {
frameImageData = tempCtx.createImageData(frame.dims.width, frame.dims.height);
}
frameImageData.data.set(frame.patch);
let patchCanvas = document.createElement('canvas');
patchCanvas.width = frame.dims.width;
patchCanvas.height = frame.dims.height;
patchCanvas.getContext('2d').putImageData(frameImageData, 0, 0);
tempCtx.drawImage(patchCanvas, frame.dims.left, frame.dims.top);
gifCtx.fillStyle = '#000';
gifCtx.fillRect(0, 0, targetWidth, targetHeight);
gifCtx.drawImage(tempCanvas, drawX, drawY, drawW, drawH);
let frameImgData = gifCtx.getImageData(0, 0, targetWidth, targetHeight);
webpFrames.push({
data: frameImgData.data,
duration: Math.max(20, frame.delay || 0),
config: { lossless: 0, quality: 90 }
});
if (frame.disposalType === 2) {
tempCtx.clearRect(frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height);
} else if (frame.disposalType === 3 && previousImageData) {
tempCtx.putImageData(previousImageData, 0, 0);
}
}
} else if (cropperOriginalType === 'image/webp' && webpDecoded) {
tempCanvas.width = webpDecoded[0].width;
tempCanvas.height = webpDecoded[0].height;
for (let i = 0; i < webpDecoded.length; i++) {
let frame = webpDecoded[i];
let frameImageData = new ImageData(
new Uint8ClampedArray(frame.data.buffer, frame.data.byteOffset, frame.data.byteLength),
frame.width,
frame.height
);
tempCtx.putImageData(frameImageData, 0, 0);
gifCtx.fillStyle = '#000';
gifCtx.fillRect(0, 0, targetWidth, targetHeight);
gifCtx.drawImage(tempCanvas, drawX, drawY, drawW, drawH);
let frameImgData = gifCtx.getImageData(0, 0, targetWidth, targetHeight);
webpFrames.push({
data: frameImgData.data,
duration: Math.max(20, frame.duration || 0),
config: { lossless: 0, quality: 90 }
});
}
}
@ -3245,7 +3345,7 @@ async function confirmCropper() {
reject(err);
worker.terminate();
};
worker.postMessage({ targetWidth, targetHeight, webpFrames });
worker.postMessage({ action: 'encode', targetWidth, targetHeight, webpFrames });
});
let webpBlob = new Blob([webpUint8], {type: 'image/webp'});
@ -3258,7 +3358,7 @@ async function confirmCropper() {
});
newSrc = 'data:image/webp;base64,' + b64;
} catch (e) {
console.error("GIF crop failed", e);
console.error("Animated crop failed", e);
b64 = cropperOriginalB64.split(',')[1];
newSrc = cropperOriginalB64;
}

View file

@ -258,6 +258,8 @@ var messageContextMenu = `
//elements
var detailsBtn = `<button class="mobile-nav-btn right" onclick="mobileNavDetails()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/></svg></button>`;
var backBtnHtml = `<button class="mobile-nav-btn" onclick="mobileNavBack()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="m313-440 224 224-57 56-320-320 320-320 57 56-224 224h487v80H313Z"/></svg></button>`;
var pinBtnHtml = `<button class="topbar-action-btn room-action-pin right"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280q0-33 23.5-56.5T400-840h160q33 0 56.5 23.5T640-760v280Z"/></svg></button>`;
var callBtnHtml = `<button class="topbar-action-btn room-action-call right"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M798-120q-125 0-247-54.5T329-329Q229-429 174.5-551T120-798q0-18 12-30t30-12h162q14 0 25 9.5t13 22.5l26 140q2 16-1 27t-11 19l-97 98q20 37 47.5 71.5T387-386q31 31 65 57.5t72 48.5l94-94q9-9 23.5-13.5T670-390l138 28q14 4 23 14.5t9 23.5v162q0 18-12 30t-30 12Z"/></svg></button>`;
var settingsBar = `
<div class="sidebar-section-header">

View file

@ -316,6 +316,8 @@ roomtopbar {
flex-direction: row;
align-items: center;
z-index: 10;
background-color: var(--main-bg-color);
width: 100%;
border-bottom: var(--border-width) solid var(--light-border-color);
@ -414,6 +416,33 @@ space{
font-weight: 600;
}
.topbar-action-btn {
display: flex;
background: transparent;
border: none;
height: 2.4rem;
width: 2.4rem;
padding: 0;
margin: 0;
margin-right: 0.8rem;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-color);
cursor: pointer;
}
.topbar-action-btn:active, .topbar-action-btn.is-pressed {
background-color: rgba(255, 255, 255, 0.1);
transform: var(--press-scale);
}
.topbar-action-btn.right {
margin-right: 0;
margin-left: 0.2rem;
}
.topbar-action-btn svg {
width: 1.5rem;
height: 1.5rem;
}
.mobile-nav-btn {
display: none;
background: transparent;
@ -435,7 +464,7 @@ space{
}
.mobile-nav-btn.right {
margin-right: 0;
margin-left: 0.8rem;
margin-left: 0.2rem;
}
.mobile-nav-btn svg {
width: 1.5rem;
@ -444,7 +473,7 @@ space{
.flex-spacer {
flex-grow: 1;
}
@media (max-width: 52rem) {
@media (max-width: 55rem) {
.mobile-nav-btn {
display: flex;
}
@ -977,3 +1006,22 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
transform: scale(1.2);
background: #fff;
}
roomcontent roomtopbar .room-action-pin,
roomcontent roomtopbar .room-action-call {
display: flex;
}
#roomdetailsbar roomtopbar .room-action-pin,
#roomdetailsbar roomtopbar .room-action-call {
display: none;
}
@media (max-width: 65rem) and (min-width: 55.001rem) {
roomcontent roomtopbar .room-action-pin { display: none !important; }
#roomdetailsbar roomtopbar .room-action-pin { display: flex !important; }
}
@media (max-width: 58rem) and (min-width: 55.001rem) {
roomcontent roomtopbar .room-action-call { display: none !important; }
#roomdetailsbar roomtopbar .room-action-call { display: flex !important; }
}

View file

@ -1,11 +1,18 @@
import { encodeAnimation } from './wasm-webp/index.js';
import { encodeAnimation, decodeAnimation } from './wasm-webp/index.js';
self.onmessage = async (e) => {
let { targetWidth, targetHeight, webpFrames } = e.data;
let action = e.data.action || 'encode';
try {
let webpUint8 = await encodeAnimation(targetWidth, targetHeight, true, webpFrames);
self.postMessage({ success: true, data: webpUint8 }, [webpUint8.buffer]);
if (action === 'decode') {
let decoded = await decodeAnimation(e.data.data, true);
let transferables = decoded.map(f => f.data.buffer);
self.postMessage({ success: true, data: decoded, action }, transferables);
} else {
let { targetWidth, targetHeight, webpFrames } = e.data;
let webpUint8 = await encodeAnimation(targetWidth, targetHeight, true, webpFrames);
self.postMessage({ success: true, data: webpUint8, action }, [webpUint8.buffer]);
}
} catch (err) {
self.postMessage({ success: false, error: err.toString() });
self.postMessage({ success: false, error: err.toString(), action });
}
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -120,6 +120,9 @@
"title.captcha.code": "captcha code",
"title.invitation.code": "invitation code",
"title.back.to.register": "back to registration",
"title.details": "details",
"title.members": "cats",
"placeholder.username": "cat name",
"placeholder.captcha.code": "captcha code",
"desc.messages.loading": "loading meows...",
@ -151,6 +154,7 @@
"title.profile": "cat profile",
"title.settings": "cat settings",
"title.logout": "log out",
"title.loading": "sniffing...",
"action.edit.profile": "edit cat profile",
"action.open.dm": "open direct meowchat",
"action.invite.accept": "accept meow invite",

View file

@ -151,7 +151,10 @@
"title.profile": "Profile",
"title.settings": "Settings",
"title.logout": "Log out",
"title.loading": "Loading...",
"title.details": "Details",
"title.members": "Members",
"action.edit.profile": "Edit profile",
"action.open.dm": "Open DM",
"action.invite.accept": "Accept invite",

View file

@ -8,8 +8,9 @@
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<script src="crypto-pq.js"></script>
<script src="https://cdn.jsdelivr.net/npm/eruda"></script> <!-- debug -->
<script>eruda.init();</script>
<!-- debug
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script> -->
</head>
<body>

View file

@ -1266,7 +1266,7 @@ var currentRoomContentHtml = null;
async function switchRoomContent(title, content, showRoomBar, icon = "", skipMobileSlide = false) {
if (currentRoomContentTitle === title && currentRoomContentHtml === content) {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 52 * rem && !skipMobileSlide) {
if (window.innerWidth <= 55 * rem && !skipMobileSlide) {
if (title != "title.splash") { //do not show splash on mobile
mainScreen.classList.remove('mobile-details');
mainScreen.classList.add('mobile-content');
@ -1286,7 +1286,7 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
roomsTopBarTransition.style.opacity = "0";
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 52 * rem && !skipMobileSlide) {
if (window.innerWidth <= 55 * rem && !skipMobileSlide) {
if (title != "title.splash") //do not show splash on mobile
{
mainScreen.classList.remove('mobile-details');
@ -1298,8 +1298,9 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
let detailsBtnHtml = showRoomBar ? detailsBtn : '';
let pinCallBtns = showRoomBar ? `${pinBtnHtml}${callBtnHtml}` : '';
roomsTopBarTransition.innerHTML = `${backBtnHtml}${icon}<space></space><inherit>${processBlah(title)}</inherit><div class="flex-spacer"></div>${detailsBtnHtml}`;
roomsTopBarTransition.innerHTML = `${backBtnHtml}${icon}<space></space><inherit>${processBlah(title)}</inherit><div class="flex-spacer"></div>${pinCallBtns}${detailsBtnHtml}`;
roomDetailsBar.style.display = showRoomBar ? "flex" : "none";
let parser = new DOMParser();
@ -1350,12 +1351,13 @@ async function switchDetailsContent(title, content) {
roomsTopBarTransition.style.opacity = "0";
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 52 * rem) {
if (window.innerWidth > 55 * rem) {
await delay(200);
}
roomsTopBarTransition.innerHTML = processBlah(title);
let pinCallBtns = `${pinBtnHtml}${callBtnHtml}`;
roomsTopBarTransition.innerHTML = `<inherit>${processBlah(title)}</inherit><div class="flex-spacer"></div>${pinCallBtns}`;
let parser = new DOMParser();
let doc = parser.parseFromString(content, "text/html");
@ -1461,8 +1463,8 @@ document.addEventListener("click", (e) => {
if (mainScreen && mainScreen.classList.contains('mobile-details')) {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 52 * rem) {
if (!roomDetailsBar.contains(e.target) && !e.target.closest('.mobile-nav-btn.right')) {
if (window.innerWidth <= 55 * rem) {
if (!roomDetailsBar.contains(e.target) && !e.target.closest('.mobile-nav-btn.right') && !e.target.closest('.profile-popup-container')) {
mobileNavBack();
}
}
@ -1527,15 +1529,7 @@ async function openProfile(accountId) {
let popupContainer = document.getElementById("profile-popup-container");
let popupInner = document.getElementById("profile-popup-inner");
//show loading
popupInner.innerHTML = `<div style="display: flex; justify-content: center; align-items: center; height: 100%;"><p>${processBlah('action.profile.loading')}</p></div>`;
popupContainer.style.display = "flex";
//force reflow
popupContainer.offsetHeight;
popupContainer.classList.add("show");
isProfileOpen = true;
showAction('action.profile.loading', 'profile-loading');
try {
if (!accountId) accountId = window.id; //var fallback
@ -1644,8 +1638,15 @@ async function openProfile(accountId) {
${actionBtnHtml}
</div>
`;
clearAction('profile-loading');
popupContainer.style.display = "flex";
popupContainer.offsetHeight;
popupContainer.classList.add("show");
isProfileOpen = true;
} catch (e) {
popupInner.innerHTML = `<div style="display: flex; justify-content: center; align-items: center; height: 100%;"><p>${processBlah('error.profile.load.failed')}</p></div>`;
clearAction('profile-loading');
showNotification(processBlah('error.profile.load.failed'), 'error');
console.error(e);
}
}
@ -1884,7 +1885,7 @@ async function gotoInbox() {
await switchRoomsBar("title.inbox", inboxRoomBar);
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 52 * rem) {
if (window.innerWidth > 55 * rem) {
gotoInvites();
}
}
@ -2055,7 +2056,7 @@ document.addEventListener('contextmenu', e => {
function handleMobileSwipe() {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 52 * rem) return;
if (window.innerWidth > 55 * rem) return;
let diffX = touchEndX - touchStartX;
let diffY = touchEndY - touchStartY;
@ -2151,6 +2152,38 @@ function escapeHtml(unsafe) {
.replaceAll(/'/g, "&#039;");
}
async function generateDmMembersHtml(targetId, targetUsername) {
let myIdStr = id + ";" + host;
let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`);
let myPfp = await getAvatarUrl(myIdStr, myUsername);
let targetPfp = await getAvatarUrl(targetId, targetUsername);
let targetDisplayName = targetUsername.split(':')[0];
let myDisplayName = myUsername.split(':')[0];
return `
<div class="list-container no-flex-grow" style="padding: 0 0.5rem; width: 100%;">
<div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%;">
<span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span>
</div>
<button class="room-entry" onclick="openProfile('${targetId}')" style="margin-bottom: 0;">
<img src="${targetPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${targetDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${targetUsername}</span>
</div>
</button>
<button class="room-entry" onclick="openProfile('${myIdStr}')" style="margin-bottom: 0;">
<img src="${myPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${myDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${myUsername}</span>
</div>
</button>
</div>
`;
}
async function openDm(dmId, username, targetId) {
try {
showAction("action.dm.opening", "dmopen");
@ -2172,6 +2205,8 @@ async function openDm(dmId, username, targetId) {
}
await switchRoomContent(username.split(':')[0], chatScreen, true, iconHtml);
let membersHtml = await generateDmMembersHtml(targetId, username);
await switchDetailsContent("title.details", membersHtml);
let msgContainer = document.getElementById("chat-messages");
if (msgContainer) {
@ -2914,37 +2949,44 @@ async function gotoSettings(tab = 'account') {
await switchRoomsBar("title.settings", settingsBar);
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 52 * rem) {
if (window.innerWidth > 55 * rem) {
switchSettingsTab(tab);
}
}
function switchSettingsTab(tabName) {
async function switchSettingsTab(tabName) {
setActiveRoombarItem('tab-settings-' + tabName);
if (tabName === 'account') {
switchRoomContent("title.account", accountSettings, false);
await switchRoomContent("title.account", accountSettings, false);
let userEl = document.getElementById("settings-username");
if (userEl && window.username) userEl.value = window.username;
} else if (tabName === 'profile') {
switchRoomContent("title.profile", profileSettings, false);
setTimeout(async () => {
showAction('title.loading', 'settings-loading');
try {
let pfpUrl = await getAvatarUrl(window.id, window.username + ":" + window.host);
let bannerUrl = `${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.banner`;
if (window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`;
let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.bio`);
clearAction('settings-loading');
await switchRoomContent("title.profile", profileSettings, false);
let pfpEl = document.getElementById("settings-avatar-preview");
if (pfpEl) pfpEl.src = pfpUrl;
let bannerUrl = `${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.banner`;
if (window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`;
let bannerEl = document.getElementById("settings-banner-preview");
if (bannerEl) bannerEl.src = bannerUrl;
let bioEl = document.getElementById("settings-bio");
if (bioEl) {
let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.bio`);
if (!bio.startsWith("error:")) bioEl.value = bio;
}
}, 210);
if (bioEl && !bio.startsWith("error:")) bioEl.value = bio;
} catch (e) {
clearAction('settings-loading');
await switchRoomContent("title.profile", profileSettings, false);
}
} else if (tabName === 'appearance') {
switchRoomContent("title.appearance", appearanceSettings, false);
setTimeout(populateLanguageDropdown, 210);
await switchRoomContent("title.appearance", appearanceSettings, false);
populateLanguageDropdown();
}
}
@ -3096,10 +3138,12 @@ function initCropper() {
// Set aspect ratio
let targetWidth = cropperType === 'avatar' ? 512 : 494;
let targetHeight = cropperType === 'avatar' ? targetWidth : 200;
let aspectRatio = targetWidth / targetHeight;
container.style.height = 'auto';
container.style.maxHeight = 'calc(85vh - 16rem)';
container.style.aspectRatio = `${targetWidth} / ${targetHeight}`;
container.style.maxWidth = `min(30rem, calc((85vh - 16rem) * ${aspectRatio}))`;
// Force layout recalculation
void container.offsetHeight;
@ -3171,63 +3215,119 @@ async function confirmCropper() {
let b64;
let newSrc;
let isAnimated = false;
let gifFrames = null;
let webpDecoded = null;
let parsedGif = null;
if (cropperOriginalType === 'image/gif') {
isAnimated = true;
} else if (cropperOriginalType === 'image/webp') {
showAction("action.processing", "uploadmedia");
try {
let res = await fetch(cropperOriginalB64);
let buffer = await res.arrayBuffer();
let parsedGif = gifuct.parseGIF(buffer);
let frames = gifuct.decompressFrames(parsedGif, true);
let webpFrames = [];
let webpWorker = new Worker('webp-worker.js', { type: 'module' });
webpDecoded = await new Promise((resolve, reject) => {
webpWorker.onmessage = (e) => {
if (e.data.success) resolve(e.data.data);
else reject(new Error(e.data.error));
webpWorker.terminate();
};
webpWorker.onerror = (err) => { reject(err); webpWorker.terminate(); };
webpWorker.postMessage({ action: 'decode', data: new Uint8Array(buffer) });
});
if (webpDecoded && webpDecoded.length > 1) {
isAnimated = true;
}
} catch (e) {
console.error("WebP decode check failed", e);
}
}
if (isAnimated) {
showAction("action.processing", "uploadmedia");
try {
let webpFrames = [];
let tempCanvas = document.createElement('canvas');
let tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCanvas.width = parsedGif.lsd.width;
tempCanvas.height = parsedGif.lsd.height;
let gifCanvas = document.createElement('canvas');
let gifCtx = gifCanvas.getContext('2d', { willReadFrequently: true });
gifCanvas.width = targetWidth;
gifCanvas.height = targetHeight;
let frameImageData = null;
let previousImageData = null;
for (let i = 0; i < frames.length; i++) {
let frame = frames[i];
if (frame.disposalType === 3) {
previousImageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
}
if (!frameImageData || frameImageData.width !== frame.dims.width || frameImageData.height !== frame.dims.height) {
frameImageData = tempCtx.createImageData(frame.dims.width, frame.dims.height);
}
frameImageData.data.set(frame.patch);
let patchCanvas = document.createElement('canvas');
patchCanvas.width = frame.dims.width;
patchCanvas.height = frame.dims.height;
patchCanvas.getContext('2d').putImageData(frameImageData, 0, 0);
tempCtx.drawImage(patchCanvas, frame.dims.left, frame.dims.top);
gifCtx.fillStyle = '#000';
gifCtx.fillRect(0, 0, targetWidth, targetHeight);
gifCtx.drawImage(tempCanvas, drawX, drawY, drawW, drawH);
let frameImgData = gifCtx.getImageData(0, 0, targetWidth, targetHeight);
webpFrames.push({
data: frameImgData.data,
duration: Math.max(20, frame.delay || 0),
config: { lossless: 0, quality: 90 }
});
if (frame.disposalType === 2) {
tempCtx.clearRect(frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height);
} else if (frame.disposalType === 3 && previousImageData) {
tempCtx.putImageData(previousImageData, 0, 0);
if (cropperOriginalType === 'image/gif') {
let res = await fetch(cropperOriginalB64);
let buffer = await res.arrayBuffer();
parsedGif = gifuct.parseGIF(buffer);
gifFrames = gifuct.decompressFrames(parsedGif, true);
tempCanvas.width = parsedGif.lsd.width;
tempCanvas.height = parsedGif.lsd.height;
let frameImageData = null;
let previousImageData = null;
for (let i = 0; i < gifFrames.length; i++) {
let frame = gifFrames[i];
if (frame.disposalType === 3) {
previousImageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
}
if (!frameImageData || frameImageData.width !== frame.dims.width || frameImageData.height !== frame.dims.height) {
frameImageData = tempCtx.createImageData(frame.dims.width, frame.dims.height);
}
frameImageData.data.set(frame.patch);
let patchCanvas = document.createElement('canvas');
patchCanvas.width = frame.dims.width;
patchCanvas.height = frame.dims.height;
patchCanvas.getContext('2d').putImageData(frameImageData, 0, 0);
tempCtx.drawImage(patchCanvas, frame.dims.left, frame.dims.top);
gifCtx.fillStyle = '#000';
gifCtx.fillRect(0, 0, targetWidth, targetHeight);
gifCtx.drawImage(tempCanvas, drawX, drawY, drawW, drawH);
let frameImgData = gifCtx.getImageData(0, 0, targetWidth, targetHeight);
webpFrames.push({
data: frameImgData.data,
duration: Math.max(20, frame.delay || 0),
config: { lossless: 0, quality: 90 }
});
if (frame.disposalType === 2) {
tempCtx.clearRect(frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height);
} else if (frame.disposalType === 3 && previousImageData) {
tempCtx.putImageData(previousImageData, 0, 0);
}
}
} else if (cropperOriginalType === 'image/webp' && webpDecoded) {
tempCanvas.width = webpDecoded[0].width;
tempCanvas.height = webpDecoded[0].height;
for (let i = 0; i < webpDecoded.length; i++) {
let frame = webpDecoded[i];
let frameImageData = new ImageData(
new Uint8ClampedArray(frame.data.buffer, frame.data.byteOffset, frame.data.byteLength),
frame.width,
frame.height
);
tempCtx.putImageData(frameImageData, 0, 0);
gifCtx.fillStyle = '#000';
gifCtx.fillRect(0, 0, targetWidth, targetHeight);
gifCtx.drawImage(tempCanvas, drawX, drawY, drawW, drawH);
let frameImgData = gifCtx.getImageData(0, 0, targetWidth, targetHeight);
webpFrames.push({
data: frameImgData.data,
duration: Math.max(20, frame.duration || 0),
config: { lossless: 0, quality: 90 }
});
}
}
@ -3245,7 +3345,7 @@ async function confirmCropper() {
reject(err);
worker.terminate();
};
worker.postMessage({ targetWidth, targetHeight, webpFrames });
worker.postMessage({ action: 'encode', targetWidth, targetHeight, webpFrames });
});
let webpBlob = new Blob([webpUint8], {type: 'image/webp'});
@ -3258,7 +3358,7 @@ async function confirmCropper() {
});
newSrc = 'data:image/webp;base64,' + b64;
} catch (e) {
console.error("GIF crop failed", e);
console.error("Animated crop failed", e);
b64 = cropperOriginalB64.split(',')[1];
newSrc = cropperOriginalB64;
}

View file

@ -258,6 +258,8 @@ var messageContextMenu = `
//elements
var detailsBtn = `<button class="mobile-nav-btn right" onclick="mobileNavDetails()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/></svg></button>`;
var backBtnHtml = `<button class="mobile-nav-btn" onclick="mobileNavBack()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="m313-440 224 224-57 56-320-320 320-320 57 56-224 224h487v80H313Z"/></svg></button>`;
var pinBtnHtml = `<button class="topbar-action-btn room-action-pin right"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280q0-33 23.5-56.5T400-840h160q33 0 56.5 23.5T640-760v280Z"/></svg></button>`;
var callBtnHtml = `<button class="topbar-action-btn room-action-call right"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M798-120q-125 0-247-54.5T329-329Q229-429 174.5-551T120-798q0-18 12-30t30-12h162q14 0 25 9.5t13 22.5l26 140q2 16-1 27t-11 19l-97 98q20 37 47.5 71.5T387-386q31 31 65 57.5t72 48.5l94-94q9-9 23.5-13.5T670-390l138 28q14 4 23 14.5t9 23.5v162q0 18-12 30t-30 12Z"/></svg></button>`;
var settingsBar = `
<div class="sidebar-section-header">

View file

@ -316,6 +316,8 @@ roomtopbar {
flex-direction: row;
align-items: center;
z-index: 10;
background-color: var(--main-bg-color);
width: 100%;
border-bottom: var(--border-width) solid var(--light-border-color);
@ -414,6 +416,33 @@ space{
font-weight: 600;
}
.topbar-action-btn {
display: flex;
background: transparent;
border: none;
height: 2.4rem;
width: 2.4rem;
padding: 0;
margin: 0;
margin-right: 0.8rem;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-color);
cursor: pointer;
}
.topbar-action-btn:active, .topbar-action-btn.is-pressed {
background-color: rgba(255, 255, 255, 0.1);
transform: var(--press-scale);
}
.topbar-action-btn.right {
margin-right: 0;
margin-left: 0.2rem;
}
.topbar-action-btn svg {
width: 1.5rem;
height: 1.5rem;
}
.mobile-nav-btn {
display: none;
background: transparent;
@ -435,7 +464,7 @@ space{
}
.mobile-nav-btn.right {
margin-right: 0;
margin-left: 0.8rem;
margin-left: 0.2rem;
}
.mobile-nav-btn svg {
width: 1.5rem;
@ -444,7 +473,7 @@ space{
.flex-spacer {
flex-grow: 1;
}
@media (max-width: 52rem) {
@media (max-width: 55rem) {
.mobile-nav-btn {
display: flex;
}
@ -977,3 +1006,22 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
transform: scale(1.2);
background: #fff;
}
roomcontent roomtopbar .room-action-pin,
roomcontent roomtopbar .room-action-call {
display: flex;
}
#roomdetailsbar roomtopbar .room-action-pin,
#roomdetailsbar roomtopbar .room-action-call {
display: none;
}
@media (max-width: 65rem) and (min-width: 55.001rem) {
roomcontent roomtopbar .room-action-pin { display: none !important; }
#roomdetailsbar roomtopbar .room-action-pin { display: flex !important; }
}
@media (max-width: 58rem) and (min-width: 55.001rem) {
roomcontent roomtopbar .room-action-call { display: none !important; }
#roomdetailsbar roomtopbar .room-action-call { display: flex !important; }
}

View file

@ -1,11 +1,18 @@
import { encodeAnimation } from './wasm-webp/index.js';
import { encodeAnimation, decodeAnimation } from './wasm-webp/index.js';
self.onmessage = async (e) => {
let { targetWidth, targetHeight, webpFrames } = e.data;
let action = e.data.action || 'encode';
try {
let webpUint8 = await encodeAnimation(targetWidth, targetHeight, true, webpFrames);
self.postMessage({ success: true, data: webpUint8 }, [webpUint8.buffer]);
if (action === 'decode') {
let decoded = await decodeAnimation(e.data.data, true);
let transferables = decoded.map(f => f.data.buffer);
self.postMessage({ success: true, data: decoded, action }, transferables);
} else {
let { targetWidth, targetHeight, webpFrames } = e.data;
let webpUint8 = await encodeAnimation(targetWidth, targetHeight, true, webpFrames);
self.postMessage({ success: true, data: webpUint8, action }, [webpUint8.buffer]);
}
} catch (err) {
self.postMessage({ success: false, error: err.toString() });
self.postMessage({ success: false, error: err.toString(), action });
}
};