From e4098ec40ad0c022b4a08eeb7aa1d7ef63e5f7ae Mon Sep 17 00:00:00 2001 From: Colby Ausen Date: Wed, 26 Nov 2025 16:18:25 -0700 Subject: [PATCH 1/7] Support inbox messages --- examples/index.html | 113 ++++++++++++++ examples/styles.css | 214 ++++++++++++++++++++++++++ src/gist.js | 19 +++ src/managers/inbox-message-manager.js | 120 +++++++++++++++ src/managers/queue-manager.js | 20 ++- src/services/queue-service.js | 2 +- 6 files changed, 483 insertions(+), 5 deletions(-) create mode 100644 src/managers/inbox-message-manager.js diff --git a/examples/index.html b/examples/index.html index a58bd9b..4aecfd7 100644 --- a/examples/index.html +++ b/examples/index.html @@ -7,6 +7,24 @@ +
+
+ + + + + +
+
+

Gist for Web

@@ -121,6 +139,101 @@

Gist for Web

function addAnonymousCustomAttribute() { Gist.setCustomAttribute("cio_anonymous_id", "123456"); } + + // Inbox functionality + async function updateInboxBadge() { + const messages = await Gist.getInboxMessages(); + const unreadCount = messages.filter(msg => !msg.opened).length; + const badge = document.getElementById('inboxBadge'); + if (unreadCount > 0) { + badge.textContent = unreadCount; + badge.style.display = 'inline-block'; + } else { + badge.style.display = 'none'; + } + } + + async function toggleInboxPanel() { + const panel = document.getElementById('inboxPanel'); + if (panel.style.display === 'none') { + panel.style.display = 'block'; + await loadInboxMessages(); + } else { + panel.style.display = 'none'; + } + } + + async function loadInboxMessages() { + const messages = await Gist.getInboxMessages(); + const content = document.getElementById('inboxPanelContent'); + + if (messages.length === 0) { + content.innerHTML = '

No messages

