using ABI_RC.Core.InteractionSystem; using ABI_RC.Core.Networking.API.Responses; using ABI_RC.Core.Networking.API.UserWebsocket; using ABI_RC.Core.Player; using ABI_RC.Core.Savior; using HarmonyLib; using MTJobSystem; using NAK.ShareBubbles.API; using NAK.ShareBubbles.API.Exceptions; using NAK.ShareBubbles.API.Responses; using Newtonsoft.Json; namespace NAK.ShareBubbles.Patches; internal static class PlayerSetup_Patches { [HarmonyPostfix] [HarmonyPatch(typeof(PlayerSetup), nameof(PlayerSetup.SetSafeScale))] public static void Postfix_PlayerSetup_SetSafeScale() { // I wish there was a callback to listen for player scale changes ShareBubbleManager.Instance.OnPlayerScaleChanged(); } [HarmonyPostfix] [HarmonyPatch(typeof(PlayerSetup), nameof(PlayerSetup.GetCurrentPropSelectionMode))] private static void Postfix_PlayerSetup_GetCurrentPropSelectionMode(ref PlayerSetup.PropSelectionMode __result) { // Stickers mod uses invalid enum value 4, so we use 5 // https://github.com/NotAKidoS/NAK_CVR_Mods/blob/d0c8298074c4dcfc089ccb34ed8b8bd7e0b9cedf/Stickers/Patches.cs#L17 if (ShareBubbleManager.Instance.IsPlacingBubbleMode) __result = (PlayerSetup.PropSelectionMode)5; } } internal static class ControllerRay_Patches { [HarmonyPostfix] [HarmonyPatch(typeof(ControllerRay), nameof(ControllerRay.DeleteSpawnable))] public static void Postfix_ControllerRay_DeleteSpawnable(ref ControllerRay __instance) { if (!__instance._interactDown) return; // not interacted, no need to check if (PlayerSetup.Instance.GetCurrentPropSelectionMode() != PlayerSetup.PropSelectionMode.Delete) return; // not in delete mode, no need to check ShareBubble shareBubble = __instance.hitTransform.GetComponentInParent(); if (shareBubble == null) return; ShareBubbleManager.Instance.DestroyBubble(shareBubble); } [HarmonyPrefix] [HarmonyPatch(typeof(ControllerRay), nameof(ControllerRay.HandlePropSpawn))] private static void Prefix_ControllerRay_HandlePropSpawn(ref ControllerRay __instance) { if (!ShareBubbleManager.Instance.IsPlacingBubbleMode) return; if (__instance._gripDown) ShareBubbleManager.Instance.IsPlacingBubbleMode = false; if (__instance._hitUIInternal || !__instance._interactDown) return; ShareBubbleManager.Instance.PlaceSelectedBubbleFromControllerRay(__instance.rayDirectionTransform); } } internal static class ViewManager_Patches { private const string DETAILS_TOOLBAR_PATCHES = """ const ContentShareMod = { debugMode: false, currentContentData: null, themeColors: null, /* Theme Handling */ getThemeColors: function() { if (this.themeColors) return this.themeColors; // Default fallback colors const defaultColors = { background: '#373021', border: '#59885d' }; // Try to get colors from favorite category element const favoriteCategoryElement = document.querySelector('.favorite-category-selection'); if (!favoriteCategoryElement) return defaultColors; const computedStyle = window.getComputedStyle(favoriteCategoryElement); this.themeColors = { background: computedStyle.backgroundColor || defaultColors.background, border: computedStyle.borderColor || defaultColors.border }; return this.themeColors; }, applyThemeToDialog: function(dialog) { const colors = this.getThemeColors(); dialog.style.backgroundColor = colors.background; dialog.style.borderColor = colors.border; // Update any close or page buttons to match theme const buttons = dialog.querySelectorAll('.close-btn, .page-btn'); buttons.forEach(button => { button.style.borderColor = colors.border; }); return colors; }, /* Core Initialization */ init: function() { const styles = [ this.getSharedStyles(), this.ShareBubble.initStyles(), this.ShareSelect.initStyles(), this.DirectShare.initStyles(), this.Unshare.initStyles() ].join('\n'); const styleElement = document.createElement('style'); styleElement.type = 'text/css'; styleElement.innerHTML = styles; document.head.appendChild(styleElement); this.shareBubbleDialog = this.ShareBubble.createDialog(); this.shareSelectDialog = this.ShareSelect.createDialog(); this.directShareDialog = this.DirectShare.createDialog(); this.unshareDialog = this.Unshare.createDialog(); this.initializeToolbars(); this.bindEvents(); }, getSharedStyles: function() { return ` .content-sharing-base-dialog { position: fixed; background-color: #373021; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 800px; min-width: 500px; border: 3px solid #59885d; padding: 20px; z-index: 100000; opacity: 0; transition: opacity 0.2s linear; } .content-sharing-base-dialog.in { opacity: 1; } .content-sharing-base-dialog.out { opacity: 0; } .content-sharing-base-dialog.hidden { display: none; } .content-sharing-base-dialog h2, .content-sharing-base-dialog h3 { margin-top: 0; margin-bottom: 0.5em; text-align: left; } .content-sharing-base-dialog .description { margin-bottom: 1em; text-align: left; font-size: 0.9em; color: #aaa; } .content-sharing-base-dialog .close-btn { position: absolute; top: 1%; right: 1%; border-radius: 0.25em; border: 3px solid #59885d; padding: 0.5em; width: 8em; text-align: center; } .page-btn { border-radius: 0.25em; border: 3px solid #59885d; padding: 0.5em; width: 8em; text-align: center; } .page-btn:disabled { opacity: 0.5; cursor: not-allowed; } .inp-hidden { display: none; } `; }, /* Feature Modules */ ShareBubble: { initStyles: function() { return ` .share-bubble-dialog { max-width: 800px; transform: translate(-50%, -60%); } .share-bubble-dialog .content-btn { position: relative; margin-bottom: 0.5em; } .share-bubble-dialog .btn-group { display: flex; margin-bottom: 1em; } .share-bubble-dialog .option-select { flex: 1 1 0; min-width: 0; background-color: inherit; text-align: center; padding: 0.5em; cursor: pointer; display: flex; align-items: center; justify-content: center; border: 1px solid inherit; } .share-bubble-dialog .option-select + .option-select { margin-left: -1px; } .share-bubble-dialog .option-select.active, .share-bubble-dialog .option-select:hover { background-color: rgba(27, 80, 55, 1); } .share-bubble-dialog .action-buttons { margin-top: 1em; display: flex; justify-content: space-between; } .share-bubble-dialog .action-btn { flex: 1; text-align: center; padding: 0.5em; border-radius: 0.25em; cursor: pointer; display: flex; align-items: center; justify-content: center; margin-right: 0.5em; } .share-bubble-dialog .action-btn:last-child { margin-right: 0; } `; }, createDialog: function() { const dialog = document.createElement('div'); dialog.id = 'content-share-bubble-dialog'; dialog.className = 'content-sharing-base-dialog share-bubble-dialog hidden'; dialog.innerHTML = `

Share Bubble

Close

Visibility

Choose who can see your Sharing Bubble.

Everyone
Friends Only

Lifetime

How long the Sharing Bubble lasts. You can delete it at any time.

2 Minutes
For Session

Access Control

Users cannot access your Private Content through this share.

Keep
Instance Only
None
Drop
Select
`; document.body.appendChild(dialog); return dialog; }, show: function(contentDetails) { const dialog = ContentShareMod.shareBubbleDialog; const colors = ContentShareMod.applyThemeToDialog(dialog); // Additional ShareBubble-specific theming const optionSelects = dialog.querySelectorAll('.option-select'); optionSelects.forEach(element => { element.style.borderColor = colors.border; }); const grantAccessSection = dialog.querySelector('.grant-access-section'); const noAccessControlMessage = dialog.querySelector('.no-access-control-message'); const showGrantAccess = contentDetails.IsMine && !contentDetails.IsPublic; if (grantAccessSection && noAccessControlMessage) { grantAccessSection.style.display = showGrantAccess ? '' : 'none'; noAccessControlMessage.style.display = showGrantAccess ? 'none' : ''; } dialog.classList.remove('hidden', 'out'); setTimeout(() => dialog.classList.add('in'), 50); ContentShareMod.currentContentData = contentDetails; }, hide: function() { const dialog = ContentShareMod.shareBubbleDialog; dialog.classList.remove('in'); dialog.classList.add('out'); setTimeout(() => { dialog.classList.add('hidden'); dialog.classList.remove('out'); }, 200); }, changeVisibility: function(element) { document.getElementById('share-visibility').value = element.dataset.visibilityValue; const buttons = ContentShareMod.shareBubbleDialog.querySelectorAll('.visibility-btn'); buttons.forEach(btn => btn.classList.remove('active')); element.classList.add('active'); }, changeDuration: function(element) { document.getElementById('share-duration').value = element.dataset.durationValue; const buttons = ContentShareMod.shareBubbleDialog.querySelectorAll('.duration-btn'); buttons.forEach(btn => btn.classList.remove('active')); element.classList.add('active'); }, changeAccess: function(element) { document.getElementById('share-access').value = element.dataset.accessValue; const buttons = ContentShareMod.shareBubbleDialog.querySelectorAll('.access-btn'); buttons.forEach(btn => btn.classList.remove('active')); element.classList.add('active'); const descriptions = ContentShareMod.shareBubbleDialog.querySelectorAll('.access-desc'); descriptions.forEach(desc => { desc.style.display = desc.dataset.accessType === element.dataset.accessValue ? '' : 'none'; }); }, submit: function(action) { const contentDetails = ContentShareMod.currentContentData; let bubbleImpl, bubbleContent, contentImage, contentName; if (contentDetails.AvatarId) { bubbleImpl = 'Avatar'; bubbleContent = contentDetails.AvatarId; contentImage = contentDetails.AvatarImageCoui; contentName = contentDetails.AvatarName; } else if (contentDetails.SpawnableId) { bubbleImpl = 'Spawnable'; bubbleContent = contentDetails.SpawnableId; contentImage = contentDetails.SpawnableImageCoui; contentName = contentDetails.SpawnableName; } else if (contentDetails.WorldId) { bubbleImpl = 'World'; bubbleContent = contentDetails.WorldId; contentImage = contentDetails.WorldImageCoui; contentName = contentDetails.WorldName; } else if (contentDetails.UserId) { bubbleImpl = 'User'; bubbleContent = contentDetails.UserId; contentImage = contentDetails.UserImageCoui; contentName = contentDetails.UserName; } else { console.error('No valid content ID found'); return; } const visibility = document.getElementById('share-visibility').value; const duration = document.getElementById('share-duration').value; const access = document.getElementById('share-access').value; const shareRule = visibility === 'FriendsOnly' ? 'FriendsOnly' : 'Everyone'; const shareLifetime = duration === 'Session' ? 'Session' : 'TwoMinutes'; const shareAccess = access === 'Permanent' ? 'Permanent' : access === 'Session' ? 'Session' : 'NoAccess'; if (ContentShareMod.debugMode) { console.log('Sharing content:', { action, bubbleImpl, bubbleContent, shareRule, shareLifetime, shareAccess }); } engine.call('NAKCallShareContent', action, bubbleImpl, bubbleContent, shareRule, shareLifetime, shareAccess, contentImage, contentName); this.hide(); } }, Unshare: { currentPage: 1, totalPages: 1, sharesPerPage: 5, sharesList: null, initStyles: function() { return ` .unshare-dialog { width: 800px; height: 1000px; transform: translate(-50%, -60%); display: flex; flex-direction: column; } .unshare-dialog .shares-container { flex: 1; overflow-y: auto; margin: 20px 0; min-height: 0; } .unshare-dialog #shares-loading, .unshare-dialog #shares-error, .unshare-dialog #shares-empty { text-align: center; padding: 2em; font-size: 1.1em; } .unshare-dialog #shares-error { color: #ff6b6b; } .unshare-dialog .share-item { display: flex; align-items: center; padding: 15px; border: 1px solid rgba(255, 255, 255, 0.1); margin-bottom: 10px; background: rgba(0, 0, 0, 0.2); border-radius: 4px; min-height: 120px; } .unshare-dialog .share-item img { width: 96px; height: 96px; border-radius: 4px; margin-right: 15px; cursor: pointer; } .unshare-dialog .share-item .user-name { flex: 1; font-size: 1.3em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 15px; cursor: pointer; } .unshare-dialog .action-btn { width: 180px; height: 60px; font-size: 1.2em; color: white; border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; margin-left: 10px; padding: 0 20px; text-align: center; } .unshare-dialog .revoke-btn { background-color: #ff6b6b; } .unshare-dialog .undo-btn { background-color: #4a9eff; } .unshare-dialog .action-btn:disabled { opacity: 0.5; cursor: not-allowed; background-color: #666; } .unshare-dialog .pagination { border-top: 1px solid rgba(255, 255, 255, 0.1); padding-top: 20px; } .unshare-dialog .page-info { font-size: 1.1em; opacity: 0.8; margin-bottom: 15px; text-align: center; } .unshare-dialog .page-buttons { display: flex; justify-content: center; gap: 20px; } .unshare-dialog .share-item.revoked { opacity: 0.7; } `; }, createDialog: function() { const dialog = document.createElement('div'); dialog.id = 'content-unshare-dialog'; dialog.className = 'content-sharing-base-dialog unshare-dialog hidden'; dialog.innerHTML = `

Manage Shares

Close
Loading shares...
`; document.body.appendChild(dialog); return dialog; }, show: function(contentDetails) { const dialog = ContentShareMod.unshareDialog; ContentShareMod.applyThemeToDialog(dialog); dialog.classList.remove('hidden', 'out'); setTimeout(() => dialog.classList.add('in'), 50); ContentShareMod.currentContentData = contentDetails; this.currentPage = 1; this.totalPages = 1; this.sharesList = null; this.requestShares(); }, hide: function() { const dialog = ContentShareMod.unshareDialog; dialog.classList.remove('in'); dialog.classList.add('out'); setTimeout(() => { dialog.classList.add('hidden'); dialog.classList.remove('out'); }, 200); }, requestShares: function() { const dialog = ContentShareMod.unshareDialog; const sharesContainer = dialog.querySelector('.shares-container'); sharesContainer.querySelector('#shares-loading').style.display = ''; sharesContainer.querySelector('#shares-error').style.display = 'none'; sharesContainer.querySelector('#shares-empty').style.display = 'none'; sharesContainer.querySelector('#shares-list').style.display = 'none'; const contentDetails = ContentShareMod.currentContentData; const contentType = contentDetails.AvatarId ? 'Avatar' : 'Spawnable'; const contentId = contentDetails.AvatarId || contentDetails.SpawnableId; engine.call('NAKGetContentShares', contentType, contentId); }, handleSharesResponse: function(success, shares) { const dialog = ContentShareMod.unshareDialog; const sharesContainer = dialog.querySelector('.shares-container'); const loadingElement = sharesContainer.querySelector('#shares-loading'); const errorElement = sharesContainer.querySelector('#shares-error'); const emptyElement = sharesContainer.querySelector('#shares-empty'); const sharesListElement = sharesContainer.querySelector('#shares-list'); loadingElement.style.display = 'none'; if (!success) { errorElement.style.display = ''; return; } try { const response = JSON.parse(shares); this.sharesList = response.Data.value; if (!this.sharesList || this.sharesList.length === 0) { emptyElement.style.display = ''; const pagination = dialog.querySelector('.pagination'); const [prevButton, nextButton] = pagination.querySelectorAll('.page-btn'); prevButton.disabled = true; nextButton.disabled = true; pagination.querySelector('.page-info').textContent = '1/1'; return; } this.totalPages = Math.ceil(this.sharesList.length / this.sharesPerPage); this.updatePageContent(); } catch (error) { console.error('Error parsing shares:', error); errorElement.style.display = ''; } }, updatePageContent: function() { const dialog = ContentShareMod.unshareDialog; const sharesListElement = dialog.querySelector('#shares-list'); const startIndex = (this.currentPage - 1) * this.sharesPerPage; const endIndex = startIndex + this.sharesPerPage; const currentShares = this.sharesList.slice(startIndex, endIndex); sharesListElement.innerHTML = currentShares.map(share => `
${share.name}'s avatar ${share.name}
`).join(''); sharesListElement.style.display = ''; const pagination = dialog.querySelector('.pagination'); pagination.querySelector('.page-info').textContent = `${this.currentPage}/${this.totalPages}`; const [prevButton, nextButton] = pagination.querySelectorAll('.page-btn'); prevButton.disabled = this.currentPage === 1; nextButton.disabled = this.currentPage === this.totalPages; }, previousPage: function() { if (this.currentPage > 1) { this.currentPage--; this.updatePageContent(); } }, nextPage: function() { if (this.currentPage < this.totalPages) { this.currentPage++; this.updatePageContent(); } }, viewUserProfile: function(userId) { this.hide(); getUserDetails(userId); }, revokeShare: function(userId, buttonElement) { const contentDetails = ContentShareMod.currentContentData; const contentType = contentDetails.AvatarId ? 'Avatar' : 'Spawnable'; const contentId = contentDetails.AvatarId || contentDetails.SpawnableId; buttonElement.disabled = true; buttonElement.textContent = 'Revoking...'; engine.call('NAKRevokeContentShare', contentType, contentId, userId); }, handleRevokeResponse: function(success, userId, error) { const dialog = ContentShareMod.unshareDialog; const shareItem = dialog.querySelector(`[data-user-id="${userId}"]`); if (!shareItem) return; const actionButton = shareItem.querySelector('button'); if (success) { shareItem.classList.add('revoked'); actionButton.className = 'action-btn undo-btn button'; actionButton.textContent = 'Undo'; actionButton.onclick = () => { actionButton.disabled = true; actionButton.textContent = 'Restoring...'; const contentDetails = ContentShareMod.currentContentData; const contentType = contentDetails.AvatarId ? 'Avatar' : 'Spawnable'; const contentId = contentDetails.AvatarId || contentDetails.SpawnableId; engine.call('NAKCallShareContentDirect', contentType, contentId, userId); }; uiPushShow("Share revoked successfully", 3); } else { actionButton.textContent = 'Failed'; actionButton.classList.add('failed'); uiPushShow(error || "Failed to revoke share", 3); // Reset button after a moment setTimeout(() => { actionButton.disabled = false; actionButton.textContent = 'Revoke'; actionButton.classList.remove('failed'); }, 1000); } }, handleShareResponse: function(success, userId, error) { const dialog = ContentShareMod.unshareDialog; const shareItem = dialog.querySelector(`[data-user-id="${userId}"]`); if (!shareItem) return; const actionButton = shareItem.querySelector('button'); if (success) { this.requestShares(); uiPushShow("Share restored successfully", 3); } else { actionButton.textContent = 'Failed'; actionButton.classList.add('failed'); uiPushShow(error || "Failed to restore share", 3); // Reset button after a moment setTimeout(() => { actionButton.disabled = false; actionButton.textContent = 'Undo'; actionButton.classList.remove('failed'); }, 1000); } } }, DirectShare: { currentPage: 1, totalPages: 1, usersPerPage: 5, usersList: null, isInstanceUsers: true, initStyles: function() { return ` .direct-share-dialog { width: 800px; height: 1000px; transform: translate(-50%, -60%); display: flex; flex-direction: column; } .direct-share-dialog .search-container { margin: 20px 0; padding: 10px; background: rgba(0, 0, 0, 0.2); border-radius: 4px; } .direct-share-dialog .search-input { width: 100%; padding: 10px; border: none; background: transparent; color: inherit; font-size: 1.1em; } .direct-share-dialog .source-indicator { padding: 10px; text-align: center; opacity: 0.8; background: rgba(0, 0, 0, 0.1); border-radius: 4px; margin-bottom: 20px; } .direct-share-dialog .users-container { flex: 1; overflow-y: auto; margin-bottom: 20px; min-height: 0; } .direct-share-dialog .user-item { display: flex; align-items: center; padding: 15px; border: 1px solid rgba(255, 255, 255, 0.1); margin-bottom: 10px; background: rgba(0, 0, 0, 0.2); border-radius: 4px; min-height: 96px; } .direct-share-dialog .user-item img { width: 96px; height: 96px; margin-right: 15px; cursor: pointer; border-radius: 4px; } .direct-share-dialog .user-item .user-name { flex: 1; font-size: 1.3em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 15px; cursor: pointer; } .direct-share-dialog .user-item .action-btn { width: 140px; height: 50px; font-size: 1.2em; color: white; background-color: rgba(27, 80, 55, 1); border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; } .direct-share-dialog .user-item .action-btn:disabled { opacity: 0.5; cursor: not-allowed; background-color: #4a9eff; } .direct-share-dialog .pagination { border-top: 1px solid rgba(255, 255, 255, 0.1); padding-top: 20px; } .direct-share-dialog .page-info { font-size: 1.1em; opacity: 0.8; margin-bottom: 15px; text-align: center; } .direct-share-dialog .page-buttons { display: flex; justify-content: center; gap: 20px; } .direct-share-dialog .user-item .action-btn { width: 180px; height: 60px; font-size: 1.2em; color: white; border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; margin-left: 10px; padding: 0 20px; text-align: center; background-color: rgba(27, 80, 55, 1); } .direct-share-dialog .user-item .action-btn:disabled { opacity: 0.5; cursor: not-allowed; } .direct-share-dialog .user-item .action-btn.shared { background-color: #4a9eff; } .direct-share-dialog .user-item .action-btn.failed { background-color: #ff6b6b; } `; }, createDialog: function() { const dialog = document.createElement('div'); dialog.id = 'content-direct-share-dialog'; dialog.className = 'content-sharing-base-dialog direct-share-dialog hidden'; dialog.innerHTML = `

Direct Share

Close
Loading users...
`; document.body.appendChild(dialog); return dialog; }, show: function(contentDetails) { const dialog = ContentShareMod.directShareDialog; ContentShareMod.applyThemeToDialog(dialog); dialog.classList.remove('hidden', 'out'); setTimeout(() => dialog.classList.add('in'), 50); ContentShareMod.currentContentData = contentDetails; this.currentPage = 1; this.totalPages = 1; this.usersList = null; this.requestUsers(true); }, hide: function() { const dialog = ContentShareMod.directShareDialog; dialog.classList.remove('in'); dialog.classList.add('out'); setTimeout(() => { dialog.classList.add('hidden'); dialog.classList.remove('out'); }, 200); }, handleUsersResponse: function(success, users, isInstanceUsers) { const dialog = ContentShareMod.directShareDialog; const usersContainer = dialog.querySelector('.users-container'); // const sourceIndicator = dialog.querySelector('.source-indicator'); const loadingElement = usersContainer.querySelector('#users-loading'); const errorElement = usersContainer.querySelector('#users-error'); const emptyElement = usersContainer.querySelector('#users-empty'); const usersListElement = usersContainer.querySelector('#users-list'); loadingElement.style.display = 'none'; // sourceIndicator.textContent = isInstanceUsers ? // 'Showing users in current instance' : // 'Showing search results'; // TODO: Add source indicator to html: //
// Showing users in current instance //
if (!success) { errorElement.style.display = ''; return; } try { const response = JSON.parse(users); this.usersList = response.entries; this.isInstanceUsers = isInstanceUsers; if (!this.usersList || this.usersList.length === 0) { emptyElement.style.display = ''; this.updatePagination(); return; } this.totalPages = Math.ceil(this.usersList.length / this.usersPerPage); this.updatePageContent(); } catch (error) { console.error('Error parsing users:', error); errorElement.style.display = ''; } }, handleSearch: function(event) { if (event.key === 'Enter') { const searchValue = event.target.value.trim(); // Pass true for instance users when empty search, false for search results this.requestUsers(searchValue === '', searchValue); } }, requestUsers: function(isInstanceUsers, searchQuery = '') { const dialog = ContentShareMod.directShareDialog; const usersContainer = dialog.querySelector('.users-container'); usersContainer.querySelector('#users-loading').style.display = ''; usersContainer.querySelector('#users-error').style.display = 'none'; usersContainer.querySelector('#users-empty').style.display = 'none'; usersContainer.querySelector('#users-list').style.display = 'none'; engine.call('NAKGetUsersForSharing', searchQuery); }, updatePageContent: function() { const dialog = ContentShareMod.directShareDialog; const usersListElement = dialog.querySelector('#users-list'); const startIndex = (this.currentPage - 1) * this.usersPerPage; const endIndex = startIndex + this.usersPerPage; const currentUsers = this.usersList.slice(startIndex, endIndex); usersListElement.innerHTML = currentUsers.map(user => `
${user.name}'s avatar ${user.name}
`).join(''); usersListElement.style.display = ''; this.updatePagination(); }, updatePagination: function() { const dialog = ContentShareMod.directShareDialog; const pagination = dialog.querySelector('.pagination'); const [prevButton, nextButton] = pagination.querySelectorAll('.page-btn'); pagination.querySelector('.page-info').textContent = `Page ${this.currentPage}/${this.totalPages}`; prevButton.disabled = this.currentPage === 1; nextButton.disabled = this.currentPage === this.totalPages; }, previousPage: function() { if (this.currentPage > 1) { this.currentPage--; this.updatePageContent(); } }, nextPage: function() { if (this.currentPage < this.totalPages) { this.currentPage++; this.updatePageContent(); } }, viewUserProfile: function(userId) { this.hide(); getUserDetails(userId); }, shareWithUser: function(userId, buttonElement) { const contentDetails = ContentShareMod.currentContentData; const contentType = contentDetails.AvatarId ? 'Avatar' : 'Spawnable'; const contentId = contentDetails.AvatarId || contentDetails.SpawnableId; const contentName = contentDetails.AvatarName || contentDetails.SpawnableName; const contentImage = contentDetails.AvatarImageURL || contentDetails.SpawnableImageURL; buttonElement.disabled = true; buttonElement.textContent = 'Sharing...'; engine.call('NAKCallShareContentDirect', contentType, contentId, userId, contentName, contentImage); }, handleShareResponse: function(success, userId, error) { const dialog = ContentShareMod.directShareDialog; const userItem = dialog.querySelector(`[data-user-id="${userId}"]`); if (!userItem) return; const actionButton = userItem.querySelector('button'); if (success) { actionButton.textContent = 'Shared'; actionButton.disabled = true; actionButton.classList.add('shared'); uiPushShow("Content shared successfully", 3, "shareresponse"); } else { actionButton.disabled = false; actionButton.textContent = 'Failed'; actionButton.classList.add('failed'); uiPushShow(error || "Failed to share content", 3, "shareresponse"); // Reset button after a moment setTimeout(() => { actionButton.disabled = false; actionButton.textContent = 'Share'; actionButton.classList.remove('failed', 'shared'); }, 1000); } } }, ShareSelect: { initStyles: function() { return ` .share-select-dialog { width: 650px; height: 480px; transform: translate(-50%, -80%); } .share-select-dialog .share-options { display: flex; flex-direction: column; gap: 15px; margin-top: 20px; } .share-select-dialog .share-option { padding: 20px; text-align: left; cursor: pointer; background: rgba(0, 0, 0, 0.2); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; transition: background-color 0.2s ease; } .share-select-dialog .share-option:hover { background-color: rgba(27, 80, 55, 1); border-color: rgba(255, 255, 255, 0.2); } .share-select-dialog h3 { margin: 0 0 8px 0; font-size: 1.2em; } .share-select-dialog p { margin: 0; opacity: 0.8; font-size: 0.95em; line-height: 1.4; } `; }, createDialog: function() { const dialog = document.createElement('div'); dialog.id = 'content-share-select-dialog'; dialog.className = 'content-sharing-base-dialog share-select-dialog hidden'; dialog.innerHTML = `

Share Content

Close
`; document.body.appendChild(dialog); return dialog; }, show: function(contentDetails) { const dialog = ContentShareMod.shareSelectDialog; ContentShareMod.applyThemeToDialog(dialog); dialog.classList.remove('hidden', 'out'); setTimeout(() => dialog.classList.add('in'), 50); ContentShareMod.currentContentData = contentDetails; }, hide: function() { const dialog = ContentShareMod.shareSelectDialog; dialog.classList.remove('in'); dialog.classList.add('out'); setTimeout(() => { dialog.classList.add('hidden'); dialog.classList.remove('out'); }, 200); }, openShareBubble: function() { this.hide(); ContentShareMod.ShareBubble.show(ContentShareMod.currentContentData); }, openDirectShare: function() { this.hide(); ContentShareMod.DirectShare.show(ContentShareMod.currentContentData); } }, // Toolbar initialization and event bindings initializeToolbars: function() { const findEmptyButtons = (toolbar) => { return Array.from(toolbar.querySelectorAll('.toolbar-btn')).filter( btn => btn.textContent.trim() === "" ); }; const setupToolbar = (selector) => { const toolbar = document.querySelector(selector); if (!toolbar) return; const emptyButtons = findEmptyButtons(toolbar); if (emptyButtons.length >= 2) { emptyButtons[0].classList.add('content-share-btn'); emptyButtons[0].textContent = 'Share'; emptyButtons[1].classList.add('content-unshare-btn'); emptyButtons[1].textContent = 'Unshare'; } }; setupToolbar('#avatar-detail .avatar-toolbar'); setupToolbar('#prop-detail .avatar-toolbar'); }, bindEvents: function() { // Avatar events engine.on("LoadAvatarDetails", (avatarDetails) => { const shareBtn = document.querySelector('#avatar-detail .content-share-btn'); const unshareBtn = document.querySelector('#avatar-detail .content-unshare-btn'); const canShareDirectly = avatarDetails.IsMine; const canUnshare = avatarDetails.IsMine || avatarDetails.IsSharedWithMe; if (shareBtn) { shareBtn.classList.remove('disabled'); if (canShareDirectly) { shareBtn.onclick = () => ContentShareMod.ShareSelect.show(avatarDetails); } else { shareBtn.onclick = () => ContentShareMod.ShareBubble.show(avatarDetails); } } if (unshareBtn) { if (canUnshare) { unshareBtn.classList.remove('disabled'); unshareBtn.onclick = () => { if (avatarDetails.IsMine) { ContentShareMod.Unshare.show(avatarDetails); } else { uiConfirmShow("Unshare Avatar", "Are you sure you want to unshare this avatar?", "unshare_avatar_confirmation", avatarDetails.AvatarId); } }; } else { unshareBtn.classList.add('disabled'); unshareBtn.onclick = null; } } ContentShareMod.currentContentData = avatarDetails; }); // Prop events engine.on("LoadPropDetails", (propDetails) => { const shareBtn = document.querySelector('#prop-detail .content-share-btn'); const unshareBtn = document.querySelector('#prop-detail .content-unshare-btn'); const canShareDirectly = propDetails.IsMine; const canUnshare = propDetails.IsMine || propDetails.IsSharedWithMe; if (shareBtn) { shareBtn.classList.remove('disabled'); if (canShareDirectly) { shareBtn.onclick = () => ContentShareMod.ShareSelect.show(propDetails); } else { shareBtn.onclick = () => ContentShareMod.ShareBubble.show(propDetails); } } if (unshareBtn) { if (canUnshare) { unshareBtn.classList.remove('disabled'); unshareBtn.onclick = () => { if (propDetails.IsMine) { ContentShareMod.Unshare.show(propDetails); } else { uiConfirmShow("Unshare Prop", "Are you sure you want to unshare this prop?", "unshare_prop_confirmation", propDetails.SpawnableId); } }; } else { unshareBtn.classList.add('disabled'); unshareBtn.onclick = null; } } ContentShareMod.currentContentData = propDetails; }); // Share response handlers engine.on("OnHandleSharesResponse", (success, shares) => { if (ContentShareMod.debugMode) { console.log('Shares response:', success, shares); } ContentShareMod.Unshare.handleSharesResponse(success, shares); }); engine.on("OnHandleRevokeResponse", (success, userId, error) => { ContentShareMod.Unshare.handleRevokeResponse(success, userId, error); }); engine.on("OnHandleShareResponse", function(success, userId, error) { // Pass event to Unshare and DirectShare modules depending on which dialog is open if (ContentShareMod.unshareDialog && !ContentShareMod.unshareDialog.classList.contains('hidden')) { ContentShareMod.Unshare.handleShareResponse(success, userId, error); } else if (ContentShareMod.directShareDialog && !ContentShareMod.directShareDialog.classList.contains('hidden')) { ContentShareMod.DirectShare.handleShareResponse(success, userId, error); } }); // Share release handlers engine.on("OnReleasedAvatarShare", (contentId) => { if (!ContentShareMod.currentContentData || ContentShareMod.currentContentData.AvatarId !== contentId) return; const unshareBtn = document.querySelector('#avatar-detail .content-unshare-btn'); ContentShareMod.currentContentData.IsSharedWithMe = false; if (unshareBtn) { unshareBtn.classList.add('disabled'); unshareBtn.onclick = null; } const contentIsAccessible = ContentShareMod.currentContentData.IsMine || ContentShareMod.currentContentData.IsPublic; if (!contentIsAccessible) { const detail = document.querySelector('#avatar-detail'); if (detail) { ['drop-btn', 'select-btn', 'fav-btn'].forEach(className => { const button = detail.querySelector('.' + className); if (button) { button.classList.add('disabled'); button.removeAttribute('onclick'); } }); } } }); engine.on("OnReleasedPropShare", (contentId) => { if (!ContentShareMod.currentContentData || ContentShareMod.currentContentData.SpawnableId !== contentId) return; const unshareBtn = document.querySelector('#prop-detail .content-unshare-btn'); ContentShareMod.currentContentData.IsSharedWithMe = false; if (unshareBtn) { unshareBtn.classList.add('disabled'); unshareBtn.onclick = null; } const contentIsAccessible = ContentShareMod.currentContentData.IsMine || ContentShareMod.currentContentData.IsPublic; if (!contentIsAccessible) { const detail = document.querySelector('#prop-detail'); if (detail) { ['drop-btn', 'select-btn', 'fav-btn'].forEach(className => { const button = detail.querySelector('.' + className); if (button) { button.classList.add('disabled'); button.removeAttribute('onclick'); } }); } } }); engine.on("OnHandleUsersResponse", (success, users, isInstanceUsers) => { if (ContentShareMod.debugMode) { console.log('Users response:', success, isInstanceUsers, users); } ContentShareMod.DirectShare.handleUsersResponse(success, users, isInstanceUsers); }); } }; ContentShareMod.init(); """; private const string UiConfirmId_ReleaseAvatarShareWarning = "unshare_avatar_confirmation"; private const string UiConfirmId_ReleasePropShareWarning = "unshare_prop_confirmation"; [HarmonyPostfix] [HarmonyPatch(typeof(ViewManager), nameof(ViewManager.Start))] public static void Postfix_ViewManager_Start(ViewManager __instance) { // Inject the details toolbar patches when the game menu view is loaded __instance.gameMenuView.Listener.FinishLoad += _ => { __instance.gameMenuView.View._view.ExecuteScript(DETAILS_TOOLBAR_PATCHES); __instance.gameMenuView.View.BindCall("NAKCallShareContent", OnShareContent); __instance.gameMenuView.View.BindCall("NAKGetContentShares", OnGetContentShares); __instance.gameMenuView.View.BindCall("NAKRevokeContentShare", OnRevokeContentShare); __instance.gameMenuView.View.BindCall("NAKCallShareContentDirect", OnShareContentDirect); __instance.gameMenuView.View.BindCall("NAKGetUsersForSharing", OnGetUsersForSharing); }; // Add the event listener for the unshare confirmation dialog __instance.OnUiConfirm.AddListener(OnReleaseContentShareConfirmation); return; void OnShareContent( string action, string bubbleImpl, string bubbleContent, string shareRule, string shareLifetime, string shareAccess, string contentImage, string contentName) { // Action: drop, select // BubbleImpl: Avatar, Prop, World, User // BubbleContent: AvatarId, PropId, WorldId, UserId // ShareRule: Public, FriendsOnly // ShareLifetime: TwoMinutes, Session // ShareAccess: PermanentAccess, SessionAccess, NoAccess ShareRule rule = shareRule switch { "Everyone" => ShareRule.Everyone, "FriendsOnly" => ShareRule.FriendsOnly, _ => ShareRule.Everyone }; ShareLifetime lifetime = shareLifetime switch { "Session" => ShareLifetime.Session, "TwoMinutes" => ShareLifetime.TwoMinutes, _ => ShareLifetime.TwoMinutes }; ShareAccess access = shareAccess switch { "Permanent" => ShareAccess.Permanent, "Session" => ShareAccess.Session, "None" => ShareAccess.None, _ => ShareAccess.None }; uint implTypeHash = ShareBubbleManager.GetMaskedHash(bubbleImpl); ShareBubbleData bubbleData = new() { BubbleId = ShareBubbleManager.GenerateBubbleId(bubbleContent, implTypeHash), ImplTypeHash = implTypeHash, ContentId = bubbleContent, Rule = rule, Lifetime = lifetime, Access = access, CreatedAt = DateTime.UtcNow }; switch (action) { case "drop": ShareBubbleManager.Instance.DropBubbleInFront(bubbleData); break; case "select": ShareBubbleManager.Instance.SelectBubbleForPlace(contentImage, contentName, bubbleData); break; } // Close menu ViewManager.Instance.UiStateToggle(false); } void OnReleaseContentShareConfirmation(string id, string value, string contentId) { // Check if the confirmation event is for unsharing content if (id != UiConfirmId_ReleaseAvatarShareWarning && id != UiConfirmId_ReleasePropShareWarning) return; //ShareBubblesMod.Logger.Msg($"Unshare confirmation received: {id}, {value}"); // Check if the user confirmed the unshare action if (value != "true") { //ShareBubblesMod.Logger.Msg("Unshare action cancelled by user"); return; } //ShareBubblesMod.Logger.Msg("Releasing share..."); // Determine the content type based on the confirmation ID ShareApiHelper.ShareContentType contentType = id == UiConfirmId_ReleaseAvatarShareWarning ? ShareApiHelper.ShareContentType.Avatar : ShareApiHelper.ShareContentType.Spawnable; Task.Run(async () => { try { await ShareApiHelper.ReleaseShareAsync(contentType, contentId); MTJobManager.RunOnMainThread("release_share_response", () => { // Cannot display a success message as opening details page pushes itself to top // after talking to api, so success message would need to be timed to show after // if (contentType == ApiShareHelper.ShareContentType.Avatar) // ViewManager.Instance.RequestAvatarDetailsPage(contentId); // else // ViewManager.Instance.GetPropDetails(contentId); ViewManager.Instance.gameMenuView.View._view.TriggerEvent( contentType == ShareApiHelper.ShareContentType.Avatar ? "OnReleasedAvatarShare" : "OnReleasedPropShare", contentId); ViewManager.Instance.TriggerPushNotification("Content unshared successfully", 3f); }); } catch (ShareApiException ex) { ShareBubblesMod.Logger.Error($"Share API error: {ex.Message}"); MTJobManager.RunOnMainThread("release_share_error", () => { ViewManager.Instance.TriggerAlert("Release Share Error", ex.UserFriendlyMessage, -1, true); }); } catch (Exception ex) { ShareBubblesMod.Logger.Error($"Unexpected error releasing share: {ex.Message}"); MTJobManager.RunOnMainThread("release_share_error", () => { ViewManager.Instance.TriggerAlert("Release Share Error", "An unexpected error occurred", -1, true); }); } }); } async void OnGetContentShares(string contentType, string contentId) { try { var response = await ShareApiHelper.GetSharesAsync>( contentType == "Avatar" ? ShareApiHelper.ShareContentType.Avatar : ShareApiHelper.ShareContentType.Spawnable, contentId ); // TODO: somethign better than this cause this is ass and i need to replace the image urls with ImageCache coui ones // FUICJK< string json = JsonConvert.SerializeObject(response.Data); // log the json to console //ShareBubblesMod.Logger.Msg($"Shares response: {json}"); __instance.gameMenuView.View.TriggerEvent("OnHandleSharesResponse", true, json); } catch (Exception ex) { ShareBubblesMod.Logger.Error($"Failed to get content shares: {ex.Message}"); __instance.gameMenuView.View.TriggerEvent("OnHandleSharesResponse", false); } } async void OnRevokeContentShare(string contentType, string contentId, string userId) { try { await ShareApiHelper.ReleaseShareAsync( contentType == "Avatar" ? ShareApiHelper.ShareContentType.Avatar : ShareApiHelper.ShareContentType.Spawnable, contentId, userId ); __instance.gameMenuView.View.TriggerEvent("OnHandleRevokeResponse", true, userId); } catch (ShareApiException ex) { ShareBubblesMod.Logger.Error($"Share API error revoking share: {ex.Message}"); __instance.gameMenuView.View.TriggerEvent("OnHandleRevokeResponse", false, userId, ex.UserFriendlyMessage); } catch (Exception ex) { ShareBubblesMod.Logger.Error($"Unexpected error revoking share: {ex.Message}"); __instance.gameMenuView.View.TriggerEvent("OnHandleRevokeResponse", false, userId, "An unexpected error occurred"); } } async void OnShareContentDirect(string contentType, string contentId, string userId, string contentName = "", string contentImage = "") { try { ShareApiHelper.ShareContentType shareContentType = contentType == "Avatar" ? ShareApiHelper.ShareContentType.Avatar : ShareApiHelper.ShareContentType.Spawnable; await ShareApiHelper.ShareContentAsync( shareContentType, contentId, userId ); // Alert the user that the share occurred //ModNetwork.SendDirectShareNotification(userId, shareContentType, contentId); __instance.gameMenuView.View._view.TriggerEvent("OnHandleShareResponse", true, userId); } catch (ShareApiException ex) { ShareBubblesMod.Logger.Error($"Share API error: {ex.Message}"); __instance.gameMenuView.View._view.TriggerEvent("OnHandleShareResponse", false, userId, ex.UserFriendlyMessage); } catch (Exception ex) { ShareBubblesMod.Logger.Error($"Unexpected error sharing content: {ex.Message}"); __instance.gameMenuView.View._view.TriggerEvent("OnHandleShareResponse", false, userId, "An unexpected error occurred"); } } void OnGetUsersForSharing(string searchTerm = "") { try { if (!string.IsNullOrEmpty(searchTerm)) { // TODO: Search users implementation will go here // For now just return an empty list var response = new { entries = new List() }; string json = JsonConvert.SerializeObject(response); __instance.gameMenuView.View.TriggerEvent("OnHandleUsersResponse", true, json, false); } else { // Get instance users CVRPlayerManager playerManager = CVRPlayerManager.Instance; if (playerManager == null) { __instance.gameMenuView.View.TriggerEvent("OnHandleUsersResponse", false, false, true); return; } var response = new { entries = playerManager.NetworkPlayers .Where(p => p != null && !string.IsNullOrEmpty(p.Uuid) && !MetaPort.Instance.blockedUserIds.Contains(p.Uuid)) // You SHOULDNT HAVE TO DO THIS, but GS dumb .Select(p => new { id = p.Uuid, name = p.Username, image = p.ApiProfileImageUrl }) .ToList() }; string json = JsonConvert.SerializeObject(response); __instance.gameMenuView.View.TriggerEvent("OnHandleUsersResponse", true, json, true); } } catch (Exception ex) { ShareBubblesMod.Logger.Error($"Failed to get users: {ex.Message}"); __instance.gameMenuView.View.TriggerEvent("OnHandleUsersResponse", false, false, string.IsNullOrEmpty(searchTerm)); } } } }