better context menu reposition, add more profile actions
All checks were successful
Android Build / publish (push) Successful in 34s
Linux Build / publish (push) Successful in 53s

This commit is contained in:
olcxja 2026-06-19 22:17:33 +02:00
commit 3f99389413
10 changed files with 260 additions and 60 deletions

View file

@ -144,5 +144,11 @@
"title.settings": "cat settings",
"title.logout": "log out",
"action.edit.profile": "edit cat profile",
"action.open.dm": "open direct meowchat"
"action.open.dm": "open direct meowchat",
"action.invite.accept": "accept meow invite",
"action.invite.cancel": "hiss away invite",
"action.invite.send": "invite to meowchat",
"action.profile.loading": "sniffing profile...",
"error.profile.load.failed": "failed to sniff profile.",
"unknown.author": "somecat"
}

View file

@ -144,5 +144,11 @@
"title.settings": "Settings",
"title.logout": "Log out",
"action.edit.profile": "Edit profile",
"action.open.dm": "Open DM"
"action.open.dm": "Open DM",
"action.invite.accept": "Accept invite",
"action.invite.cancel": "Cancel invite",
"action.invite.send": "Invite to DM",
"action.profile.loading": "Loading profile...",
"error.profile.load.failed": "Failed to load profile.",
"unknown.author": "Someone"
}

View file

