Script Notifier

Sistema de notificações para UserScripts

Ce script ne doit pas être installé directement. C'est une librairie destinée à être incluse dans d'autres scripts avec la méta-directive // @require https://update.greasyfork.org/scripts/549920/1662869/Script%20Notifier.js

// ==UserScript==
// @name               Script Notifier
// @namespace          http://github.com/0H4S
// @version            1.0
// @author             OHAS
// @description        Sistema de notificações para UserScripts
// @license            Copyright © 2025 OHAS. All Rights Reserved.
// ==/UserScript==
/*
    ScriptNotifier - Version: 1.0
    Copyright Notice & Terms of Use
    Copyright © 2025 OHAS. All Rights Reserved.

    This software is the exclusive property of OHAS and is licensed for personal, non-commercial use only.

    You may:
    - Install, use, and inspect the code for learning or personal purposes.

    You may NOT (without prior written permission from the author):
    - Copy, redistribute, or republish this software.
    - Modify, sell, or use it commercially.
    - Create derivative works.

    For questions, permission requests, or alternative licensing, please contact via
    GitHub: https://github.com/0H4S
    Greasy Fork: https://greasyfork.org/users/1464180-ohas

    This software is provided "as is", without warranty of any kind. The author is not liable for any damages arising from its use.
*/
class ScriptNotifier {
  constructor({ notificationsUrl, scriptVersion, currentLang }) {
    this.NOTIFICATIONS_URL = notificationsUrl;
    this.SCRIPT_VERSION = scriptVersion;
    this.uiStrings = {
        showAllNotificationsCmd: {
            'pt-BR': '🔔 Notificações',
            'en': '🔔 Notifications',
            'es-419': '🔔 Notificaciones',
            'zh-CN': '🔔 通知'
        },
        disableNotificationsCmd: {
            'pt-BR': '❌ Desativar Notificações',
            'en': '❌ Disable Notifications',
            'es-419': '❌ Desactivar Notificaciones',
            'zh-CN': '❌ 禁用通知'
        },
        enableNotificationsCmd: {
            'pt-BR': '✅ Ativar Notificações',
            'en': '✅ Enable Notifications',
            'es-419': '✅ Activar Notificaciones',
            'zh-CN': '✅ 启用通知'
        },
        closeButtonTitle: {
            'pt-BR': 'Fechar',
            'en': 'Close',
            'es-419': 'Cerrar',
            'zh-CN': '关闭'
        }
    };
    this.currentLang = this._initializeLanguage(currentLang);
    this.DISMISSED_NOTIFICATIONS_KEY = 'DismissedNotifications';
    this.NOTIFICATIONS_ENABLED_KEY = 'NotificationsEnabled';
    this.hostElement = null;
    this.shadowRoot = null;
    this.icons = {
        success: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"></path></svg>`,
        warning: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"></path></svg>`,
        info: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></svg>`
    };
    this.activeNotifications = [];
    this.baseSpacing = 20;
    this.scriptPolicy = this._createPolicy();
    if (!document.getElementById('script-notifier-host')) {
        this._createHostAndInjectStyles();
    }
    this.shadowRoot = document.getElementById('script-notifier-host').shadowRoot;
  }
  _initializeLanguage(forcedLang) {
    const supportedLanguages = ['pt-BR', 'en', 'es-419', 'zh-CN'];
    let lang = forcedLang || navigator.language || 'en';
    if (lang.startsWith('pt')) lang = 'pt-BR';
    else if (lang.startsWith('es')) lang = 'es-419';
    else if (lang.startsWith('zh')) lang = 'zh-CN';
    else if (lang.startsWith('en')) lang = 'en';
    if (!supportedLanguages.includes(lang)) {
        return 'en';
    }
    return lang;
  }
  _getUIText(key) {
      if (!this.uiStrings[key]) return '';
      return this.uiStrings[key][this.currentLang] || this.uiStrings[key]['en'];
  }
  async run() {
    await this._registerUserCommands();
    setTimeout(() => this.checkForNotifications(), 1500);
  }
  checkForNotifications() {
    if (!this.NOTIFICATIONS_URL || this.NOTIFICATIONS_URL.includes("SEU_USUARIO")) {
      return;
    }
    GM_xmlhttpRequest({
      method: 'GET', url: `${this.NOTIFICATIONS_URL}?t=${new Date().getTime()}`,
      onload: async (response) => {
        if (response.status >= 200 && response.status < 300) {
          try {
            const data = JSON.parse(response.responseText);
            const notifications = data.notifications;
            await this._cleanupDismissedNotifications(notifications);
            const dismissed = await GM_getValue(this.DISMISSED_NOTIFICATIONS_KEY, []);
            for (const notification of notifications) {
              if (dismissed.includes(notification.id)) continue;
              if (notification.expires && new Date(notification.expires) < new Date()) continue;
              if (notification.targetVersion !== 'all' && notification.targetVersion !== this.SCRIPT_VERSION) continue;
              if (notification.targetHostname && window.location.hostname !== notification.targetHostname) continue;
              this.displayNotification(notification);
            }
          } catch (e) {}
        }
      },
      onerror: () => {}
    });
  }
  forceShowAllNotifications() {
    if (!this.NOTIFICATIONS_URL || this.NOTIFICATIONS_URL.includes("SEU_USUARIO")) return;
    GM_xmlhttpRequest({
      method: 'GET', url: `${this.NOTIFICATIONS_URL}?t=${new Date().getTime()}`,
      onload: (response) => {
        if (response.status >= 200 && response.status < 300) {
          try {
            const data = JSON.parse(response.responseText);
            const notifications = data.notifications;
            for (const notification of notifications) {
              if (notification.expires && new Date(notification.expires) < new Date()) continue;
              if (notification.targetVersion !== 'all' && notification.targetVersion !== this.SCRIPT_VERSION) continue;
              if (notification.targetHostname && window.location.hostname !== notification.targetHostname) continue;
              this.displayNotification(notification);
            }
          } catch (e) {}
        }
      },
      onerror: () => {}
    });
  }
  _createHostAndInjectStyles() {
    this.hostElement = document.createElement('div');
    this.hostElement.id = 'script-notifier-host';
    document.body.appendChild(this.hostElement);
    const shadow = this.hostElement.attachShadow({ mode: 'open' });
    const style = document.createElement('style');
    style.textContent = `
      :host {
        --bg-color: #333;
        --text-color: #ddd;
        --title-color: #fff;
        --link-color: #22c55e;
        --shadow: 0 8px 20px rgba(0,0,0,0.5);
        --border-color: #444;
        --scrollbar-track-color: #444;
        --scrollbar-thumb-color: #666;
        --scrollbar-thumb-hover-color: #888;
        --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
      }
      @media (prefers-color-scheme: light) {
        :host {
          --bg-color: #fff;
          --text-color: #333;
          --title-color: #000;
          --shadow: 0 8px 20px rgba(0,0,0,0.15);
          --border-color: #ddd;
          --scrollbar-track-color: #f1f1f1;
          --scrollbar-thumb-color: #ccc;
          --scrollbar-thumb-hover-color: #aaa;
        }
      }
      .notification-container {
        position: fixed;
        top: ${this.baseSpacing}px;
        right: 20px;
        z-index: 2147483647;
        width: 380px;
        background-color: var(--bg-color);
        color: var(--text-color);
        border-radius: 12px;
        box-shadow: var(--shadow);
        border: 1px solid var(--border-color);
        font-family: var(--font-family);
        transform: translateX(120%);
        opacity: 0;
        transition: transform 0.4s ease-out, opacity 0.4s ease-out;
        overflow: hidden;
        display: flex;
        align-items: flex-start;
        padding: 16px;
        box-sizing: border-box;
        border-left: 5px solid transparent;
      }
      .notification-container[data-type="success"] {
        --type-color: #22c55e;
      }
      .notification-container[data-type="warning"] {
        --type-color: #f97316;
      }
      .notification-container[data-type="info"] {
        --type-color: #3b82f6;
      }
      .notification-container[data-type] {
        border-left-color: var(--type-color);
      }
      .notification-icon {
        width: 24px;
        height: 24px;
        margin-right: 12px;
        flex-shrink: 0;
        color: var(--type-color);
      }
      .notification-image {
        width: 48px;
        height: 48px;
        border-radius: 8px;
        object-fit: cover;
        flex-shrink: 0;
        margin-right: 15px;
      }
      .notification-content {
        flex-grow: 1;
      }
      .notification-title {
        margin: 0 0 8px;
        font-size: 16px;
        font-weight: 600;
        color: var(--title-color);
      }
      .notification-message {
        font-size: 14px;
        line-height: 1.5;
        max-height: 110px;
        overflow-y: auto;
        padding-right: 8px;
      }
      .dismiss-button {
        background: none;
        border: none;
        color: #999;
        font-size: 20px;
        cursor: pointer;
        padding: 0;
        margin-left: 10px;
        line-height: 1;
        align-self: flex-start;
        transition: color 0.2s ease, transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1.5);
        width: 24px;
        height: 24px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        transform-origin: center;
      }
      .dismiss-button:hover {
        color: #ff4d4d;
        transform: rotate(90deg);
      }
      .dismiss-button:active {
        transform: rotate(90deg) scale(0.9);
      }
      .notification-buttons {
        margin-top: 12px;
        display: flex;
        gap: 8px;
      }
      .notification-button {
        background-color: var(--border-color);
        color: var(--text-color);
        border: none;
        border-radius: 6px;
        padding: 6px 12px;
        font-size: 13px;
        font-weight: 500;
        cursor: pointer;
        transition: background-color 0.2s ease, transform 0.1s ease;
      }
      .notification-button:hover {
        background-color: #555;
        color: #fff;
      }
      .notification-button.primary {
        background-color: var(--link-color);
        color: #fff;
      }
      .notification-button.primary:hover {
        filter: brightness(1.1);
      }
      .notification-button.custom-bg:hover {
        filter: brightness(1.15);
      }
      .notification-message::-webkit-scrollbar {
        width: 6px;
      }
      .notification-message::-webkit-scrollbar-track {
        background: var(--scrollbar-track-color);
        border-radius: 3px;
      }
      .notification-message::-webkit-scrollbar-thumb {
        background: var(--scrollbar-thumb-color);
        border-radius: 3px;
      }
      .notification-message::-webkit-scrollbar-thumb:hover {
        background: var(--scrollbar-thumb-hover-color);
      }
      .notification-message a {
        color: var(--link-color);
        text-decoration: none;
      }
      .notification-message a:hover {
        text-decoration: underline;
      }
    `;
    shadow.appendChild(style);
  }
  async displayNotification(notification) {
    const notificationsEnabled = await GM_getValue(this.NOTIFICATIONS_ENABLED_KEY, true);
    if (notification.priority !== 'high' && !notificationsEnabled) return;
    const title = this._getTranslatedText(notification.title);
    const message = this._getTranslatedText(notification.message);
    if (!title || !message) return;
    const notificationId = `notification-${notification.id}`;
    if (this.shadowRoot.getElementById(notificationId)) return;
    const container = document.createElement('div');
    container.id = notificationId;
    container.className = 'notification-container';
    const notificationType = notification.type || 'info';
    container.dataset.type = notificationType;
    const iconSVG = this.icons[notificationType] || this.icons['info'];
    const imageOrIconHTML = notification.imageUrl
      ? `<img src="${notification.imageUrl}" class="notification-image">`
      : `<div class="notification-icon">${iconSVG}</div>`;
    const closeIconSVG = `<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></svg>`;
    const notificationHTML = `
    ${imageOrIconHTML}
    <div class="notification-content">
        <h3 class="notification-title">${title}</h3>
        <div class="notification-message">${this._prepareMessageHTML(message)}</div>
    </div>
    <button class="dismiss-button" title="${this._getUIText('closeButtonTitle')}">${closeIconSVG}</button>
    `;
    this._setSafeInnerHTML(container, notificationHTML);
    if (notification.buttons && notification.buttons.length > 0) {
        const buttonsContainer = document.createElement('div');
        buttonsContainer.className = 'notification-buttons';
        notification.buttons.forEach((buttonData, index) => {
            const button = document.createElement('button');
            button.textContent = this._getTranslatedText(buttonData.text);
            button.className = 'notification-button';
            if (buttonData.backgroundColor) {
                button.style.backgroundColor = buttonData.backgroundColor;
                button.classList.add('custom-bg');
            } else if (index === 0) {
                button.classList.add('primary');
            }
            if (buttonData.textColor) {
                button.style.color = buttonData.textColor;
            }
            button.onclick = () => {
                switch (buttonData.action) {
                    case 'open_url': window.location.href = buttonData.value; break;
                    case 'open_url_new_tab': window.open(buttonData.value, '_blank'); break;
                }
                container.querySelector('.dismiss-button').click();
            };
            buttonsContainer.appendChild(button);
        });
        container.querySelector('.notification-content').appendChild(buttonsContainer);
    }
    this.shadowRoot.appendChild(container);
    this.activeNotifications.unshift({ id: notification.id, element: container });
    this._updateNotificationPositions(true);
    container.querySelector('.dismiss-button').onclick = async () => {
      const dismissed = await GM_getValue(this.DISMISSED_NOTIFICATIONS_KEY, []);
      if (!dismissed.includes(notification.id)) dismissed.push(notification.id);
      await GM_setValue(this.DISMISSED_NOTIFICATIONS_KEY, dismissed);
      container.style.transform = `translateX(120%)`;
      container.style.opacity = '0';
      setTimeout(() => {
        this.activeNotifications = this.activeNotifications.filter(n => n.id !== notification.id);
        container.remove();
        this._updateNotificationPositions(false);
      }, 400);
    };
  }
  _updateNotificationPositions() {
      let currentTop = this.baseSpacing;
      this.activeNotifications.forEach(notif => {
          const { element } = notif;
          element.style.transform = `translateY(${currentTop - this.baseSpacing}px)`;
          element.style.opacity = '1';
          currentTop += element.offsetHeight + (this.baseSpacing / 2);
      });
  }
  _createPolicy() {
    return window.trustedTypes
      ? window.trustedTypes.createPolicy('script-notifier-policy', { createHTML: (input) => input }) : null;
  }
  _setSafeInnerHTML(element, html) {
    if (!element) return;
    element.innerHTML = this.scriptPolicy ? this.scriptPolicy.createHTML(html) : html;
  }
  _getTranslatedText(translationObject) { 
    if (!translationObject) return '';
    if (typeof translationObject === 'string') return translationObject;
    return translationObject[this.currentLang] || translationObject[this.currentLang.split('-')[0]] || translationObject['en'] || '';
  }
  _prepareMessageHTML(text) {
    return text || ''; 
  }
  async _cleanupDismissedNotifications(serverNotifications) {
    const dismissed = await GM_getValue(this.DISMISSED_NOTIFICATIONS_KEY, []);
    if (dismissed.length === 0) return;
    const validServerIds = new Set(
        serverNotifications
            .filter(n => !n.expires || new Date(n.expires) >= new Date())
            .map(n => n.id)
    );
    const cleanedDismissed = dismissed.filter(id => validServerIds.has(id));
    if (cleanedDismissed.length < dismissed.length) {
        await GM_setValue(this.DISMISSED_NOTIFICATIONS_KEY, cleanedDismissed);
    }
  }
  async _registerUserCommands() {
    GM_registerMenuCommand(this._getUIText('showAllNotificationsCmd'), () => { this.forceShowAllNotifications(); });
    const notificationsEnabled = await GM_getValue(this.NOTIFICATIONS_ENABLED_KEY, true);
    const toggleCommandText = notificationsEnabled 
        ? this._getUIText('disableNotificationsCmd') 
        : this._getUIText('enableNotificationsCmd');
    GM_registerMenuCommand(toggleCommandText, async () => {
      const currentState = await GM_getValue(this.NOTIFICATIONS_ENABLED_KEY, true);
      await GM_setValue(this.NOTIFICATIONS_ENABLED_KEY, !currentState);
      window.location.reload();
    });
  }
}