diff --git a/README.md b/README.md index 77165d46c0..1dff2adc4c 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ Converse implements a wide range of XMPP Extensions (XEPs), making it one of the | [XEP-0184](https://xmpp.org/extensions/xep-0184.html) | Message Receipt | | | [XEP-0198](https://xmpp.org/extensions/xep-0198.html) | Stream Management | | | [XEP-0199](https://xmpp.org/extensions/xep-0199.html) | XMPP Ping | | +| [XEP-0202](https://xmpp.org/extensions/xep-0202.html) | Entity Time | | | [XEP-0203](https://xmpp.org/extensions/xep-0203.html) | Delayed Delivery | | | [XEP-0206](https://xmpp.org/extensions/xep-0206.html) | XMPP Over BOSH | | | [XEP-0245](https://xmpp.org/extensions/xep-0245.html) | The /me Command | | diff --git a/conversejs.doap b/conversejs.doap index 3fec76155f..f7ed3cb81d 100644 --- a/conversejs.doap +++ b/conversejs.doap @@ -148,6 +148,13 @@ + + + + partial + 1:1 chats only, no MUC occupant support + + diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index ed6220af47..dc5aa7d036 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -2180,6 +2180,23 @@ If true, you'll see yourself in your list of contacts (aka the "roster") and can open a chat with yourself. +send_entity_time +---------------- + +* Default: ``true`` + +If enabled, Converse will respond to entity time queries from other users +(per `XEP-0202 `_), sharing your +local timezone information. + +Set to ``false`` if you prefer not to share your timezone with others for +privacy reasons. When disabled, Converse will respond with a +``service-unavailable`` error to time queries. + +Note: This setting controls *outgoing* timezone information. To disable +*incoming* time warnings, use ``show_entity_time``. + + show_send_button ---------------- @@ -2189,6 +2206,50 @@ Adds a button to the chat which can be clicked or tapped in order to send the message. +show_entity_time +---------------- + +* Default: ``true`` + +If enabled, Converse will query contacts for their local time (per `XEP-0202 `_) +and show a warning bar in private chats when the contact is in "off-hours" +(e.g., nighttime in their timezone). + +This is useful for distributed teams to avoid messaging colleagues at inappropriate times. + +Related settings: ``entity_time_warning_start``, ``entity_time_warning_end``, +``entity_time_min_diff_hours``. + + +entity_time_warning_start +------------------------- + +* Default: ``22`` + +The hour (0-23) at which the "off-hours" warning period starts. +Default is 22 (10 PM). + + +entity_time_warning_end +----------------------- + +* Default: ``7`` + +The hour (0-23) at which the "off-hours" warning period ends. +Default is 7 (7 AM). + + +entity_time_min_diff_hours +-------------------------- + +* Default: ``0`` + +Minimum timezone difference (in hours) before showing the warning. + +- ``0``: Show warning for any different timezone (default) +- ``3``: Only show warning if contact is 3+ hours ahead/behind + + show_tab_notifications ---------------------- diff --git a/karma.conf.js b/karma.conf.js index be4259d2c5..2a748f2c29 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -66,6 +66,7 @@ module.exports = function(config) { { pattern: "src/plugins/rootview/tests/*.js", type: 'module' }, { pattern: "src/plugins/rosterview/tests/*.js", type: 'module' }, { pattern: "src/plugins/rosterview/tests/requesting_contacts.js", type: 'module' }, + { pattern: "src/plugins/time-views/tests/*.js", type: 'module' }, { pattern: "src/shared/modals/tests/*.js", type: 'module' }, { pattern: "src/utils/tests/*.js", type: 'module' }, ], diff --git a/src/headless/index.js b/src/headless/index.js index 471afd2c6a..506c5c69be 100644 --- a/src/headless/index.js +++ b/src/headless/index.js @@ -35,6 +35,7 @@ export { MUCMessage, MUCMessages, MUC, MUCOccupant, MUCOccupants } from './plugi import './plugins/ping/index.js'; // XEP-0199 XMPP Ping import './plugins/pubsub/index.js'; // XEP-0060 Pubsub +import './plugins/time/index.js'; // XEP-0202 Entity Time // RFC-6121 Contacts Roster export { RosterContact, RosterContacts, RosterFilter, Presence, Presences } from './plugins/roster/index.js'; diff --git a/src/headless/karma.conf.js b/src/headless/karma.conf.js index 9b4d356c40..aed4a68e46 100644 --- a/src/headless/karma.conf.js +++ b/src/headless/karma.conf.js @@ -27,6 +27,7 @@ module.exports = function(config) { { pattern: "plugins/roster/tests/*.js", type: 'module' }, { pattern: "plugins/smacks/tests/*.js", type: 'module' }, { pattern: "plugins/status/tests/*.js", type: 'module' }, + { pattern: "plugins/time/tests/*.js", type: 'module' }, ], client: { jasmine: { diff --git a/src/headless/plugins/time/api.js b/src/headless/plugins/time/api.js new file mode 100644 index 0000000000..debb8e417b --- /dev/null +++ b/src/headless/plugins/time/api.js @@ -0,0 +1,57 @@ +import api from '../../shared/api/index.js'; +import converse from '../../shared/api/public.js'; +import log from '@converse/log'; + +const { Strophe, stx, u } = converse.env; + +export default { + /** + * The "time" namespace groups methods for XEP-0202 Entity Time + * @namespace api.time + * @memberOf api + */ + time: { + /** + * Gets the entity time from a JID per XEP-0202 + * @method api.time.get + * @param {string} jid - The JID to query for time + * @param {number} [timeout=10000] - Timeout in milliseconds + * @returns {Promise<{utc: Date, tzo: string}|null>} The entity's time info or null on error + */ + async get(jid, timeout = 10000) { + if (!api.connection.authenticated()) { + log.debug('Not querying time when not authenticated'); + return null; + } + + const iq = stx` + + `; + + const result = await api.sendIQ(iq, timeout, false); + + if (result === null) { + log.warn(`Timeout while getting time from ${jid}`); + return null; + } else if (u.isErrorStanza(result)) { + log.debug(`Error getting time from ${jid} (entity may not support XEP-0202)`); + return null; + } + + const time_el = result.querySelector('time'); + const utc_str = time_el?.querySelector('utc')?.textContent; + const tzo = time_el?.querySelector('tzo')?.textContent; + + if (!utc_str || !tzo) { + log.error('Invalid time response - missing utc or tzo'); + return null; + } + + return { + utc: new Date(utc_str), + tzo: tzo + }; + } + } +}; diff --git a/src/headless/plugins/time/index.js b/src/headless/plugins/time/index.js new file mode 100644 index 0000000000..8627077a09 --- /dev/null +++ b/src/headless/plugins/time/index.js @@ -0,0 +1,29 @@ +/** + * @description + * Converse.js plugin which adds support for XEP-0202 Entity Time. + * @see https://xmpp.org/extensions/xep-0202.html + * @copyright 2026, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import api from '../../shared/api/index.js'; +import converse from '../../shared/api/public.js'; +import time_api from './api.js'; +import { registerTimeHandler } from './utils.js'; + +converse.plugins.add('converse-time', { + + initialize() { + api.settings.extend({ + 'send_entity_time': true, // Whether to respond to time requests + 'show_entity_time': true, + 'entity_time_warning_start': 22, + 'entity_time_warning_end': 7, + 'entity_time_min_diff_hours': 0, // Minimum timezone difference to show warning (0 = any different timezone) + }); + + Object.assign(api, time_api); + + api.listen.on('connected', registerTimeHandler); + api.listen.on('reconnected', registerTimeHandler); + } +}); diff --git a/src/headless/plugins/time/tests/time.js b/src/headless/plugins/time/tests/time.js new file mode 100644 index 0000000000..16c3760583 --- /dev/null +++ b/src/headless/plugins/time/tests/time.js @@ -0,0 +1,192 @@ +/*global converse */ +import mock from "../../../tests/mock.js"; + +const { Strophe, sizzle, u, stx } = converse.env; + +/** + * Helper to find a time IQ stanza in the IQ_stanzas array. + * @param {Element[]} stanzas - Array of IQ stanzas + * @returns {Element|undefined} The time IQ stanza if found + */ +function findTimeIQ(stanzas) { + return stanzas.find(iq => sizzle(`time[xmlns="${Strophe.NS.TIME}"]`, iq).length); +} + +describe('XEP-0202 Entity Time', function () { + + describe('Responding to time requests', function () { + + it('responds with current time when queried', + mock.initConverse(['statusInitialized'], {}, (_converse) => { + const ping = u.toStanza(` + + `); + _converse.api.connection.get()._dataRecv(mock.createRequest(ping)); + + const sent_stanza = _converse.api.connection.get().IQ_stanzas.pop(); + expect(sent_stanza.getAttribute('type')).toBe('result'); + expect(sent_stanza.getAttribute('to')).toBe('romeo@montague.lit/orchard'); + expect(sent_stanza.getAttribute('id')).toBe('time-1'); + + const time_el = sent_stanza.querySelector('time'); + expect(time_el).not.toBeNull(); + expect(time_el.namespaceURI).toBe('urn:xmpp:time'); + + const tzo = time_el.querySelector('tzo'); + const utc = time_el.querySelector('utc'); + expect(tzo).not.toBeNull(); + expect(utc).not.toBeNull(); + + // Verify TZO format (±HH:MM) + expect(tzo.textContent).toMatch(/^[+-]\d{2}:\d{2}$/); + + // Verify UTC format (ISO 8601 without milliseconds) + expect(utc.textContent).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + }) + ); + + it('returns service-unavailable when send_entity_time is disabled', + mock.initConverse(['statusInitialized'], { + send_entity_time: false + }, (_converse) => { + const time_iq = u.toStanza(` + + `); + _converse.api.connection.get()._dataRecv(mock.createRequest(time_iq)); + + const sent_stanza = _converse.api.connection.get().IQ_stanzas.pop(); + expect(sent_stanza.getAttribute('type')).toBe('error'); + expect(sent_stanza.getAttribute('to')).toBe('romeo@montague.lit/orchard'); + expect(sent_stanza.getAttribute('id')).toBe('time-1'); + + const error_el = sent_stanza.querySelector('error'); + expect(error_el).not.toBeNull(); + expect(error_el.getAttribute('type')).toBe('cancel'); + + const unavailable = sizzle('service-unavailable[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', error_el); + expect(unavailable.length).toBe(1); + }) + ); + }); + + describe('Querying entity time', function () { + + it('can query another entity for their time', + mock.initConverse(['statusInitialized'], {}, async (_converse) => { + const jid = 'juliet@capulet.lit/balcony'; + + // Start the query + const promise = _converse.api.time.get(jid); + + // Get the sent IQ (filter for time query specifically) + const sent_iq = await u.waitUntil(() => + findTimeIQ(_converse.api.connection.get().IQ_stanzas) + ); + + expect(sent_iq.getAttribute('type')).toBe('get'); + expect(sent_iq.getAttribute('to')).toBe(jid); + + const time_el = sent_iq.querySelector('time'); + expect(time_el).not.toBeNull(); + expect(time_el.namespaceURI).toBe('urn:xmpp:time'); + + // Simulate response + const id = sent_iq.getAttribute('id'); + const response = stx` + + + `; + _converse.api.connection.get()._dataRecv(mock.createRequest(response)); + + const result = await promise; + expect(result).not.toBeNull(); + expect(result.tzo).toBe('-06:00'); + expect(result.utc).toEqual(new Date('2026-03-16T12:00:00Z')); + }) + ); + + it('returns null when entity does not support XEP-0202', + mock.initConverse(['statusInitialized'], {}, async (_converse) => { + const jid = 'juliet@capulet.lit/balcony'; + + const promise = _converse.api.time.get(jid, 1000); + + // Get the sent IQ (filter for time query specifically) + const sent_iq = await u.waitUntil(() => + findTimeIQ(_converse.api.connection.get().IQ_stanzas) + ); + const id = sent_iq.getAttribute('id'); + + // Simulate error response (feature not implemented) + const response = stx` + + + + + `; + _converse.api.connection.get()._dataRecv(mock.createRequest(response)); + + const result = await promise; + expect(result).toBeNull(); + }) + ); + + it('returns null when not authenticated', + mock.initConverse([], { auto_login: false }, async (_converse) => { + const result = await _converse.api.time.get('someone@example.com'); + expect(result).toBeNull(); + }) + ); + }); + + describe('Utility functions', function () { + + it('parseTZO correctly parses timezone offsets', mock.initConverse(['statusInitialized'], {}, () => { + const { parseTZO } = converse.env.u.time; + expect(parseTZO('+00:00')).toBe(0); + expect(parseTZO('+05:30')).toBe(330); + expect(parseTZO('-08:00')).toBe(-480); + expect(parseTZO('-05:45')).toBe(-345); + expect(parseTZO('invalid')).toBe(0); + })); + + it('isOffHours correctly identifies nighttime hours', mock.initConverse(['statusInitialized'], {}, () => { + const { isOffHours } = converse.env.u.time; + // Default range: 22:00 - 07:00 + expect(isOffHours(22)).toBe(true); + expect(isOffHours(23)).toBe(true); + expect(isOffHours(0)).toBe(true); + expect(isOffHours(3)).toBe(true); + expect(isOffHours(6)).toBe(true); + expect(isOffHours(7)).toBe(false); + expect(isOffHours(12)).toBe(false); + expect(isOffHours(21)).toBe(false); + })); + + it('getRemoteHour calculates correct remote hour', mock.initConverse(['statusInitialized'], {}, () => { + const { getRemoteHour } = converse.env.u.time; + const utc = new Date('2026-03-16T12:00:00Z'); // Noon UTC + + expect(getRemoteHour(utc, '+00:00')).toBe(12); // UTC + expect(getRemoteHour(utc, '+05:30')).toBe(17); // India (17:30) + expect(getRemoteHour(utc, '-08:00')).toBe(4); // Pacific (04:00) + expect(getRemoteHour(utc, '-05:00')).toBe(7); // Eastern (07:00) + })); + + it('formatRemoteTime formats time correctly', mock.initConverse(['statusInitialized'], {}, () => { + const { formatRemoteTime } = converse.env.u.time; + const utc = new Date('2026-03-16T12:00:00Z'); + + // Note: exact format depends on locale, but should contain hour:minute + const formatted = formatRemoteTime(utc, '+00:00'); + expect(formatted).toMatch(/12:00/); + })); + }); +}); diff --git a/src/headless/plugins/time/utils.js b/src/headless/plugins/time/utils.js new file mode 100644 index 0000000000..bab3114806 --- /dev/null +++ b/src/headless/plugins/time/utils.js @@ -0,0 +1,152 @@ +import api from '../../shared/api/index.js'; +import converse from '../../shared/api/public.js'; +import u from '../../utils/index.js'; + +const { Strophe, stx } = converse.env; + +/** + * Responds to incoming time requests per XEP-0202 + * @param {Element} iq - The incoming IQ stanza + * @returns {boolean} + */ +function handleTimeRequest(iq) { + const from = iq.getAttribute('from'); + const id = iq.getAttribute('id'); + + const now = new Date(); + // Get timezone offset in ±HH:MM format + const tzo_minutes = now.getTimezoneOffset(); + const tzo_sign = tzo_minutes <= 0 ? '+' : '-'; + const tzo_hours = String(Math.floor(Math.abs(tzo_minutes) / 60)).padStart(2, '0'); + const tzo_mins = String(Math.abs(tzo_minutes) % 60).padStart(2, '0'); + const tzo = `${tzo_sign}${tzo_hours}:${tzo_mins}`; + + // Get UTC time in ISO 8601 format (without milliseconds) + const utc = now.toISOString().replace(/\.\d{3}Z$/, 'Z'); + + const response = stx` + + + `; + + api.sendIQ(response); + return true; +} + +/** + * Responds to incoming time requests with service-unavailable when disabled + * @param {Element} iq - The incoming IQ stanza + * @returns {boolean} + */ +function handleTimeRequestDisabled(iq) { + const from = iq.getAttribute('from'); + const id = iq.getAttribute('id'); + + const response = stx` + + + + + `; + + api.sendIQ(response); + return true; +} + +/** + * Registers the XEP-0202 time handler and advertises support via disco + */ +export function registerTimeHandler() { + const connection = api.connection.get(); + + // If not configured to respond, register handler that returns service-unavailable + if (!api.settings.get('send_entity_time')) { + return connection.addHandler(handleTimeRequestDisabled, Strophe.NS.TIME, 'iq', 'get'); + } + + return connection.addHandler(handleTimeRequest, Strophe.NS.TIME, 'iq', 'get'); +} + +/** + * Parses timezone offset string (±HH:MM) to minutes + * @param {string} tzo - Timezone offset string like "+05:30" or "-08:00" + * @returns {number} Offset in minutes + */ +export function parseTZO(tzo) { + const match = tzo.match(/^([+-])(\d{2}):(\d{2})$/); + if (!match) return 0; + const sign = match[1] === '+' ? 1 : -1; + const hours = parseInt(match[2], 10); + const mins = parseInt(match[3], 10); + return sign * (hours * 60 + mins); +} + +/** + * Checks if the given hour falls within "off-hours" (e.g., nighttime) + * @param {number} hour - Hour in 24h format (0-23) + * @param {number} warning_start - Start hour of warning period (default 22) + * @param {number} warning_end - End hour of warning period (default 7) + * @returns {boolean} + */ +export function isOffHours(hour, warning_start = 22, warning_end = 7) { + if (warning_start > warning_end) { + // Range spans midnight (e.g., 22:00 - 07:00) + return hour >= warning_start || hour < warning_end; + } else { + // Range within same day + return hour >= warning_start && hour < warning_end; + } +} + +/** + * Gets the current hour in the remote entity's timezone + * @param {Date} now - The current time (e.g., new Date()) + * @param {string} tzo - Timezone offset string like "+05:30" + * @returns {number} Hour in remote timezone (0-23) + */ +export function getRemoteHour(now, tzo) { + const offset_mins = parseTZO(tzo); + const remote_time = new Date(now.getTime() + offset_mins * 60 * 1000); + return remote_time.getUTCHours(); +} + +/** + * Formats the current time in a remote timezone as HH:MM + * @param {Date} now - The current time (e.g., new Date()) + * @param {string} tzo - Timezone offset string like "+05:30" + * @returns {string} Time string in HH:MM format + */ +export function formatRemoteTime(now, tzo) { + const offset_mins = parseTZO(tzo); + const remote_time = new Date(now.getTime() + offset_mins * 60 * 1000); + const hours = String(remote_time.getUTCHours()).padStart(2, '0'); + const minutes = String(remote_time.getUTCMinutes()).padStart(2, '0'); + return `${hours}:${minutes}`; +} + +/** + * Gets the local (browser) timezone offset in minutes + * @returns {number} Offset in minutes (positive = ahead of UTC) + */ +export function getLocalTZOMinutes() { + // getTimezoneOffset returns minutes behind UTC (negative for ahead) + // Invert it to match the convention used here (positive = ahead of UTC) + return -new Date().getTimezoneOffset(); +} + +/** + * Calculates the absolute difference in hours between two timezones + * @param {string} remote_tzo - Remote timezone offset string like "+05:30" + * @returns {number} Absolute difference in hours + */ +export function getTimezoneDiffHours(remote_tzo) { + const remote_mins = parseTZO(remote_tzo); + const local_mins = getLocalTZOMinutes(); + return Math.abs(remote_mins - local_mins) / 60; +} + +// Export utility functions for use by other plugins +Object.assign(u, { time: { parseTZO, isOffHours, getRemoteHour, formatRemoteTime, getLocalTZOMinutes, getTimezoneDiffHours } }); diff --git a/src/headless/shared/constants.js b/src/headless/shared/constants.js index cef09ea374..f16d0667ee 100644 --- a/src/headless/shared/constants.js +++ b/src/headless/shared/constants.js @@ -117,6 +117,7 @@ Strophe.addNamespace('SID', 'urn:xmpp:sid:0'); Strophe.addNamespace('SPOILER', 'urn:xmpp:spoiler:0'); Strophe.addNamespace('STANZAS', 'urn:ietf:params:xml:ns:xmpp-stanzas'); Strophe.addNamespace('STYLING', 'urn:xmpp:styling:0'); +Strophe.addNamespace('TIME', 'urn:xmpp:time'); Strophe.addNamespace('VCARD', 'vcard-temp'); Strophe.addNamespace('VCARDUPDATE', 'vcard-temp:x:update'); Strophe.addNamespace('XFORM', 'jabber:x:data'); @@ -143,6 +144,7 @@ export const CORE_PLUGINS = [ 'converse-roster', 'converse-smacks', 'converse-status', + 'converse-time', 'converse-vcard', 'converse-omemo', ]; diff --git a/src/index.js b/src/index.js index 94971989d0..31ade41715 100644 --- a/src/index.js +++ b/src/index.js @@ -40,6 +40,7 @@ import "./plugins/roomslist/index.js"; // Show currently open chat rooms import "./plugins/rootview/index.js"; import "./plugins/rosterview/index.js"; import "./plugins/singleton/index.js"; +import "./plugins/time-views/index.js"; // Views for XEP-0202 Entity Time import "./plugins/dragresize/index.js"; // Allows chat boxes to be resized by dragging them import "./plugins/fullscreen/index.js"; /* END: Removable components */ diff --git a/src/plugins/chatview/templates/chat.js b/src/plugins/chatview/templates/chat.js index 795d47e923..40b5373167 100644 --- a/src/plugins/chatview/templates/chat.js +++ b/src/plugins/chatview/templates/chat.js @@ -17,23 +17,23 @@ export default (el) => {
${is_overlayed ? html`` : ''} ${el.model - ? html` + ? html`
${el.model.contact - ? html` + ? html` ` - : ''} + : ''}
${show_help_messages - ? html`
+ ? html`
{ chat_type="${CHATROOMS_TYPE}" >
` - : ''} + : ''}
+
` - : ''} + : ''}
`; }; diff --git a/src/plugins/time-views/entity-time-alert.js b/src/plugins/time-views/entity-time-alert.js new file mode 100644 index 0000000000..0f4baafa9d --- /dev/null +++ b/src/plugins/time-views/entity-time-alert.js @@ -0,0 +1,253 @@ +import { _converse, api, converse } from '@converse/headless'; +import { CustomElement } from 'shared/components/element.js'; +import tplEntityTimeAlert from './templates/entity-time-alert.js'; +import log from '@converse/log'; + +const { u } = converse.env; + +/** + * @typedef {Object} EntityTimeInfo + * @property {Date} utc - The time when the query was made (as Date object) + * @property {string} tzo - Timezone offset string (e.g., "+05:30") + */ + +export default class EntityTimeAlert extends CustomElement { + static properties = { + jid: { type: String }, + }; + + constructor() { + super(); + this.jid = null; + /** @type {EntityTimeInfo|null} */ + this.time_info = null; + this.loading = false; + this.dismissed = false; + /** @type {ReturnType|null} */ + this._fetch_timeout = null; + /** @type {ReturnType|null} */ + this._sync_timeout = null; + /** @type {ReturnType|null} */ + this._update_interval = null; + } + + connectedCallback() { + super.connectedCallback(); + // Sync to minute boundary so displayed time matches the clock + const now = new Date(); + const ms_until_next_minute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); + this._sync_timeout = setTimeout(() => { + this.requestUpdate(); + this._update_interval = setInterval(() => this.requestUpdate(), 60000); + }, ms_until_next_minute); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._sync_timeout) clearTimeout(this._sync_timeout); + if (this._update_interval) clearInterval(this._update_interval); + if (this._fetch_timeout) clearTimeout(this._fetch_timeout); + this._sync_timeout = null; + this._update_interval = null; + this._fetch_timeout = null; + } + + async initialize() { + super.initialize(); + + const { chatboxes } = _converse.state; + this.model = chatboxes.get(this.jid); + + if (!this.model) { + return; + } + + // Reset dismissed flag each time the chat is opened. + if (this.model.get('entity_time_dismissed')) { + this.model.save({ entity_time_dismissed: false }, { silent: true }); + } + + // Listen to dismissed state changes + this.listenTo(this.model, 'change:entity_time_dismissed', () => { + this.dismissed = this.model.get('entity_time_dismissed'); + this.requestUpdate(); + }); + + // Set up and fetch entity time once ready + this._setupAndFetch(); + + // Listen for new messages to get full JID (for non-roster contacts) + if (this.model.messages) { + this.listenTo(this.model.messages, 'add', (msg) => { + if (!this.time_info && msg.get('sender') === 'them') { + this.fetchEntityTime(); + } + }); + } + } + + async _setupAndFetch() { + // Wait for roster contact if applicable + if (this.model.rosterContactAdded) { + await this.model.rosterContactAdded; + if (this.model.contact?.presence) { + this.setupPresenceListeners(); + } + } + + // Wait for messages (fallback source for full JID if not in roster) + if (this.model.messages?.fetched) { + await this.model.messages.fetched; + } + + // Use stored timezone if available, otherwise fetch + const stored = this.model.get('entity_time_info'); + if (stored) { + this.time_info = stored; + this.requestUpdate(); + } else { + this.fetchEntityTime(); + } + } + + setupPresenceListeners() { + const presence = this.model.contact.presence; + + // Refetch when presence changes (contact may have switched devices/timezones) + this.listenTo(presence, 'change', () => this.fetchEntityTime()); + + // Also listen for resources being added/changed (e.g., contact comes online) + if (presence.resources) { + this.listenTo(presence.resources, 'add change', () => this.fetchEntityTime()); + } + } + + /** + * Get full JID (with resource) - needed because bare JID queries go to server. + * @returns {string|null} + */ + getFullJid() { + // Try roster contact's presence first + const resource = this.model.contact?.presence?.getHighestPriorityResource(); + if (resource) { + return `${this.jid}/${resource.get('name')}`; + } + + // Fall back to extracting from recent incoming messages + const messages = this.model.messages; + if (messages?.length) { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages.at(i); + const from = msg.get('from'); + if (from?.includes('/') && msg.get('sender') === 'them') { + return from; + } + } + } + return null; + } + + /** + * Fetch entity time with debouncing to prevent rapid re-queries on presence flapping. + */ + fetchEntityTime() { + if (this._fetch_timeout) { + clearTimeout(this._fetch_timeout); + } + this._fetch_timeout = setTimeout(() => { + this._fetch_timeout = null; + this._doFetch(); + }, 300); + } + + /** + * @private + */ + async _doFetch() { + if (!api.settings.get('show_entity_time') || this.loading || !this.model) { + return; + } + + const full_jid = this.getFullJid(); + if (!full_jid) { + return; + } + + // Only show loading state on initial fetch to avoid flicker during refresh + if (!this.time_info) { + this.loading = true; + this.requestUpdate(); + } + + try { + const result = await api.time.get(full_jid); + if (result) { + this.time_info = result; + this.model.save('entity_time_info', this.time_info, { silent: true }); + } + } catch (e) { + log.error('Error fetching entity time:', e); + } finally { + this.loading = false; + this.requestUpdate(); + } + } + + render() { + if (!api.settings.get('show_entity_time')) return ''; + if (this.dismissed) return ''; + if (this.loading) return ''; + if (!this.time_info) return ''; + if (!u.time) return ''; + + // Use current time + their timezone offset to check if they're in off-hours now + const remote_hour = u.time.getRemoteHour(new Date(), this.time_info.tzo); + + // Check if timezone difference meets minimum threshold + // min_diff_hours=0 means "show for any different timezone" (threshold=1) + // min_diff_hours=3 means "show only if 3+ hours apart" + const min_diff_hours = api.settings.get('entity_time_min_diff_hours'); + const threshold = min_diff_hours === 0 ? 1 : min_diff_hours; + const tz_diff = u.time.getTimezoneDiffHours(this.time_info.tzo); + if (tz_diff < threshold) { + return ''; + } + + const warning_start = api.settings.get('entity_time_warning_start'); + const warning_end = api.settings.get('entity_time_warning_end'); + + if (!u.time.isOffHours(remote_hour, warning_start, warning_end)) { + return ''; + } + + return tplEntityTimeAlert(this); + } + + /** + * @param {MouseEvent} ev + */ + dismiss(ev) { + ev?.preventDefault?.(); + this.model.save({ entity_time_dismissed: true }); + } + + /** + * Gets the display name for the contact + * @returns {string} + */ + getDisplayName() { + return this.model?.contact?.getDisplayName() || this.model?.getDisplayName() || this.jid; + } + + /** + * Gets the formatted current time in the remote contact's timezone + * @returns {string} + */ + getFormattedTime() { + if (!this.time_info) return ''; + // Calculate current time in the contact's timezone using their offset + return u.time.formatRemoteTime(new Date(), this.time_info.tzo); + } +} + +api.elements.define('converse-entity-time-alert', EntityTimeAlert); diff --git a/src/plugins/time-views/index.js b/src/plugins/time-views/index.js new file mode 100644 index 0000000000..856c3cfaab --- /dev/null +++ b/src/plugins/time-views/index.js @@ -0,0 +1,20 @@ +/** + * @description + * Converse.js plugin which adds UI for XEP-0202 Entity Time. + * Shows an alert bar in chats when the contact is in "off-hours" (e.g., nighttime). + * @copyright 2026, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import { converse } from '@converse/headless'; +import './entity-time-alert.js'; + +import './styles/time-alert.scss'; + +converse.plugins.add('converse-time-views', { + + dependencies: ['converse-time', 'converse-chatview'], + + initialize() { + // Element is defined at module level in entity-time-alert.js + } +}); diff --git a/src/plugins/time-views/styles/time-alert.scss b/src/plugins/time-views/styles/time-alert.scss new file mode 100644 index 0000000000..4e3d426813 --- /dev/null +++ b/src/plugins/time-views/styles/time-alert.scss @@ -0,0 +1,40 @@ +converse-entity-time-alert { + display: block; + + .entity-time-alert { + display: flex; + align-items: center; + padding: 0.5em 0.75em; + margin-bottom: 0; + background-color: var(--chat-head-color); + border-left: 3px solid var(--warning-color); + border-top: 1px solid var(--chat-separator-border-color, rgba(0, 0, 0, 0.1)); + box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.05); + gap: 0.5em; + + &__icon { + color: var(--warning-color); + flex-shrink: 0; + } + + &__message { + flex: 1; + font-size: 0.85em; + color: var(--foreground-color); + overflow: hidden; + } + + &__dismiss { + background: none; + border: none; + cursor: pointer; + padding: 0.25em; + color: var(--foreground-color); + opacity: 0.7; + + &:hover { + opacity: 1; + } + } + } +} \ No newline at end of file diff --git a/src/plugins/time-views/templates/entity-time-alert.js b/src/plugins/time-views/templates/entity-time-alert.js new file mode 100644 index 0000000000..5f8ede32bd --- /dev/null +++ b/src/plugins/time-views/templates/entity-time-alert.js @@ -0,0 +1,29 @@ +import { html } from 'lit'; +import { __ } from 'i18n'; + +/** + * @param {import('../entity-time-alert').default} el + */ +export default (el) => { + const display_name = el.getDisplayName(); + const formatted_time = el.getFormattedTime(); + + const i18n_time_warning = __("It's %1$s for %2$s", formatted_time, display_name); + const i18n_dismiss = __('Dismiss'); + + return html` +
+ + ${i18n_time_warning} + +
+ `; +}; diff --git a/src/plugins/time-views/tests/entity-time-alert.js b/src/plugins/time-views/tests/entity-time-alert.js new file mode 100644 index 0000000000..c2ee1321e1 --- /dev/null +++ b/src/plugins/time-views/tests/entity-time-alert.js @@ -0,0 +1,270 @@ +/*global mock, converse */ + +const { Strophe, sizzle, stx, u } = converse.env; + +/** + * Helper to find a time IQ stanza in the IQ_stanzas array. + * @param {Element[]} stanzas - Array of IQ stanzas + * @returns {Element|undefined} The time IQ stanza if found + */ +function findTimeIQ(stanzas) { + return stanzas.find(iq => sizzle(`time[xmlns="${Strophe.NS.TIME}"]`, iq).length); +} + +describe('XEP-0202 Entity Time Views', function () { + + describe('The entity time alert', function () { + // Mock local time to 17:00 UTC for all tests in this suite + const MOCK_TIME = new Date('2026-03-16T17:00:00Z'); + let OriginalDate; + + beforeEach(function () { + OriginalDate = Date; + const MockDate = function (...args) { + if (args.length === 0) { + return new OriginalDate(MOCK_TIME); + } + return new OriginalDate(...args); + }; + MockDate.now = () => MOCK_TIME.getTime(); + MockDate.parse = OriginalDate.parse; + MockDate.UTC = OriginalDate.UTC; + MockDate.prototype = OriginalDate.prototype; + // @ts-ignore + window.Date = MockDate; + }); + + afterEach(function () { + window.Date = OriginalDate; + }); + + it('shows a warning when contact is in off-hours', + mock.initConverse(['chatBoxesFetched'], { + show_entity_time: true, + entity_time_warning_start: 22, + entity_time_warning_end: 7, + }, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + + const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit'; + const full_jid = contact_jid + '/resource'; + + // Send presence so the component can get the full JID + const presence = stx``; + api.connection.get()._dataRecv(mock.createRequest(presence)); + + // Wait for presence resource to be processed before opening chat + const contact = await api.contacts.get(contact_jid); + await u.waitUntil(() => contact.presence?.getHighestPriorityResource()); + + await mock.openChatBoxFor(_converse, contact_jid); + + const view = _converse.chatboxviews.get(contact_jid); + const alert_el = view.querySelector('converse-entity-time-alert'); + expect(alert_el).not.toBeNull(); + + // Wait for the IQ to be sent + const sent_iq = await u.waitUntil(() => findTimeIQ(api.connection.get().IQ_stanzas)); + + expect(sent_iq.getAttribute('to')).toBe(full_jid); + + // Simulate response: contact is at UTC+06:00 + // Local time is 17:00 UTC, so contact's time is 23:00 (off-hours) + const id = sent_iq.getAttribute('id'); + const response = stx` + + + `; + api.connection.get()._dataRecv(mock.createRequest(response)); + + // Wait for alert to show + await u.waitUntil(() => alert_el.querySelector('.entity-time-alert')); + const alert_msg = alert_el.querySelector('.entity-time-alert__message'); + expect(alert_msg.textContent).toContain('23:00'); + }) + ); + + it('does not show warning when contact is not in off-hours', + mock.initConverse(['chatBoxesFetched'], { + show_entity_time: true, + entity_time_warning_start: 22, + entity_time_warning_end: 7, + }, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + + const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit'; + const full_jid = contact_jid + '/resource'; + + // Send presence so the component can get the full JID + const presence = stx``; + api.connection.get()._dataRecv(mock.createRequest(presence)); + + // Wait for presence resource to be processed + const contact = await api.contacts.get(contact_jid); + await u.waitUntil(() => contact.presence?.getHighestPriorityResource()); + + await mock.openChatBoxFor(_converse, contact_jid); + + const view = _converse.chatboxviews.get(contact_jid); + const alert_el = view.querySelector('converse-entity-time-alert'); + + // Wait for the IQ to be sent + const sent_iq = await u.waitUntil(() => findTimeIQ(api.connection.get().IQ_stanzas)); + + // Simulate response: contact is at UTC+00:00 + // Local time is 17:00 UTC, so contact's time is also 17:00 (not off-hours) + const id = sent_iq.getAttribute('id'); + const response = stx` + + + `; + api.connection.get()._dataRecv(mock.createRequest(response)); + + // Give it time to process + await u.waitUntil(() => alert_el.time_info !== null, 500); + + // Alert should not be visible (17:00 is not off-hours) + expect(alert_el.querySelector('.entity-time-alert')).toBeNull(); + }) + ); + + it('can be dismissed', + mock.initConverse(['chatBoxesFetched'], { + show_entity_time: true, + entity_time_warning_start: 22, + entity_time_warning_end: 7, + }, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + + const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit'; + const full_jid = contact_jid + '/resource'; + + // Send presence so the component can get the full JID + const presence = stx``; + api.connection.get()._dataRecv(mock.createRequest(presence)); + + // Wait for presence resource to be processed + const contact = await api.contacts.get(contact_jid); + await u.waitUntil(() => contact.presence?.getHighestPriorityResource()); + + await mock.openChatBoxFor(_converse, contact_jid); + + const view = _converse.chatboxviews.get(contact_jid); + const alert_el = view.querySelector('converse-entity-time-alert'); + + // Wait for the IQ to be sent + const sent_iq = await u.waitUntil(() => findTimeIQ(api.connection.get().IQ_stanzas)); + + // Simulate response: contact is at UTC+06:00 + // Local time is 17:00 UTC, so contact's time is 23:00 (off-hours) + const id = sent_iq.getAttribute('id'); + const response = stx` + + + `; + api.connection.get()._dataRecv(mock.createRequest(response)); + + // Wait for alert to show + await u.waitUntil(() => alert_el.querySelector('.entity-time-alert')); + + // Click dismiss button + const close_btn = alert_el.querySelector('.entity-time-alert__dismiss'); + close_btn.click(); + + // Alert should be hidden + await u.waitUntil(() => !alert_el.querySelector('.entity-time-alert')); + + // Model should have dismiss flag set + const chatbox = _converse.state.chatboxes.get(contact_jid); + expect(chatbox.get('entity_time_dismissed')).toBe(true); + }) + ); + + it('does not show if feature is disabled', + mock.initConverse(['chatBoxesFetched'], { + show_entity_time: false, + }, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + + const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + + const view = _converse.chatboxviews.get(contact_jid); + const alert_el = view.querySelector('converse-entity-time-alert'); + + // Give it time to initialize + await new Promise(resolve => setTimeout(resolve, 100)); + + // No IQ should have been sent + const time_iqs = _converse.api.connection.get().IQ_stanzas.filter( + iq => sizzle(`time[xmlns="${Strophe.NS.TIME}"]`, iq).length + ); + expect(time_iqs.length).toBe(0); + + // Alert should render nothing + expect(alert_el.querySelector('.entity-time-alert')).toBeNull(); + }) + ); + + it('handles entities that do not support XEP-0202', + mock.initConverse(['chatBoxesFetched'], { + show_entity_time: true, + }, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + + const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit'; + const full_jid = contact_jid + '/resource'; + + // Send presence so the component can get the full JID + const presence = stx``; + api.connection.get()._dataRecv(mock.createRequest(presence)); + + // Wait for presence resource to be processed + const contact = await api.contacts.get(contact_jid); + await u.waitUntil(() => contact.presence?.getHighestPriorityResource()); + + await mock.openChatBoxFor(_converse, contact_jid); + + const view = _converse.chatboxviews.get(contact_jid); + const alert_el = view.querySelector('converse-entity-time-alert'); + + // Wait for the IQ to be sent + const sent_iq = await u.waitUntil(() => findTimeIQ(api.connection.get().IQ_stanzas)); + + // Simulate error response (feature not supported) + const id = sent_iq.getAttribute('id'); + const response = stx` + + + + + `; + api.connection.get()._dataRecv(mock.createRequest(response)); + + // Give it time to process error + await new Promise(resolve => setTimeout(resolve, 100)); + + // Alert should not be visible + expect(alert_el.querySelector('.entity-time-alert')).toBeNull(); + }) + ); + }); +}); diff --git a/src/shared/components/templates/icons.js b/src/shared/components/templates/icons.js index 54b5e0609c..370437a8a9 100644 --- a/src/shared/components/templates/icons.js +++ b/src/shared/components/templates/icons.js @@ -100,6 +100,9 @@ export default () => html` + + + @@ -261,6 +264,9 @@ export default () => html` + + + `; diff --git a/src/shared/constants.js b/src/shared/constants.js index 3ae16219f5..996b5249ad 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -27,7 +27,8 @@ export const VIEW_PLUGINS = [ 'converse-roomslist', 'converse-rootview', 'converse-rosterview', - 'converse-singleton' + 'converse-singleton', + 'converse-time-views' ]; /** @@ -41,13 +42,13 @@ export const VIEW_PLUGINS = [ * @property {string} online */ export const PRETTY_CHAT_STATUS = { - offline: __('Offline'), - unavailable: __('Unavailable'), - xa: __('Extended Away'), - away: __('Away'), - dnd: __('Do not disturb'), - chat: __('Chatty'), - online: __('Online') + offline: __('Offline'), + unavailable: __('Unavailable'), + xa: __('Extended Away'), + away: __('Away'), + dnd: __('Do not disturb'), + chat: __('Chatty'), + online: __('Online') };