@ -1467,7 +1467,7 @@ async function openProfile(accountId) {
let popupInner = document.getElementById("profile-popup-inner");
//show loading
popupInner.innerHTML = '<div style="display: flex; justify-content: center; align-items: center; height: 100%;"><p>Loading profile...</p></div>';
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
@ -1496,9 +1496,77 @@ async function openProfile(accountId) {
let bioHtml = bio && bio.trim() !== "" ? `<div class="profile-bio">${bio.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</div>` : "";
let isOwnProfile = accountId === id;
let actionBtnHtml = isOwnProfile ?
`<button class="profile-action-btn">Edit Profile</button>` :
`<button class="profile-action-btn">Open DM</button>`;
let actionBtnHtml = "";
if (isOwnProfile) {
actionBtnHtml = `<button class="profile-action-btn">${processBlah('action.edit.profile')}</button>`;
} else {
let hasDm = false;
let dmRoomId = "";
if (window.cachedDms) {
try {
let parsedDms = JSON.parse(window.cachedDms);
if (parsedDms.dms) {
for (let dmId in parsedDms.dms) {
let parts = dmId.split('_');
if (parts.length >= 2) {
let accStandard = accountId.replace(':', ';');
if (!accStandard.includes(';')) accStandard += ';' + host;
let p0Standard = parts[0].replace(':', ';');
if (!p0Standard.includes(';')) p0Standard += ';' + host;
let p1Standard = parts[1].replace(':', ';');
if (!p1Standard.includes(';')) p1Standard += ';' + host;
if (p0Standard === accStandard || p1Standard === accStandard) {
hasDm = true;
dmRoomId = dmId;
break;
}
}
}
}
} catch(e){}
}
if (hasDm) {
actionBtnHtml = `<button class="profile-action-btn" onclick="openDm('${dmRoomId}', '${profileName}', '${accountId}'); closeProfile();">${processBlah('action.open.dm')}</button>`;
} else {
let sentInvite = false;
let receivedInvite = false;
if (window.cachedInvites) {
let checkInvite = (type) => {
if (window.cachedInvites[type]) {
try {
let invites = JSON.parse(window.cachedInvites[type]);
if (invites.dms) {
for(let key in invites.dms) {
let accStandard = accountId.replace(':', ';');
if (!accStandard.includes(';')) accStandard += ';' + host;
let keyStandard = key.replace(':', ';');
if (!keyStandard.includes(';')) keyStandard += ';' + host;
if (keyStandard === accStandard) return true;
}
}
} catch(e){}
}
return false;
};
sentInvite = checkInvite('sent');
receivedInvite = checkInvite('received');
}
if (receivedInvite) {
actionBtnHtml = `<button class="profile-action-btn" onclick="acceptInvite('${accountId}'); closeProfile();">${processBlah('action.invite.accept')}</button>`;
} else if (sentInvite) {
actionBtnHtml = `<button class="profile-action-btn" onclick="revokeInvite('${accountId}'); closeProfile();">${processBlah('action.invite.cancel')}</button>`;
} else {
actionBtnHtml = `<button class="profile-action-btn" onclick="sendInviteTo('${accountId}'); closeProfile();">${processBlah('action.invite.send')}</button>`;
}
}
}
popupInner.innerHTML = `
${bannerHtml}
@ -1508,14 +1576,14 @@ async function openProfile(accountId) {
<div class="profile-info">
<div>
<div class="profile-name">${profileName}</div>
<div class="profile-id-host">${profileName}:${profileHost} (${accountId})</div>
<div class="profile-id-host">${profileName}:${profileHost} (${accountId.split(':')[0]})</div>
</div>
${bioHtml}
${actionBtnHtml}
</div>
`;
} catch (e) {
popupInner.innerHTML = '<div style="display: flex; justify-content: center; align-items: center; height: 100%;"><p>Failed to load profile.</p></div>';
popupInner.innerHTML = `<div style="display: flex; justify-content: center; align-items: center; height: 100%;"><p>${processBlah('error.profile.load.failed')}</p></div>`;
console.error(e);
}
}
@ -1608,7 +1676,8 @@ async function renderInvites(res, type) {
.replaceAll("{username}", username.split(':')[0])
.replaceAll("{pfp}", pfp)
.replaceAll("{desc}", desc)
.replaceAll("{actions}", actions);
.replaceAll("{actions}", actions)
.replaceAll("{id}", id);
html += entry;
}
@ -1659,6 +1728,19 @@ async function switchInvitesTab(tab) {
await loadInvites(tab);
}
async function sendInviteTo(targetId) {
try {
showAction("action.invite.sending", "invite.action");
let res = await fetchEncrypted("user/dm/invite", targetId);
clearAction("invite.action");
showBlahNotification(res || "success:invite.sent");
if (typeof loadInvites === 'function') await loadInvites('sent');
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function acceptInvite(targetId) { //TODO: Implement key generation
try {
showAction("action.invite.accepting", "invite.action");
@ -1769,10 +1851,12 @@ function gotoJoinSpace() {
switchRoomContent("title.join.space", joinSpaceScreen, false);
}
async function gotoHome() {
async function gotoHome(splash = true) {
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
setActiveSidebarIndicator(sidebarHomeIndicator);
switchRoomContent("title.splash", splashScreen, false);
if (splash) {
switchRoomContent("title.splash", splashScreen, false);
}
switchRoomsBar("title.home", homeRoomBar);
setActiveRoombarItem(null);
setupWebSocket();
@ -2010,6 +2094,11 @@ async function openDm(dmId, username, targetId) {
let pfp = await getAvatarUrl(targetId, username);
let iconHtml = `<img src="${pfp}" style="width: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
if (currentRoomsBarTitle !== "title.home") {
gotoHome(false);
}
await switchRoomContent(username.split(':')[0], chatScreen, true, iconHtml);
let msgContainer = document.getElementById("chat-messages");
@ -2155,7 +2244,7 @@ async function renderMessages(messages, isPrepend = false) {
respondedAuthorId = parts[1];
}
let replyAuthor = "Someone";
let replyAuthor = processBlah('unknown.author');
let needsAsyncFetch = false;
if (loadedMessages[respondedId] && loadedMessages[respondedId].author !== "0") {
@ -2183,7 +2272,7 @@ async function renderMessages(messages, isPrepend = false) {
window.replyIdCounter = (window.replyIdCounter || 0) + 1;
if (window.replyIdCounter > 1000000) window.replyIdCounter = 0;
randId = "reply-author-" + window.replyIdCounter;
replyAuthor = "Someone";
replyAuthor = processBlah('unknown.author');
setTimeout(async () => {
try {
let aId = respondedAuthorId;
@ -2262,10 +2351,10 @@ async function renderMessages(messages, isPrepend = false) {
html += `
<div class="chat-message ${extraClass}" data-msg-id="${msgId}" id="msg-${msgId}">
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="openProfile('${msg.author}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
<div class="chat-message-content" oncontextmenu="handleMessageContextMenu(event, '${msgId}')">
${showAvatar ? `<div class="chat-message-header">
<span class="chat-message-author">${authorName}</span>
<span class="chat-message-author" onclick="openProfile('${msg.author}')" style="cursor: pointer;">${authorName}</span>
<span class="chat-message-timestamp">${timeStr}</span>
</div>` : ""}
${repliedHtml}
@ -2307,8 +2396,16 @@ window.visualViewport?.addEventListener("resize", async () => {
isAdjusting = true;
while (isAdjusting) {
//chat scroll fix
ignoreScroll = true;
container.scrollTop = container.scrollHeight - container.clientHeight - lastChatScroll;
//context menu fix
let menuRect = fixedContextMenu.getBoundingClientRect();
if (menuRect.bottom > window.innerHeight) {
fixedContextMenu.style.top = `${Math.max(10, window.innerHeight - menuRect.height - 10)}px`;
}
requestAnimationFrame(() => {
ignoreScroll = false;
});
@ -2585,7 +2682,7 @@ async function replyMessage(msgId) {
let bar = document.getElementById("replying-bar");
if (bar) {
let msg = loadedMessages[msgId];
let authorName = "Someone";
let authorName = processBlah('unknown.author');
if (msg && msg.author !== "0" && dmUsernameCache[msg.author]) {
authorName = dmUsernameCache[msg.author].split(':')[0];
}
@ -2670,16 +2767,7 @@ function openReactionPicker(e) {
let rect = fixedContextMenu.getBoundingClientRect();
showFixedContextMenu({top: rect.top, right: rect.left, bottom: rect.top, left: rect.left}, reactionPickerMenuHtml);
let searchInput = document.getElementById('reaction-picker-search');
if (searchInput) {
searchInput.addEventListener('focus', () => { //TODO: use code from resize event
setTimeout(() => {
let menuRect = fixedContextMenu.getBoundingClientRect();
if (menuRect.bottom > window.innerHeight) {
fixedContextMenu.style.top = `${Math.max(10, window.innerHeight - menuRect.height - 10)}px`;
}
}, 500);
});
}
}
async function reactMessagePrompt(msgId, quickReaction = null) {

View file

@ -117,9 +117,9 @@ var notifisScreen = `
var invitesEntry = `
<div class="invite-entry">
<div class="invite-info">
<img src="{pfp}" class="invite-pfp">
<img src="{pfp}" class="invite-pfp" onclick="openProfile('{id}')" style="cursor: pointer;">
<div class="invite-details">
<span class="invite-name">{username}</span>
<span class="invite-name" onclick="openProfile('{id}')" style="cursor: pointer;">{username}</span>
<span class="invite-desc"><blah>{desc}</blah></span>
</div>
</div>

View file

@ -811,14 +811,14 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.5);
opacity: 0;
transform: scale(0.6);
transition: transform 0.2s cubic-bezier(0.4, -0.3, 0.7, 1), opacity 0.2s ease;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
}
.profile-popup-container.show .profile-popup-content {
transform: scale(1);
opacity: 1;
transition: transform 0.25s cubic-bezier(0.2, 1.15, 0.4, 1), opacity 0.25s ease;
transition: all 0.2s ease;
}
.profile-popup-close-btn {
position: absolute;

View file

@ -144,5 +144,11 @@
"title.settings": "cat settings",
"title.logout": "log out",
"action.edit.profile": "edit cat profile",
"action.open.dm": "open direct meowchat"
"action.open.dm": "open direct meowchat",
"action.invite.accept": "accept meow invite",
"action.invite.cancel": "hiss away invite",
"action.invite.send": "invite to meowchat",
"action.profile.loading": "sniffing profile...",
"error.profile.load.failed": "failed to sniff profile.",
"unknown.author": "somecat"
}

View file

@ -144,5 +144,11 @@
"title.settings": "Settings",
"title.logout": "Log out",
"action.edit.profile": "Edit profile",
"action.open.dm": "Open DM"
"action.open.dm": "Open DM",
"action.invite.accept": "Accept invite",
"action.invite.cancel": "Cancel invite",
"action.invite.send": "Invite to DM",
"action.profile.loading": "Loading profile...",
"error.profile.load.failed": "Failed to load profile.",
"unknown.author": "Someone"
}

View file

@ -1467,7 +1467,7 @@ async function openProfile(accountId) {
let popupInner = document.getElementById("profile-popup-inner");
//show loading
popupInner.innerHTML = '<div style="display: flex; justify-content: center; align-items: center; height: 100%;"><p>Loading profile...</p></div>';
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
@ -1496,9 +1496,77 @@ async function openProfile(accountId) {
let bioHtml = bio && bio.trim() !== "" ? `<div class="profile-bio">${bio.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</div>` : "";
let isOwnProfile = accountId === id;
let actionBtnHtml = isOwnProfile ?
`<button class="profile-action-btn">Edit Profile</button>` :
`<button class="profile-action-btn">Open DM</button>`;
let actionBtnHtml = "";
if (isOwnProfile) {
actionBtnHtml = `<button class="profile-action-btn">${processBlah('action.edit.profile')}</button>`;
} else {
let hasDm = false;
let dmRoomId = "";
if (window.cachedDms) {
try {
let parsedDms = JSON.parse(window.cachedDms);
if (parsedDms.dms) {
for (let dmId in parsedDms.dms) {
let parts = dmId.split('_');
if (parts.length >= 2) {
let accStandard = accountId.replace(':', ';');
if (!accStandard.includes(';')) accStandard += ';' + host;
let p0Standard = parts[0].replace(':', ';');
if (!p0Standard.includes(';')) p0Standard += ';' + host;
let p1Standard = parts[1].replace(':', ';');
if (!p1Standard.includes(';')) p1Standard += ';' + host;
if (p0Standard === accStandard || p1Standard === accStandard) {
hasDm = true;
dmRoomId = dmId;
break;
}
}
}
}
} catch(e){}
}
if (hasDm) {
actionBtnHtml = `<button class="profile-action-btn" onclick="openDm('${dmRoomId}', '${profileName}', '${accountId}'); closeProfile();">${processBlah('action.open.dm')}</button>`;
} else {
let sentInvite = false;
let receivedInvite = false;
if (window.cachedInvites) {
let checkInvite = (type) => {
if (window.cachedInvites[type]) {
try {
let invites = JSON.parse(window.cachedInvites[type]);
if (invites.dms) {
for(let key in invites.dms) {
let accStandard = accountId.replace(':', ';');
if (!accStandard.includes(';')) accStandard += ';' + host;
let keyStandard = key.replace(':', ';');
if (!keyStandard.includes(';')) keyStandard += ';' + host;
if (keyStandard === accStandard) return true;
}
}
} catch(e){}
}
return false;
};
sentInvite = checkInvite('sent');
receivedInvite = checkInvite('received');
}
if (receivedInvite) {
actionBtnHtml = `<button class="profile-action-btn" onclick="acceptInvite('${accountId}'); closeProfile();">${processBlah('action.invite.accept')}</button>`;
} else if (sentInvite) {
actionBtnHtml = `<button class="profile-action-btn" onclick="revokeInvite('${accountId}'); closeProfile();">${processBlah('action.invite.cancel')}</button>`;
} else {
actionBtnHtml = `<button class="profile-action-btn" onclick="sendInviteTo('${accountId}'); closeProfile();">${processBlah('action.invite.send')}</button>`;
}
}
}
popupInner.innerHTML = `
${bannerHtml}
@ -1508,14 +1576,14 @@ async function openProfile(accountId) {
<div class="profile-info">
<div>
<div class="profile-name">${profileName}</div>
<div class="profile-id-host">${profileName}:${profileHost} (${accountId})</div>
<div class="profile-id-host">${profileName}:${profileHost} (${accountId.split(':')[0]})</div>
</div>
${bioHtml}
${actionBtnHtml}
</div>
`;
} catch (e) {
popupInner.innerHTML = '<div style="display: flex; justify-content: center; align-items: center; height: 100%;"><p>Failed to load profile.</p></div>';
popupInner.innerHTML = `<div style="display: flex; justify-content: center; align-items: center; height: 100%;"><p>${processBlah('error.profile.load.failed')}</p></div>`;
console.error(e);
}
}
@ -1608,7 +1676,8 @@ async function renderInvites(res, type) {
.replaceAll("{username}", username.split(':')[0])
.replaceAll("{pfp}", pfp)
.replaceAll("{desc}", desc)
.replaceAll("{actions}", actions);
.replaceAll("{actions}", actions)
.replaceAll("{id}", id);
html += entry;
}
@ -1659,6 +1728,19 @@ async function switchInvitesTab(tab) {
await loadInvites(tab);
}
async function sendInviteTo(targetId) {
try {
showAction("action.invite.sending", "invite.action");
let res = await fetchEncrypted("user/dm/invite", targetId);
clearAction("invite.action");
showBlahNotification(res || "success:invite.sent");
if (typeof loadInvites === 'function') await loadInvites('sent');
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function acceptInvite(targetId) { //TODO: Implement key generation
try {
showAction("action.invite.accepting", "invite.action");
@ -1769,10 +1851,12 @@ function gotoJoinSpace() {
switchRoomContent("title.join.space", joinSpaceScreen, false);
}
async function gotoHome() {
async function gotoHome(splash = true) {
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
setActiveSidebarIndicator(sidebarHomeIndicator);
switchRoomContent("title.splash", splashScreen, false);
if (splash) {
switchRoomContent("title.splash", splashScreen, false);
}
switchRoomsBar("title.home", homeRoomBar);
setActiveRoombarItem(null);
setupWebSocket();
@ -2010,6 +2094,11 @@ async function openDm(dmId, username, targetId) {
let pfp = await getAvatarUrl(targetId, username);
let iconHtml = `<img src="${pfp}" style="width: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
if (currentRoomsBarTitle !== "title.home") {
gotoHome(false);
}
await switchRoomContent(username.split(':')[0], chatScreen, true, iconHtml);
let msgContainer = document.getElementById("chat-messages");
@ -2155,7 +2244,7 @@ async function renderMessages(messages, isPrepend = false) {
respondedAuthorId = parts[1];
}
let replyAuthor = "Someone";
let replyAuthor = processBlah('unknown.author');
let needsAsyncFetch = false;
if (loadedMessages[respondedId] && loadedMessages[respondedId].author !== "0") {
@ -2183,7 +2272,7 @@ async function renderMessages(messages, isPrepend = false) {
window.replyIdCounter = (window.replyIdCounter || 0) + 1;
if (window.replyIdCounter > 1000000) window.replyIdCounter = 0;
randId = "reply-author-" + window.replyIdCounter;
replyAuthor = "Someone";
replyAuthor = processBlah('unknown.author');
setTimeout(async () => {
try {
let aId = respondedAuthorId;
@ -2262,10 +2351,10 @@ async function renderMessages(messages, isPrepend = false) {
html += `
<div class="chat-message ${extraClass}" data-msg-id="${msgId}" id="msg-${msgId}">
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="openProfile('${msg.author}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
<div class="chat-message-content" oncontextmenu="handleMessageContextMenu(event, '${msgId}')">
${showAvatar ? `<div class="chat-message-header">
<span class="chat-message-author">${authorName}</span>
<span class="chat-message-author" onclick="openProfile('${msg.author}')" style="cursor: pointer;">${authorName}</span>
<span class="chat-message-timestamp">${timeStr}</span>
</div>` : ""}
${repliedHtml}
@ -2307,8 +2396,16 @@ window.visualViewport?.addEventListener("resize", async () => {
isAdjusting = true;
while (isAdjusting) {
//chat scroll fix
ignoreScroll = true;
container.scrollTop = container.scrollHeight - container.clientHeight - lastChatScroll;
//context menu fix
let menuRect = fixedContextMenu.getBoundingClientRect();
if (menuRect.bottom > window.innerHeight) {
fixedContextMenu.style.top = `${Math.max(10, window.innerHeight - menuRect.height - 10)}px`;
}
requestAnimationFrame(() => {
ignoreScroll = false;
});
@ -2585,7 +2682,7 @@ async function replyMessage(msgId) {
let bar = document.getElementById("replying-bar");
if (bar) {
let msg = loadedMessages[msgId];
let authorName = "Someone";
let authorName = processBlah('unknown.author');
if (msg && msg.author !== "0" && dmUsernameCache[msg.author]) {
authorName = dmUsernameCache[msg.author].split(':')[0];
}
@ -2670,16 +2767,7 @@ function openReactionPicker(e) {
let rect = fixedContextMenu.getBoundingClientRect();
showFixedContextMenu({top: rect.top, right: rect.left, bottom: rect.top, left: rect.left}, reactionPickerMenuHtml);
let searchInput = document.getElementById('reaction-picker-search');
if (searchInput) {
searchInput.addEventListener('focus', () => { //TODO: use code from resize event
setTimeout(() => {
let menuRect = fixedContextMenu.getBoundingClientRect();
if (menuRect.bottom > window.innerHeight) {
fixedContextMenu.style.top = `${Math.max(10, window.innerHeight - menuRect.height - 10)}px`;
}
}, 500);
});
}
}
async function reactMessagePrompt(msgId, quickReaction = null) {

View file

@ -117,9 +117,9 @@ var notifisScreen = `
var invitesEntry = `
<div class="invite-entry">
<div class="invite-info">
<img src="{pfp}" class="invite-pfp">
<img src="{pfp}" class="invite-pfp" onclick="openProfile('{id}')" style="cursor: pointer;">
<div class="invite-details">
<span class="invite-name">{username}</span>
<span class="invite-name" onclick="openProfile('{id}')" style="cursor: pointer;">{username}</span>
<span class="invite-desc"><blah>{desc}</blah></span>
</div>
</div>

View file

@ -811,14 +811,14 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.5);
opacity: 0;
transform: scale(0.6);
transition: transform 0.2s cubic-bezier(0.4, -0.3, 0.7, 1), opacity 0.2s ease;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
}
.profile-popup-container.show .profile-popup-content {
transform: scale(1);
opacity: 1;
transition: transform 0.25s cubic-bezier(0.2, 1.15, 0.4, 1), opacity 0.25s ease;
transition: all 0.2s ease;
}
.profile-popup-close-btn {
position: absolute;