'; + return; + } + + let html = ''; + for (const message of messages) { + const isUnread = !message.opened; + const props = message.properties || {}; + const queueId = message.queueId; + const propertiesJson = JSON.stringify(props, null, 2); + + html += ` +
+
+ Properties + ${isUnread ? '' : ''} +
+
+
${propertiesJson}
+
+
+ ${isUnread ? `` : ''} + +
+
+ `; + } + + content.innerHTML = html; + } + + async function markAsRead(queueId) { + try { + await Gist.markInboxMessageAsOpened(queueId); + await loadInboxMessages(); + await updateInboxBadge(); + } catch (error) { + console.error('Failed to mark message as read:', error); + alert('Failed to mark message as read. Please try again.'); + } + } + + async function deleteMessage(queueId) { + // Remove from UI immediately + const messageElement = document.querySelector(`[data-queue-id="${queueId}"]`); + if (messageElement) { + messageElement.remove(); + } + + // Update badge immediately + await updateInboxBadge(); + + try { + await Gist.removeInboxMessage(queueId); + } catch (error) { + console.error('Failed to delete message:', error); + alert('Failed to delete message. Please try again.'); + // Reload messages to restore the UI state + await loadInboxMessages(); + await updateInboxBadge(); + } + } + + // Update badge on load and periodically + updateInboxBadge(); + setInterval(updateInboxBadge, 5000); \ No newline at end of file diff --git a/examples/styles.css b/examples/styles.css index 5f447c9..e768c5a 100644 --- a/examples/styles.css +++ b/examples/styles.css @@ -7,6 +7,10 @@ html, body { line-height: 1.4; } +body { + padding-top: 40px; +} + .row { margin: 0px 16px; display: flex; @@ -52,4 +56,214 @@ h1 { .button { width: 100%; } +} + +/* Inbox Header */ +.inbox-header { + height: 40px; + background-color: #e76f51; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0 16px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; +} + +.inbox-icon-container { + position: relative; + cursor: pointer; + padding: 8px; + border-radius: 4px; + transition: background-color 0.2s; +} + +.inbox-icon-container:hover { + background-color: rgba(255,255,255,0.1); +} + +.inbox-icon { + color: white; + display: block; +} + +.inbox-badge { + position: absolute; + top: 2px; + right: 2px; + background-color: #264653; + color: white; + border-radius: 10px; + padding: 2px 6px; + font-size: 11px; + font-weight: bold; + min-width: 18px; + text-align: center; +} + +/* Inbox Panel */ +.inbox-panel { + position: fixed; + top: 40px; + right: 0; + width: 400px; + max-width: 100%; + max-height: calc(100vh - 40px); + background-color: white; + box-shadow: -2px 0 8px rgba(0,0,0,0.15); + z-index: 999; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.inbox-panel .inbox-panel-content { + min-height: 400px; +} + +.inbox-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid #e0e0e0; + background-color: #f5f5f5; +} + +.inbox-panel-header h3 { + margin: 0; + font-size: 18px; +} + +.close-btn { + background: none; + border: none; + font-size: 28px; + cursor: pointer; + color: #666; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.close-btn:hover { + background-color: rgba(0,0,0,0.05); +} + +.inbox-panel-content { + padding: 16px; +} + +.no-messages { + text-align: center; + color: #999; + padding: 32px 16px; + font-size: 16px; +} + +/* Inbox Message */ +.inbox-message { + background-color: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; + transition: box-shadow 0.2s; +} + +.inbox-message:hover { + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +.inbox-message.unread { + background-color: #f0f9ff; + border-color: #2a9d8f; +} + +.inbox-message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.inbox-message-header strong { + font-size: 16px; + color: #264653; +} + +.unread-dot { + width: 8px; + height: 8px; + background-color: #2a9d8f; + border-radius: 50%; + display: inline-block; + margin-left: 8px; +} + +.inbox-message-body { + font-size: 14px; + color: #555; + line-height: 1.5; + margin-bottom: 12px; +} + +.inbox-message-body pre { + background-color: #f5f5f5; + padding: 12px; + border-radius: 4px; + overflow-x: auto; + font-size: 12px; + margin: 0; + font-family: 'Courier New', monospace; +} + +.inbox-message-footer { + margin-bottom: 12px; +} + +.inbox-message-footer a { + color: #2a9d8f; + text-decoration: none; + font-size: 14px; + font-weight: 600; +} + +.inbox-message-footer a:hover { + text-decoration: underline; +} + +.inbox-message-actions { + display: flex; + gap: 8px; +} + +.inbox-message-actions button { + padding: 6px 12px; + font-size: 13px; + border: 1px solid #e0e0e0; + background-color: white; + color: #555; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.inbox-message-actions button:hover { + background-color: #f5f5f5; + border-color: #2a9d8f; +} + +@media (max-width: 800px) { + .inbox-panel { + width: 100%; + } } \ No newline at end of file diff --git a/src/gist.js b/src/gist.js index 56c720f..3d6291e 100644 --- a/src/gist.js +++ b/src/gist.js @@ -7,6 +7,11 @@ import { showMessage, embedMessage, hideMessage, removePersistentMessage, fetchM import { setUserLocale } from "./managers/locale-manager"; import { setCustomAttribute, clearCustomAttributes, removeCustomAttribute } from "./managers/custom-attribute-manager"; import { setupPreview } from "./utilities/preview-mode"; +import { + getInboxMessagesFromLocalStore, + markInboxMessageAsOpened, + removeInboxMessage +} from "./managers/inbox-message-manager"; export default class { static async setup(config) { @@ -125,4 +130,18 @@ export default class { this.events.dispatch('messageAction', {message: message, action: action, name: name}); } + // Inbox Messages + + static async getInboxMessages() { + return await getInboxMessagesFromLocalStore(); + } + + static async markInboxMessageAsOpened(queueId) { + return await markInboxMessageAsOpened(queueId); + } + + static async removeInboxMessage(queueId) { + return await removeInboxMessage(queueId); + } + } diff --git a/src/managers/inbox-message-manager.js b/src/managers/inbox-message-manager.js new file mode 100644 index 0000000..b38821d --- /dev/null +++ b/src/managers/inbox-message-manager.js @@ -0,0 +1,120 @@ +import Gist from '../gist'; +import { getKeyFromLocalStore, setKeyToLocalStore } from '../utilities/local-storage'; +import { getHashedUserToken } from './user-manager'; +import { UserNetworkInstance } from '../services/network'; +import { log } from '../utilities/log'; +import { logUserMessageView } from '../services/log-service'; + +const inboxMessagesLocalStoreName = "gist.web.inbox.messages"; +const inboxMessagesLocalStoreCacheInMinutes = 60; + +export async function updateInboxMessagesLocalStore(messages) { + const inboxLocalStoreName = await getInboxMessagesLocalStoreName(); + if (!inboxLocalStoreName) return; + + const expiryDate = new Date(); + expiryDate.setMinutes(expiryDate.getMinutes() + inboxMessagesLocalStoreCacheInMinutes); + + setKeyToLocalStore(inboxLocalStoreName, messages, expiryDate); + + Gist.events.dispatch('inboxMessages', messages); +} + +export async function getInboxMessagesFromLocalStore() { + const inboxLocalStoreName = await getInboxMessagesLocalStoreName(); + if (!inboxLocalStoreName) return []; + + const storedMessages = getKeyFromLocalStore(inboxLocalStoreName) ?? []; + const now = new Date(); + + // Filter out messages that have expired + return storedMessages.filter(message => { + if (!message.expiry) return true; + const expiryDate = new Date(message.expiry); + return expiryDate > now; + }); +} + +export async function getInboxMessagesByTopic(topic) { + const messages = await getInboxMessagesFromLocalStore(); + if (!topic) return messages; + + return messages.filter(message => { + if (!message.topics || message.topics.length === 0) { + return topic === 'default'; + } + return message.topics.includes(topic); + }); +} + +export async function markInboxMessageAsOpened(queueId) { + const inboxLocalStoreName = await getInboxMessagesLocalStoreName(); + if (!inboxLocalStoreName) { + throw new Error('User token not available'); + } + + try { + const response = await UserNetworkInstance()(`/api/v1/messages/${queueId}`, { + method: 'PATCH', + body: JSON.stringify({ opened: true }), + headers: { + 'Content-Type': 'application/json' + } + }); + + if (response.status < 200 || response.status >= 300) { + throw new Error(`Failed to mark message as opened: ${response.status}`); + } + + log(`Marked inbox message ${queueId} as opened on server:`, response); + } catch (error) { + log(`Error marking inbox message ${queueId} as opened:`, error); + throw error; + } + + const messages = await getInboxMessagesFromLocalStore(); + const updatedMessages = messages.map(message => { + if (message.queueId === queueId) { + return { ...message, opened: true }; + } + return message; + }); + + const expiryDate = new Date(); + expiryDate.setMinutes(expiryDate.getMinutes() + inboxMessagesLocalStoreCacheInMinutes); + setKeyToLocalStore(inboxLocalStoreName, updatedMessages, expiryDate); + + return true; +} + +export async function removeInboxMessage(queueId) { + const inboxLocalStoreName = await getInboxMessagesLocalStoreName(); + if (!inboxLocalStoreName) { + throw new Error('User token not available'); + } + + const response = await logUserMessageView(queueId); + + if (!response || response.status < 200 || response.status >= 300) { + const errorMsg = `Failed to log message view: ${response?.status || 'unknown error'}`; + log(errorMsg); + throw new Error(errorMsg); + } + + const messages = await getInboxMessagesFromLocalStore(); + const filteredMessages = messages.filter(message => + message.queueId !== queueId + ); + + const expiryDate = new Date(); + expiryDate.setMinutes(expiryDate.getMinutes() + inboxMessagesLocalStoreCacheInMinutes); + setKeyToLocalStore(inboxLocalStoreName, filteredMessages, expiryDate); + + return true; +} + +async function getInboxMessagesLocalStoreName() { + const userToken = await getHashedUserToken(); + if (!userToken) return null; + return `${inboxMessagesLocalStoreName}.${userToken}`; +} diff --git a/src/managers/queue-manager.js b/src/managers/queue-manager.js index 42b4ece..532c497 100644 --- a/src/managers/queue-manager.js +++ b/src/managers/queue-manager.js @@ -7,6 +7,7 @@ import { resolveMessageProperties } from "./gist-properties-manager"; import { clearKeyFromLocalStore, getKeyFromLocalStore } from '../utilities/local-storage'; import { updateBroadcastsLocalStore, getEligibleBroadcasts, isShowAlwaysBroadcast } from './message-broadcast-manager'; import { updateQueueLocalStore, getMessagesFromLocalStore, isMessageLoading, setMessageLoading } from './message-user-queue-manager'; +import { updateInboxMessagesLocalStore } from './inbox-message-manager'; import { settings } from '../services/settings'; var sleep = time => new Promise(resolve => setTimeout(resolve, time)) @@ -109,13 +110,14 @@ async function checkQueueThroughPolling() { // We're using the TTL as a way to determine if we should check the queue, so if the key is not there, we check the queue. if (getKeyFromLocalStore(userQueueNextPullCheckLocalStoreName) === null) { var response = await getUserQueue(); - var responseData = []; if (response) { if (response.status === 200 || response.status === 204) { log("200 response, updating local store."); - responseData = response.data; - updateQueueLocalStore(responseData); - updateBroadcastsLocalStore(responseData); + var inAppMessages = response.data?.inAppMessages || []; + var inboxMessages = response.data?.inboxMessages || []; + updateQueueLocalStore(inAppMessages); + updateBroadcastsLocalStore(inAppMessages); + updateInboxMessagesLocalStore(inboxMessages); } else if (response.status === 304) { log("304 response, using local store."); } @@ -181,6 +183,16 @@ async function setupSSEQueueListener() { } }); + sseSource.addEventListener("inbox_messages", async (event) => { + try { + var inboxMessages = JSON.parse(event.data); + log("SSE inbox messages received:", inboxMessages); + await updateInboxMessagesLocalStore(inboxMessages); + } catch (e) { + log("Failed to parse SSE inbox messages", e); + } + }); + sseSource.addEventListener("error", async (event) => { log("SSE error received:", event); stopSSEListener(); diff --git a/src/services/queue-service.js b/src/services/queue-service.js index 71c4277..22c566f 100644 --- a/src/services/queue-service.js +++ b/src/services/queue-service.js @@ -22,7 +22,7 @@ export async function getUserQueue() { "X-Gist-User-Anonymous": isUsingGuestUserToken(), "Content-Language": getUserLocale() } - response = await UserNetworkInstance().post(`/api/v3/users?sessionId=${getSessionId()}`, {}, { headers: headers }); + response = await UserNetworkInstance().post(`/api/v4/users?sessionId=${getSessionId()}`, {}, { headers: headers }); } } catch (error) { if (error.response) { From c79aa022255edb769bd6dff2ab5e332341c58c03 Mon Sep 17 00:00:00 2001 From: Colby Ausen Date: Mon, 1 Dec 2025 12:40:11 -0700 Subject: [PATCH 2/7] Review feedback --- examples/inbox.js | 87 ++++++++++++++++++++++ examples/index.html | 100 +------------------------- examples/styles.css | 5 ++ src/gist.js | 11 ++- src/managers/inbox-message-manager.js | 38 +++------- src/services/message-service.js | 16 +++++ 6 files changed, 130 insertions(+), 127 deletions(-) create mode 100644 examples/inbox.js create mode 100644 src/services/message-service.js diff --git a/examples/inbox.js b/examples/inbox.js new file mode 100644 index 0000000..61593f8 --- /dev/null +++ b/examples/inbox.js @@ -0,0 +1,87 @@ +async function refreshInboxMessages(messages) { + const unreadCount = await Gist.getInboxUnopenedCount(); + const badge = document.getElementById('inboxBadge'); + if (unreadCount > 0) { + badge.textContent = unreadCount; + badge.style.display = 'inline-block'; + } else { + badge.style.display = 'none'; + } + + if (!messages) { + messages = await Gist.getInboxMessages(); + } + + const content = document.getElementById('inboxPanelContent'); + + if (messages.length === 0) { + content.innerHTML = '

No messages

'; + return; + } + + let html = ''; + for (const message of messages) { + const isUnread = !message.opened; + const props = message.properties || {}; + const queueId = message.queueId; + const propertiesJson = JSON.stringify(props, null, 2); + + html += ` +
+
+ Properties +

