/*
* ThunderAI [https://micz.it/thunderbird-addon-thunderai/]
* Copyright (C) 2024 - 2026 Mic (m@micz.it)
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* You should have received a copy of the GNU General Public License
* along with this program. If not, see
");
} else {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
removeMozMainHeader(doc.body);
html = doc.body.innerHTML;
}
return {text, html};
}
export async function reloadBody(tabId){
let composeDetails = await messenger.compose.getComposeDetails(tabId);
let originalHtmlBody = composeDetails.body + " ";
await messenger.compose.setComposeDetails(tabId, {body: originalHtmlBody});
}
export async function getOriginalBody(tabId){
let composeDetails = await messenger.compose.getComposeDetails(tabId);
return composeDetails.body;
}
export async function setBody(tabId, fullHtmlBody){
await messenger.compose.setComposeDetails(tabId, {body: fullHtmlBody});
}
export async function replaceBody(tabId, replyHtml) {
let composeDetails = await messenger.compose.getComposeDetails(tabId);
let originalHtmlBody = composeDetails.body;
//console.log('originalHtmlBody: ' + originalHtmlBody);
let fullBody = insertHtml(replyHtml, originalHtmlBody);
//console.log('fullBody: ' + fullBody);
await messenger.compose.setComposeDetails(tabId, {body: fullBody});
}
export async function getMailHeader(curr_message, mail_header_id) {
let mail_header_value = "";
let full_message = await browser.messages.getFull(curr_message.id);
// console.log(">>>>>>>>>>>> getMailHeader full_message: " + JSON.stringify(full_message));
if(full_message.hasOwnProperty("headers") && Object.keys(full_message.headers).some(header => header.toLowerCase() === mail_header_id.toLowerCase())){
const raw_value = full_message.headers[Object.keys(full_message.headers).find(header => header.toLowerCase() === mail_header_id.toLowerCase())];
mail_header_value = Array.isArray(raw_value) ? raw_value.join(", ") : raw_value;
}
// console.log(">>>>>>>>>>>> getMailHeader mail_header_value: " + mail_header_value)
return mail_header_value;
}
export function sanitizeHtml(input) {
// Keep only
tags and remove all other HTML tags
return input.replace(/<(?!br\s*\/?)[^>]+>/gi, '');
}
export function sanitizeMailHeaders(input){
// console.log(">>>>>>>>>>>> sanitizeMailHeaders input: " + JSON.stringify(input));
if(!input) return '';
return input.replace(//g, '>');
}
export function stripHtmlKeepLines(htmlString) {
// Replaces
tags with a newline at the beginning // and removes all other HTML tags return convertBrToNewlines(htmlString) .replace(/
/gi, '') // removes
tags .replace(/<\/p>/gi, '\n') // replaces
tags with newline .replace(/<[^>]*>/g, '') // removes any other HTML tags .trim(); // removes leading/trailing whitespace } export function htmlBodyToPlainText(htmlString) { // Create a new DOMParser instance const parser = new DOMParser(); // Parse the HTML string const doc = parser.parseFromString(htmlString, 'text/html'); removeMozMainHeader(doc.body); // remove invisible elements https://stackoverflow.com/questions/39813081/queryselector-where-display-is-not-none // return doc; doc.querySelectorAll('[style*="display:none"]').forEach(e => e.remove());//.querySelector('html').children.not(':visible').remove() doc.querySelectorAll('style').forEach(e => e.remove());//.querySelector('html').children.not(':visible').remove() // Extract text content const textContent = doc.body.textContent || ""; // Trim whitespace return textContent .replace(/\r\n/g, '\n') .replace(/[ \t]+\n/g, '\n') .replace(/\n{2,}/g, '\n') .replace(/[ \t]+/g, ' ') .replace(/ /gi,"") .trim(); } export function removeMozMainHeader(root) { for (const table of root.querySelectorAll('table.moz-main-header')) { let sibling = table.previousElementSibling; while (sibling && sibling.tagName === 'DIV') { const toRemove = sibling; sibling = sibling.previousElementSibling; toRemove.remove(); } table.remove(); } } export function cleanupNewlines(text) { return text .replace(/\r\n/g, '\n') .replace(/[ \t]+\n/g, '\n') .replace(/\n{2,}/g, '\n') .replace(/[ \t]+/g, ' ') .replace(/ /gi,' ') .trim(); } export function convertNewlinesToBr(text) { return text.replace(/\r\n/g, '\n').replace(/\n/g, '${escapeHtml(line)}
`) .join(''); } function escapeHtml(text) { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // This method is used to convert the model string id used in the URL // to the model string used in the webpage export function getGPTWebModelString(model) { if (!model) return ''; model = model.toLowerCase().trim(); switch (model) { case 'gpt-5': return '5'; case 'gpt-5-instant': return '5 Fast'; case 'gpt-5-t-mini': return '5 Thinking mini'; case 'gpt-5-thinking': return '5 Thinking'; default: return model; } } export function getChatGPTWebModelsList_HTML(values, targetRowId) { const rowElement = document.getElementById(targetRowId); if (!rowElement) return; // Clears any existing td elements rowElement.innerHTML = ''; // First TD: label const labelTd = document.createElement('td'); const label = document.createElement('i'); label.className = 'small_info'; const labelNobr = document.createElement('nobr'); labelNobr.textContent = browser.i18n.getMessage("AllowedValues") + ":"; label.appendChild(labelNobr); labelTd.appendChild(label); // Second TD: values const valuesTd = document.createElement('td'); const valuesContainer = document.createElement('i'); valuesContainer.className = 'small_info'; values.forEach(value => { const nbspBefore = document.createTextNode(' \u00A0 '); // " " const valueNobr = document.createElement('nobr'); valueNobr.className = 'conntype_chatgpt_web_option'; valueNobr.textContent = value; const nbspAfter = document.createTextNode(' \u00A0 '); valuesContainer.appendChild(nbspBefore); valuesContainer.appendChild(valueNobr); valuesContainer.appendChild(nbspAfter); }); valuesTd.appendChild(valuesContainer); // Adds the td elements to the row rowElement.appendChild(labelTd); rowElement.appendChild(valuesTd); } export function openTab(url){ // check if the tab is already there browser.tabs.query({url: browser.runtime.getURL(url)}).then((tabs) => { if (tabs.length > 0) { // if the tab is already there, focus it browser.tabs.update(tabs[0].id, {active: true}); } else { // if the tab is not there, create it browser.tabs.create({url: browser.runtime.getURL(url)}); } }) } export function i18nConditionalGet(str) { // if we are getting a string that starts with '__MSG_' and ends with '__' we return the translated string // using the browser.i18n API // else we return the original string // Check if the string starts with '__MSG_' and ends with '__' if (str.startsWith('__MSG_') && str.endsWith('__')) { // Remove '__MSG_' from the beginning and '__' from the end return browser.i18n.getMessage(str.substring(6, str.length - 2)); } return str; // Return the original string if the conditions are not met } function compareThunderbirdVersions(v1, v2) { const v1parts = v1.split('.').map(Number); const v2parts = v2.split('.').map(Number); for (let i = 0; i < Math.max(v1parts.length, v2parts.length); i++) { const v1part = v1parts[i] || 0; const v2part = v2parts[i] || 0; if (v1part > v2part) return 1; if (v1part < v2part) return -1; } return 0; } export function generateCallID(length = 10) { const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; const charactersLength = characters.length; for (let i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; } export async function getTagsList(){ let messageTags = []; // without the tags related permissions, we can't get the tags list messageTags = await browser.messages.tags.list(); const output = messageTags.map(tag => tag.tag).join(', '); const output2 = messageTags.reduce((acc, messageTag) => { acc[messageTag.key] = { tag: messageTag.tag, color: messageTag.color, key: messageTag.key, ordinal: messageTag.ordinal, }; return acc; }, {}); return [output, output2]; // Return the list of tags and the list of tags objects as an array } export async function createTag(tag) { let prefs_tag = await browser.storage.sync.get({ add_tags_first_uppercase: prefs_default.add_tags_first_uppercase }); if(prefs_tag.add_tags_first_uppercase) tag = tag.toLowerCase().charAt(0).toUpperCase() + tag.toLowerCase().slice(1); try { const tagKey = '$ta-' + generateCallID(16) + '-' + sanitizeString(tag); // Ensure uniqueness with a longer random ID return browser.messages.tags.create(tagKey, tag, generateHexColorForTag()); } catch (error) { console.error('[ThunderAI] Error creating tag:', error); } } // export function checkIfTagExists(tag, tags_list) { // console.log(">>>>>>>>>>> checkIfTagExists tags_list: " + JSON.stringify(tags_list)); // console.log(">>>>>>>>>>> checkIfTagExists tag: " + tag); // return tags_list.hasOwnProperty("$ta-" + sanitizeString(tag)); // } export function checkIfTagLabelExists(tag_label, tags_list) { // console.log(">>>>>>>>>>> checkIfTagExists tags_list: " + JSON.stringify(tags_list)); // console.log(">>>>>>>>>>> checkIfTagExists tag_label: " + tag_label); const lowerTagLabel = tag_label.toLowerCase(); return Object.values(tags_list).some(label => label.tag.toLowerCase() === lowerTagLabel); } // export async function assignTagsToMessage(messageId, tags) { // console.log(">>>>>>>>>>> assignTagsToMessage messageId: tags: " + JSON.stringify(tags)); // tags = tags.map(tag => `$ta-${sanitizeString(tag)}`); // let msg_prop = await browser.messages.get(messageId); // tags = tags.concat(msg_prop.tags || []); // try { // return browser.messages.update(messageId, {tags: tags}); // } catch (error) { // console.error('[ThunderAI] Error assigning tag [messageId: ', messageId, ' - tag: ', tag, ']:', error); // } // } export async function assignTagsToMessage(messageId, tags) { // console.log(">>>>>>>>>>> assignTagsToMessage tags: " + JSON.stringify(tags)); let all_tags_list = await getTagsList(); all_tags_list = all_tags_list[1]; tags = getTagsKeyFromLabel(tags, all_tags_list); // console.log(">>>>>>>>>>> assignTagsToMessage tags after conversion: " + JSON.stringify(tags)); let msg_prop = await browser.messages.get(messageId); // console.log(">>>>>>>>>>> assignTagsToMessage msg_prop.tags: " + JSON.stringify(msg_prop.tags)); tags = tags.concat(msg_prop.tags || []); tags = [...new Set(tags)]; // console.log(">>>>>>>>>>> assignTagsToMessage tags after concat: " + JSON.stringify(tags)); try { await browser.messages.update(messageId, {tags: tags}); return tags; // Return the updated tags for confirmation } catch (error) { console.error('[ThunderAI] Error assigning tag [messageId: ', messageId, ' - tag: ', tag, ']:', error); } } function getTagsKeyFromLabel(tag_names, all_tags_list) { const result = []; tag_names.forEach(name => { const lowerName = name.toLowerCase(); const match = Object.entries(all_tags_list).find( ([, value]) => value.tag.toLowerCase() === lowerName ); if (match) { result.push(match[0]); } }); return result; } function sanitizeString(input) { // Replaces accented characters with their non-accented version input = input.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); // Define the regex to match valid characters const validChar = /^[^ ()/{%*<>"]+$/; // Filter out invalid characters from the string let sanitized = ''; for (const char of input) { const cp = char.codePointAt(0); if (cp > 0x7F) { // Encode non-ASCII characters (e.g. Chinese, emoji) as uXXXX sanitized += 'u' + cp.toString(16); } else { if (validChar.test(char)) { sanitized += char; } // else: discard blacklisted ASCII chars } } // Truncate to fit the 50-char total key limit: // $ta- (4) + callID (16) + - (1) + sanitized (max 29) = 50 return sanitized.toLowerCase().slice(0, 29); } /* returnType: 0: string comma separated (default) 1: string \n separated 2: array */ export function normalizeStringList(list, returnType = 0) { let _array_new = list.split(/[\n,]+/); _array_new = Array.from(new Set(_array_new.map(item => item.trim().toLowerCase()))).sort(); switch(returnType) { case 0: return _array_new.join(', '); case 1: return _array_new.join('\n'); case 2: return _array_new; default: return _array_new.join(', '); } } export function prepareOriginURL(url) { return url.endsWith('/') ? `${url}*` : `${url}/*`; } function generateHexColorForTag() { const red = Math.floor(Math.random() * 256); const green = Math.floor(Math.random() * 256); const blue = Math.floor(Math.random() * 256); const hexColor = `#${red.toString(16).padStart(2, '0')}${green.toString(16).padStart(2, '0')}${blue.toString(16).padStart(2, '0')}`; return hexColor; } export async function transformTagsLabels(labels, tags_list) { // console.log(">>>>>>>>> transformTagsLabels labels: " + labels); // console.log(">>>>>>>>> transformTagsLabels tags_list: " + tags_list); let output = []; for(let label of labels) { output.push(tags_list[label].tag); } return output; } export function setTomSelectBorder(el){ if (el.getValue() === "") { el.control.style.border = '2px solid red'; } else { el.control.style.border = '1px solid #d0d0d0'; } } export function getAPIsInitMessageString(args = {}) { const { api_string = '', model_string = '', host_string = '', version_string = '', additional_messages = [] } = args; let output = "" + browser.i18n.getMessage("_api_connecting", api_string) + ""; if (model_string !== '') { output += "\n" + browser.i18n.getMessage("_api_connecting_model") + ": " + model_string; } if (host_string !== '') { output += "\n" + browser.i18n.getMessage("_api_connecting_host") + ": " + host_string; } if (version_string !== '') { output += "\n" + browser.i18n.getMessage("_api_connecting_version") + ": " + version_string; } let additional_message = ''; if (additional_messages.length > 0) { additional_message = additional_messages.map(msg => "" + msg.label + ": " + msg.value).join("\n"); } if (additional_message !== '') { output += "\n" + additional_message; } return output; } export function getActiveSpecialPromptsIDs(args = {}) { const { addtags = false, addtags_api = false, get_calendar_event = false, get_calendar_event_from_clipboard = false, get_task = false, spamfilter = false, summarize = false, translate = false, is_chatgpt_web = false } = args; let output = []; // console.log(">>>>>>>>>> getActiveSpecialPromptsIDs args: " + JSON.stringify(args)); if (is_chatgpt_web) { if (addtags_api && addtags) { output.push('prompt_add_tags'); } return output; } if (addtags) { output.push('prompt_add_tags'); } if (get_calendar_event) { output.push('prompt_get_calendar_event'); } if (get_calendar_event_from_clipboard) { output.push('prompt_get_calendar_event_from_clipboard'); } if (get_task) { output.push('prompt_get_task'); } if (spamfilter) { output.push('prompt_spamfilter'); } if (summarize) { output.push('prompt_summarize'); } if (translate) { output.push('prompt_translate_this'); } // console.log(">>>>>>>>>> getActiveSpecialPromptsIDs output: " + JSON.stringify(output)); return output; } export function checkAPIIntegration(connection_type, use_specific_integration, specific_integration_conntype){ return (connection_type !== "chatgpt_web") || (use_specific_integration && (specific_integration_conntype != null) && (specific_integration_conntype !== '')); } export function hasSpecificIntegration(use, conntype){ return use && (conntype != null) && (conntype !== ''); } export function extractJsonObject(inputString) { try { const jsonMatch = inputString.match(/\{[\s\S]*\}/); if (jsonMatch) { const jsonObject = JSON.parse(jsonMatch[0]); // console.log(">>>>>>>>>> Extracted JSON object:", jsonObject); return jsonObject; } else { console.error("[ThunderAI] No JSON object found in the input string."); throw new Error("No JSON object found in the input string."); return null; } } catch (error) { console.error("[ThunderAI] Error extracting JSON object:", error); throw new Error("Error extracting JSON object: " + error.message); return null; } } export function isAPIKeyValue(id){ return id.endsWith('_api_key'); } export function getConnectionType(prefs, prompt, prefix = null) { let defaultType = ''; let specificType = ''; if (prefs !== null) { defaultType = prefs.connection_type; if (typeof prefix === 'string' && prefix) { const useSpecific = getDynamicSettingValue(prefs, prefix, 'use_specific_integration'); if (useSpecific) { specificType = getDynamicSettingValue(prefs, prefix, 'connection_type'); } } } if (specificType && specificType !== '') return specificType; if (prompt) { if (prompt.api_type && prompt.api_type !== '') return prompt.api_type; } return defaultType; } export async function checkSparksPresence() { try { let sparks_current = await browser.runtime.sendMessage('thunderai-sparks@micz.it',{action: "checkPresence"}); if(sparks_current === undefined || sparks_current === null) { return -1; } if (compareThunderbirdVersions(sparks_current, sparks_min) < 0) { return 0; }else{ return 1; } } catch (error) { return -1; } } export function validateChatGPTWebCustomData(data) { return /^\/g\/[a-zA-Z0-9/-]+$/.test(data) || data == ''; } export function sanitizeChatGPTModelData(input) { if(!input) return ''; return encodeURIComponent(input).toLowerCase() } export function sanitizeChatGPTWebCustomData(input) { if(!input) return ''; // Removes all characters that are not letters, numbers, dashes, or slashes return input.replace(/[^\p{L}\p{N}\/-]+/gu, ''); } export function validateCustomData_ChatGPTWeb(event) { let is_valid = validateChatGPTWebCustomData(event.target.value); event.target.style.borderColor = is_valid ? 'green' : 'red'; document.getElementById(event.target.id + '_info').style.color = is_valid ? '' : 'red'; } // From https://thunderbird.topicbox.com/groups/addons/Tafa58394231a18f8-M3e565a75287313ea4395ff5f // Thanks to John Bieling export async function requestSitePermission(url) { // Normalize the origin to ensure it ends with /* const origin = url.replace(/\/?\*?$/, '/*'); const hasPermission = await browser.permissions.contains({ origins: [origin], }); if (hasPermission) { // Permission already granted — safe to save and use the URL. return true; } const granted = await browser.permissions.request({ origins: [origin], }); // If not granted, it's not safe to save or use the URL, // since the user explicitly denied access. return granted; } // The following methods are a modified version derived from https://github.com/ali-raheem/Aify/blob/13ff87583bc520fb80f555ab90a90c5c9df797a7/plugin/content_scripts/compose.js const insertHtml = function (replyHtml, fullBody_string) { const parser = new DOMParser(); let fullBody = parser.parseFromString(fullBody_string, "text/html"); let reply = parser.parseFromString(replyHtml, "text/html"); // looking for the first quoted mail (reply or forward) or the signature, which come first in case of "signature above the quote". let prefix_quote = fullBody.getElementsByClassName("moz-cite-prefix"); if(prefix_quote.length == 0){ prefix_quote = fullBody.getElementsByClassName("moz-forward-container"); } const prefix_sign = fullBody.getElementsByClassName("moz-signature"); let firstElement = null; if (prefix_quote.length > 0 && prefix_sign.length > 0) { firstElement = prefix_quote[0].compareDocumentPosition(prefix_sign[0]) & Node.DOCUMENT_POSITION_FOLLOWING ? prefix_quote[0] : prefix_sign[0]; //console.log('>>>>>>>>>>>>>>> quote and signature found: ' + JSON.stringify(firstElement.innerHTML)) //console.log('>>>>>>>>>>>>>>> DocPosition: ' + prefix_quote[0].compareDocumentPosition(prefix_sign[0])) } else if (prefix_quote.length > 0) { firstElement = prefix_quote[0]; //console.log('>>>>>>>>>>>>>>> quote found') } else if (prefix_sign.length > 0) { firstElement = prefix_sign[0]; //console.log('>>>>>>>>>>>>>>> signature found') } if (firstElement) { let sibling = firstElement.previousSibling; while (sibling) { fullBody.body.removeChild(sibling); sibling = firstElement.previousSibling; } } let final_p = document.createElement("p"); let br1 = document.createElement("br"); let br2 = document.createElement("br"); final_p.appendChild(br1); final_p.appendChild(br2); fullBody.body.insertBefore(final_p, fullBody.body.firstChild); //fullBody.body.insertBefore(reply, fullBody.body.firstChild); let fragment = document.createDocumentFragment(); Array.from(reply.body.childNodes).forEach(node => { if (node.parentNode === reply.body) { fragment.appendChild(node.cloneNode(true)); } }); fullBody.body.insertBefore(fragment, fullBody.body.firstChild); return fullBody.body.innerHTML; } export async function getLocalStorageUsedSpace(){ // we can't use this, see: https://bugzilla.mozilla.org/show_bug.cgi?id=1385832 //let customprompts_space = await browser.storage.local.getBytesInUse("_custom_prompt"); //let's use a workaround let customprompts_space = Object.entries(await browser.storage.local.get(["_custom_prompt", "_default_prompts_properties"])).map(([key, value]) => key.length + JSON.stringify(value).length).reduce((acc, x) => acc + x, 0) return formatBytes(customprompts_space); } export async function getCacheStorageUsedSpace(){ let all = await browser.storage.local.get(null); let cacheSpace = Object.entries(all) .filter(([key]) => key.startsWith('msg:')) .map(([key, value]) => key.length + JSON.stringify(value).length) .reduce((acc, x) => acc + x, 0); return formatBytes(cacheSpace); } function formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes'; const step = 1024; const suffixes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const index = Math.floor(Math.log(bytes) / Math.log(step)); const value = bytes / Math.pow(step, index); // Ensure the index does not exceed the suffix array length const suffix = suffixes[index] || suffixes[suffixes.length - 1]; return `${value.toFixed(decimals)} ${suffix}`; } // We need to migrate the Custom Prompts storage from storage.sync to storage.local // because the storage.sync has a too narrow limit, see https://github.com/micz/ThunderAI/issues/129 export async function migrateCustomPromptsStorage(){ //check if storage.sync has custom prompts let custom_prompts_sync = await browser.storage.sync.get({_custom_prompt: null}); if(custom_prompts_sync._custom_prompt === null){ // There are no custom prompts in storage.sync, nothing to do // console.log("migrateCustomPromptsStorage: no custom prompts in storage.sync, nothing to do"); return; } //check if storage.local has custom prompts let custom_prompts_local = await browser.storage.local.get({_custom_prompt: null}); if(custom_prompts_local._custom_prompt !== null){ // There are custom prompts in storage.local, nothing to do // console.log("migrateCustomPromptsStorage: there are custom prompts in storage.local, nothing to do"); return; } //copy custom prompts from storage.sync to storage.local await browser.storage.local.set({_custom_prompt: custom_prompts_sync._custom_prompt}); await browser.storage.sync.remove("_custom_prompt"); // console.log("migrateCustomPromptsStorage: migrated custom prompts from storage.sync to storage.local"); } // Do the same for the default prompts properties export async function migrateDefaultPromptsPropStorage(){ //check if storage.sync has default prompts properties let default_prompts_properties_sync = await browser.storage.sync.get({_default_prompts_properties: null}); if(default_prompts_properties_sync._default_prompts_properties === null){ // There are no default prompts properties in storage.sync, nothing to do // console.log("migrateDefaultPromptsPropStorage: no default prompts properties in storage.sync, nothing to do"); return; } //check if storage.local has default prompts properties let default_prompts_properties_local = await browser.storage.local.get({_default_prompts_properties: null}); if(default_prompts_properties_local._default_prompts_properties !== null){ // There are default prompts properties in storage.local, nothing to do // console.log("migrateDefaultPromptsPropStorage: there are default prompts properties in storage.local, nothing to do"); return; } //copy default prompts properties from storage.sync to storage.local await browser.storage.local.set({_default_prompts_properties: default_prompts_properties_sync._default_prompts_properties}); await browser.storage.sync.remove("_default_prompts_properties"); // console.log("migrateDefaultPromptsPropStorage: migrated default prompts properties from storage.sync to storage.local"); } export async function* getMessages(list) { let page = await list; for (let message of page.messages) { yield message; } while (page.id) { page = await messenger.messages.continueList(page.id); for (let message of page.messages) { yield message; } } }