diff --git a/examples/inbox.js b/examples/inbox.js new file mode 100644 index 0000000..3a60508 --- /dev/null +++ b/examples/inbox.js @@ -0,0 +1,84 @@ +async function refreshInboxMessages(messages) { + const unopenedCount = await window.Gist.getInboxUnopenedCount(); + const badge = document.getElementById('inboxBadge'); + if (unopenedCount > 0) { + badge.textContent = unopenedCount; + badge.style.display = 'inline-block'; + } else { + badge.style.display = 'none'; + } + + if (!messages) { + messages = await window.Gist.getInboxMessages(); + } + + const content = document.getElementById('inboxPanelContent'); + + if (messages.length === 0) { + content.innerHTML = '

No messages

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

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

+ ${!message.opened ? '' : ''} +
+
+
${propertiesJson}
+
+
+ ${!message.opened ? `` : ``} + +
+
+ `; + } + + content.innerHTML = html; +} + +// eslint-disable-next-line no-unused-vars +async function updateInboxMessageOpenState(queueId, opened) { + try { + await window.Gist.updateInboxMessageOpenState(queueId, opened); + } catch (error) { + console.error('Failed to mark message as opened:', error); + alert('Failed to mark message as read. Please try again.'); + } +} + +// eslint-disable-next-line no-unused-vars +async function deleteMessage(queueId) { + try { + await window.Gist.removeInboxMessage(queueId); + } catch (error) { + console.error('Failed to delete message:', error); + alert('Failed to delete message. Please try again.'); + } +} + +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(); + +window.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 a58bd9b..064a2b6 100644 --- a/examples/index.html +++ b/examples/index.html @@ -7,6 +7,24 @@ +
+
+ + + + + +
+
+

Gist for Web

@@ -26,15 +44,79 @@

Gist for Web

More information can be found on our docs, if you have any question you can email us at support@gist.build

+
+
+ ⚙️ Configuration Override + +
+
+
+
+

Gist.setup()

+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+

Gist.setUserToken()

+
+ + +
+
+
+ + +
+
+
+
+ + \ No newline at end of file diff --git a/examples/settings.js b/examples/settings.js new file mode 100644 index 0000000..e42c956 --- /dev/null +++ b/examples/settings.js @@ -0,0 +1,76 @@ +// Default configuration +const defaultConfig = { + siteId: "a5ec106751ef4b34a0b9", + dataCenter: "eu", + env: "prod", + logging: true, + useAnonymousSession: true, + userToken: "ABC123" +}; + +// Load configuration from localStorage or use defaults +function loadConfig() { + const savedConfig = localStorage.getItem('gistConfig'); + return savedConfig ? JSON.parse(savedConfig) : defaultConfig; +} + +// Get current configuration +// eslint-disable-next-line no-unused-vars +function getConfig() { + return loadConfig(); +} + +// Populate form with current config +function populateConfigForm() { + const config = loadConfig(); + document.getElementById('siteId').value = config.siteId; + document.getElementById('dataCenter').value = config.dataCenter; + document.getElementById('env').value = config.env; + document.getElementById('logging').checked = config.logging; + document.getElementById('useAnonymousSession').checked = config.useAnonymousSession; + document.getElementById('userToken').value = config.userToken; +} + +// Toggle config form visibility +// eslint-disable-next-line no-unused-vars +function toggleConfigForm() { + const content = document.getElementById('configFormContent'); + const icon = document.getElementById('configToggleIcon'); + if (content.style.display === 'none' || !content.style.display) { + content.style.display = 'block'; + icon.textContent = '▲'; + } else { + content.style.display = 'none'; + icon.textContent = '▼'; + } +} + +// Save config and reload page +// eslint-disable-next-line no-unused-vars +function saveConfig(event) { + event.preventDefault(); + const newConfig = { + siteId: document.getElementById('siteId').value, + dataCenter: document.getElementById('dataCenter').value, + env: document.getElementById('env').value, + logging: document.getElementById('logging').checked, + useAnonymousSession: document.getElementById('useAnonymousSession').checked, + userToken: document.getElementById('userToken').value + }; + localStorage.setItem('gistConfig', JSON.stringify(newConfig)); + window.location.reload(); +} + +// Reset to default config +// eslint-disable-next-line no-unused-vars +function resetConfig() { + if (confirm('Are you sure you want to reset to default configuration?')) { + localStorage.removeItem('gistConfig'); + window.location.reload(); + } +} + +// Initialize form on page load +window.addEventListener('DOMContentLoaded', function() { + populateConfigForm(); +}); diff --git a/examples/styles.css b/examples/styles.css index 5f447c9..53f12dd 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,369 @@ 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.unopened { + 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; +} + +.inbox-message-header p { + font-size: 12px; + font-style: italic; +} + +.unopened-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%; + } +} + +/* Config Form Sticky */ +.config-form-sticky { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background-color: #264653; + box-shadow: 0 -2px 8px rgba(0,0,0,0.2); + z-index: 998; +} + +.config-form-header { + padding: 12px 16px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + color: white; + font-weight: 600; + font-size: 14px; + user-select: none; + background-color: #264653; + transition: background-color 0.2s; +} + +.config-form-header:hover { + background-color: #1d3a44; +} + +.config-form-content { + display: none; + max-height: 60vh; + overflow-y: auto; + background-color: #f5f5f5; + padding: 16px; + border-top: 1px solid #1d3a44; +} + +#configForm { + max-width: 1200px; + margin: 0 auto; +} + +.form-section { + background-color: white; + padding: 16px; + border-radius: 8px; + margin-bottom: 16px; +} + +.form-section h4 { + margin: 0 0 16px 0; + color: #264653; + font-size: 16px; + border-bottom: 2px solid #e76f51; + padding-bottom: 8px; +} + +.form-field { + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 12px; +} + +.form-field label { + min-width: 180px; + font-size: 14px; + color: #333; + font-weight: 500; +} + +.form-field input[type="text"], +.form-field select { + flex: 1; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + font-family: 'Courier New', monospace; +} + +.form-field input[type="text"]:focus, +.form-field select:focus { + outline: none; + border-color: #e76f51; +} + +.form-field input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + margin-left: 0; +} + +.form-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 16px; +} + +.form-actions .button { + margin: 0; + height: auto; + min-width: 120px; + padding: 10px 20px; + font-size: 14px; + cursor: pointer; + border: none; +} + +.form-actions .button.primary { + background-color: #e76f51; + color: white; +} + +.form-actions .button:not(.primary) { + background-color: #888; +} + +.form-actions .button:hover { + opacity: 0.9; +} + +@media (max-width: 800px) { + .form-field { + flex-direction: column; + align-items: flex-start; + } + + .form-field label { + min-width: auto; + width: 100%; + } + + .form-field input[type="text"], + .form-field select { + width: 100%; + } + + .form-actions { + flex-direction: column; + } + + .form-actions .button { + width: 100%; + } } \ No newline at end of file diff --git a/src/gist.js b/src/gist.js index 56c720f..f30b1af 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, + updateInboxMessageOpenState, + removeInboxMessage +} from "./managers/inbox-message-manager"; export default class { static async setup(config) { @@ -125,4 +130,23 @@ export default class { this.events.dispatch('messageAction', {message: message, action: action, name: name}); } + // Inbox Messages + + static async getInboxUnopenedCount() { + const messages = await getInboxMessagesFromLocalStore(); + return messages.filter(msg => !msg.opened).length; + } + + static async getInboxMessages() { + return await getInboxMessagesFromLocalStore(); + } + + static async updateInboxMessageOpenState(queueId, opened) { + return await updateInboxMessageOpenState(queueId, opened); + } + + 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..de6e1b4 --- /dev/null +++ b/src/managers/inbox-message-manager.js @@ -0,0 +1,115 @@ +import Gist from '../gist'; +import { getKeyFromLocalStore, setKeyToLocalStore } from '../utilities/local-storage'; +import { getHashedUserToken } from './user-manager'; +import { log } from '../utilities/log'; +import { logUserMessageView } from '../services/log-service'; +import { updateMessage } from '../services/message-service'; + +const messageInboxUpdatedEventName = 'messageInboxUpdated'; +const inboxMessageEventName = 'inboxMessageAction'; +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(messageInboxUpdatedEventName, messages); +} + +export async function getInboxMessagesFromLocalStore() { + const inboxLocalStoreName = await getInboxMessagesLocalStoreName(); + if (!inboxLocalStoreName) return []; + + const storedMessages = getKeyFromLocalStore(inboxLocalStoreName) ?? []; + const now = new Date(); + + 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 updateInboxMessageOpenState(queueId, opened) { + const inboxLocalStoreName = await getInboxMessagesLocalStoreName(); + if (!inboxLocalStoreName) return; + + const response = await updateMessage(queueId, { opened: opened }); + + if (!response || response.status < 200 || response.status >= 300) { + const errorMsg = `Failed to mark inbox message opened: ${response?.status || 'unknown error'}`; + log(errorMsg); + throw new Error(errorMsg); + } + + const messages = await getInboxMessagesFromLocalStore(); + let updatedMessage = null; + const updatedMessages = messages.map(message => { + if (message.queueId === queueId) { + message.opened = opened; + updatedMessage = message; + return { ...message }; + } + return message; + }); + + + const expiryDate = new Date(); + expiryDate.setMinutes(expiryDate.getMinutes() + inboxMessagesLocalStoreCacheInMinutes); + setKeyToLocalStore(inboxLocalStoreName, updatedMessages, expiryDate); + + if (updatedMessage) { + const action = opened ? 'opened' : 'unopened'; + Gist.events.dispatch(inboxMessageEventName, {message: updatedMessage, action: action}); + } + + Gist.events.dispatch(messageInboxUpdatedEventName, await getInboxMessagesFromLocalStore()); +} + +export async function removeInboxMessage(queueId) { + const inboxLocalStoreName = await getInboxMessagesLocalStoreName(); + if (!inboxLocalStoreName) return; + + const response = await logUserMessageView(queueId); + + if (!response || response.status < 200 || response.status >= 300) { + const errorMsg = `Failed to remove inbox message: ${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); + + Gist.events.dispatch(messageInboxUpdatedEventName, await getInboxMessagesFromLocalStore()); +} + +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/message-service.js b/src/services/message-service.js new file mode 100644 index 0000000..e91eed0 --- /dev/null +++ b/src/services/message-service.js @@ -0,0 +1,16 @@ +import { UserNetworkInstance } from '../services/network'; + +export async function updateMessage(queueId, updatedFields) { + try { + const response = await UserNetworkInstance()(`/api/v1/messages/${queueId}`, { + method: 'PATCH', + body: JSON.stringify(updatedFields), + headers: { + 'Content-Type': 'application/json' + } + }); + return response; + } catch (error) { + return error.response; + } +} 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) { diff --git a/src/utilities/event-emitter.js b/src/utilities/event-emitter.js index 0606ead..822735a 100644 --- a/src/utilities/event-emitter.js +++ b/src/utilities/event-emitter.js @@ -5,6 +5,25 @@ export default class EventEmitter { else callbacks.push(callback); } + off(name, callback) { + var callbacks = this[name]; + if (!callbacks) return; + + if (!callback) { + delete this[name]; + return; + } + + var index = callbacks.indexOf(callback); + if (index !== -1) { + callbacks.splice(index, 1); + } + + if (callbacks.length === 0) { + delete this[name]; + } + } + dispatch(name, event) { var callbacks = this[name]; if (callbacks) callbacks.forEach(callback => callback(event));