Sent at ${new Date(message.sentAt).toLocaleString()}

+ ${isUnread ? '' : ''} +
+
+
${propertiesJson}
+
+
+ ${isUnread ? `` : ''} + +
+
+ `; + } + + content.innerHTML = html; +} + +async function markAsRead(queueId) { + try { + await Gist.markInboxMessageOpened(queueId); + } catch (error) { + console.error('Failed to mark message as read:', error); + alert('Failed to mark message as read. Please try again.'); + } + + await refreshInboxMessages(); +} + +async function deleteMessage(queueId) { + try { + await Gist.removeInboxMessage(queueId); + } catch (error) { + console.error('Failed to delete message:', error); + alert('Failed to delete message. Please try again.'); + } + + await refreshInboxMessages(); +} + +document.querySelectorAll(".toggle-inbox").forEach(element => { + element.addEventListener("click", () => { + const panel = document.getElementById('inboxPanel'); + if (panel.style.display === 'none') { + panel.style.display = 'block'; + } else { + panel.style.display = 'none'; + } + }); +}); + +refreshInboxMessages(); + +Gist.events.on('messageInboxUpdated', async function(messages) { + await refreshInboxMessages(messages); +}); \ No newline at end of file diff --git a/examples/index.html b/examples/index.html index 4aecfd7..c7c659c 100644 --- a/examples/index.html +++ b/examples/index.html @@ -8,7 +8,7 @@
-
+
@@ -19,7 +19,7 @@