Lolz <-> LolzGram Sync

Синхронизация lolz.live и LolzGram: OAuth, уведомления и профильные интеграции

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         Lolz <-> LolzGram Sync
// @namespace    lolzgram
// @version      1.5.3
// @description  Синхронизация lolz.live и LolzGram: OAuth, уведомления и профильные интеграции
// @match        https://lolz.live/*
// @match        https://zelenka.guru/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_notification
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @connect      lolzgram.live
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  const STORAGE_KEYS = {
    apiBase: 'lg_api_base',
    token: 'lg_token',
    profile: 'lg_profile',
    pollMs: 'lg_poll_ms',
    lastUnread: 'lg_last_unread',
    seenIds: 'lg_seen_notification_ids',
    oauthState: 'lg_oauth_state',
  };

  const DEFAULTS = {
    apiBase: 'https://lolzgram.live/api',
    pollMs: 30000,
  };
  const ALLOWED_API_BASES = new Set([
    'https://lolzgram.live/api',
  ]);

  let pollTimer = null;
  let lock = false;
  let nativeAlertsUi = null;
  let profileLinkInjectLock = false;
  let apiReadyPromise = null;
  let profileInjectDoneFor = '';
  let profilePathKey = '';
  let profileRootObserver = null;
  const lgUserCache = new Map();
  const lgFollowCache = new Map();
  let lgProfileTabState = null;
  const LG_FOLLOW_PREVIEW = 5;
  const LG_POSTS_PAGE_SIZE = 12;
  const OBSERVER_DEBOUNCE_MS = 120;

  const state = {
    apiBase: sanitizeApiBase(GM_getValue(STORAGE_KEYS.apiBase, DEFAULTS.apiBase)),
    token: GM_getValue(STORAGE_KEYS.token, ''),
    profile: safeJsonParse(GM_getValue(STORAGE_KEYS.profile, ''), null),
    pollMs: sanitizePollMs(GM_getValue(STORAGE_KEYS.pollMs, DEFAULTS.pollMs)),
    lastUnread: Number(GM_getValue(STORAGE_KEYS.lastUnread, 0)) || 0,
    seenIds: new Set(safeJsonParse(GM_getValue(STORAGE_KEYS.seenIds, '[]'), [])),
    latestNotifications: [],
  };

  state.apiBase = DEFAULTS.apiBase;
  persistState();

  function normalizeBase(value) {
    return String(value || '').trim().replace(/\/+$/, '');
  }

  function isAllowedApiBase(value) {
    return ALLOWED_API_BASES.has(normalizeBase(value));
  }

  function sanitizeApiBase(value) {
    const n = normalizeBase(value);
    return isAllowedApiBase(n) ? n : DEFAULTS.apiBase;
  }

  function safeApiOrigin() {
    return sanitizeApiBase(state.apiBase).replace(/\/api$/i, '');
  }

  function debounce(fn, ms) {
    let timer = null;
    return function debounced(...args) {
      clearTimeout(timer);
      timer = setTimeout(() => fn.apply(this, args), ms);
    };
  }

  function isProfilePage() {
    const path = String(window.location.pathname || '');
    if (/^\/members\//i.test(path)) return true;
    return !!document.querySelector('#profile_short, .profile_info_short, .memberHeader');
  }

  function ensureApiReady() {
    if (!apiReadyPromise) {
      apiReadyPromise = resolveApiBase()
        .then(() => true)
        .catch(() => false);
    }
    return apiReadyPromise;
  }

  function isUserNotFoundError(err) {
    const msg = err instanceof Error ? err.message : String(err || '');
    return /404|не найден|not found/i.test(msg);
  }

  function memberSlugToUsername(slug) {
    const s = decodeURIComponent(String(slug || '').trim()).replace(/^@+/, '');
    if (!s) return '';
    const parts = s.split('.').filter(Boolean);
    for (const part of parts) {
      if (!/^\d+$/.test(part)) return part;
    }
    return '';
  }

  function lgUsernameCandidates(raw) {
    const out = [];
    const add = (v) => {
      const s = String(v || '').trim().toLowerCase();
      if (!s || /^\d+$/.test(s) || out.includes(s)) return;
      out.push(s);
    };
    add(raw);
    const path = String(window.location.pathname || '');
    const fromPath = path.match(/\/members\/([^/]+)/i);
    if (fromPath && fromPath[1]) add(memberSlugToUsername(fromPath[1]));
    const canon = document.querySelector('link[rel="canonical"]');
    if (canon && canon.href) {
      const m = canon.href.match(/\/members\/([^/?#]+)/i);
      if (m && m[1]) add(memberSlugToUsername(m[1]));
    }
    for (const a of document.querySelectorAll('a[href*="/members/"]')) {
      const href = a.getAttribute('href') || '';
      const m = href.match(/\/members\/([^/?#]+)/i);
      if (m && m[1]) add(memberSlugToUsername(m[1]));
    }
    const anchor = findProfileUsernameAnchor();
    if (anchor && anchor.textContent) add(anchor.textContent);
    return out;
  }

  async function fetchLgUser(username) {
    const candidates = lgUsernameCandidates(username);
    if (!candidates.length) return null;
    for (const key of candidates) {
      if (lgUserCache.has(key)) {
        const cached = lgUserCache.get(key);
        if (cached) return cached;
        continue;
      }
      try {
        const user = await request(
          'GET',
          `/users/${encodeURIComponent(key)}`,
          null,
          false,
          DEFAULTS.apiBase,
        );
        lgUserCache.set(key, user);
        return user;
      } catch (err) {
        if (isUserNotFoundError(err)) {
          lgUserCache.set(key, null);
          continue;
        }
        return undefined;
      }
    }
    return null;
  }

  function sanitizePollMs(value) {
    const n = Number(value);
    if (!Number.isFinite(n)) return DEFAULTS.pollMs;
    return Math.max(10000, Math.min(300000, Math.floor(n)));
  }

  function safeJsonParse(raw, fallback) {
    try {
      return JSON.parse(raw);
    } catch {
      return fallback;
    }
  }

  function persistState() {
    GM_setValue(STORAGE_KEYS.apiBase, state.apiBase);
    GM_setValue(STORAGE_KEYS.token, state.token);
    GM_setValue(STORAGE_KEYS.profile, JSON.stringify(state.profile || null));
    GM_setValue(STORAGE_KEYS.pollMs, state.pollMs);
    GM_setValue(STORAGE_KEYS.lastUnread, state.lastUnread);
    GM_setValue(STORAGE_KEYS.seenIds, JSON.stringify(Array.from(state.seenIds).slice(-300)));
  }

  function clearAuth() {
    state.token = '';
    state.profile = null;
    state.lastUnread = 0;
    state.seenIds = new Set();
    GM_deleteValue(STORAGE_KEYS.token);
    GM_deleteValue(STORAGE_KEYS.profile);
    GM_deleteValue(STORAGE_KEYS.lastUnread);
    GM_deleteValue(STORAGE_KEYS.seenIds);
    state.latestNotifications = [];
    setStatus('Не подключено');
    setHint('Нажмите "Войти через OAuth"');
    renderNotificationsList();
    renderNativeAlertsTab();
    updateBadge(0);
  }

  function request(method, path, body, withAuth = true, baseOverride = '') {
    const requestedBase = normalizeBase(baseOverride || state.apiBase);
    if (withAuth && !isAllowedApiBase(requestedBase)) {
      return Promise.reject(new Error('Небезопасный API base отклонен'));
    }
    const base = sanitizeApiBase(requestedBase);
    const url = `${base}${path}`;
    return new Promise((resolve, reject) => {
      const headers = { Accept: 'application/json' };
      if (body != null) headers['Content-Type'] = 'application/json';
      if (withAuth && state.token) headers.Authorization = `Bearer ${state.token}`;

      GM_xmlhttpRequest({
        method,
        url,
        headers,
        timeout: 25000,
        data: body != null ? JSON.stringify(body) : undefined,
        onload: (resp) => {
          const text = resp.responseText || '';
          const json = text ? safeJsonParse(text, null) : null;
          if (resp.status >= 200 && resp.status < 300) {
            resolve(json);
            return;
          }
          const detail = json && typeof json === 'object' && json.detail ? String(json.detail) : `HTTP ${resp.status}`;
          reject(new Error(detail));
        },
        ontimeout: () => reject(new Error('Таймаут запроса')),
        onerror: () => reject(new Error('Сетевая ошибка')),
      });
    });
  }

  async function resolveApiBase() {
    const base = sanitizeApiBase(state.apiBase || DEFAULTS.apiBase);
    await request('GET', '/auth/lzt/oauth/url', null, false, base);
    state.apiBase = base;
    persistState();
    return base;
  }

  async function startOAuth() {
    await resolveApiBase();
    const redirectUri = `${window.location.origin}/`;
    setStatus('OAuth: подготовка...');
    const oauth = await request(
      'GET',
      `/auth/lzt/oauth/url?redirect_uri=${encodeURIComponent(redirectUri)}`,
      null,
      false
    );
    if (!oauth || !oauth.authorize_url || !oauth.state) {
      throw new Error('Некорректный ответ OAuth URL');
    }
    GM_setValue(STORAGE_KEYS.oauthState, String(oauth.state));
    window.location.assign(String(oauth.authorize_url));
  }

  async function handleOAuthCallbackIfPresent() {
    const hash = window.location.hash || '';
    if (!hash || hash.length < 2) return false;
    const params = new URLSearchParams(hash.slice(1));
    const accessToken = params.get('access_token');
    const stateParam = params.get('state');
    const error = params.get('error');
    const hasOAuthPayload = !!(accessToken || stateParam || error);
    if (!hasOAuthPayload) return false;

    const cleanUrl = `${window.location.origin}${window.location.pathname}${window.location.search}`;
    window.history.replaceState(null, '', cleanUrl);

    if (error) {
      GM_deleteValue(STORAGE_KEYS.oauthState);
      throw new Error(`OAuth error: ${error}`);
    }
    if (!accessToken || !stateParam) {
      GM_deleteValue(STORAGE_KEYS.oauthState);
      throw new Error('OAuth ответ неполный');
    }

    const expectedState = String(GM_getValue(STORAGE_KEYS.oauthState, '') || '');
    GM_deleteValue(STORAGE_KEYS.oauthState);
    if (!expectedState || expectedState !== stateParam) {
      throw new Error('OAuth state mismatch');
    }

    await resolveApiBase();
    setStatus('OAuth: подтверждение...');
    const complete = await request('POST', '/auth/lzt/oauth/complete', {
      access_token: accessToken,
      state: stateParam,
    }, false);
    if (!complete || !complete.access_token) {
      throw new Error('OAuth complete не вернул access_token');
    }
    state.token = String(complete.access_token);
    persistState();
    await bootstrapAfterLogin();
    return true;
  }

  async function bootstrapAfterLogin() {
    const me = await request('GET', '/auth/me');
    if (!me || !me.id || !me.username) throw new Error('Не удалось получить профиль');
    state.profile = { id: Number(me.id), username: String(me.username) };
    state.lastUnread = 0;
    state.seenIds = new Set();
    persistState();
    setStatus(`Подключено: ${state.profile.username}`);
    setHint('OAuth активен, polling запущен');
    showToast(`LolzGram: вход выполнен (${state.profile.username})`);
    restartPolling(true);
    renderNativeAlertsTab();
  }

  async function verifyToken() {
    if (!state.token) return false;
    try {
      const me = await request('GET', '/auth/me');
      if (!me || !me.id || !me.username) return false;
      state.profile = { id: Number(me.id), username: String(me.username) };
      persistState();
      return true;
    } catch {
      return false;
    }
  }

  function buildNotificationText(item) {
    const type = item && item.type ? String(item.type) : 'event';
    const typeMap = {
      like: 'Лайк',
      comment: 'Комментарий',
      follow: 'Подписка',
      repost: 'Репост',
      tag: 'Упоминание',
      story_reaction: 'Реакция',
      admin: 'Сообщение администрации',
    };
    const header = typeMap[type] || 'Уведомление';
    return { title: `LolzGram • ${header}`, text: notificationText(item) };
  }

  function emitNotification(title, text, onClickUrl) {
    GM_notification({
      title,
      text,
      timeout: 8000,
      onclick: () => {
        if (onClickUrl) window.open(onClickUrl, '_blank', 'noopener,noreferrer');
      },
    });
  }

  function knownNotificationUrl(item) {
    const targetType = item && item.target_type ? String(item.target_type) : '';
    const targetId = item && item.target_id ? Number(item.target_id) : 0;
    const origin = safeApiOrigin();
    if (!targetType || !targetId) return `${origin}/notifications`;
    if (targetType === 'post') return `${origin}/p/${targetId}`;
    if (targetType === 'reel') return `${origin}/reels/${targetId}`;
    if (targetType === 'user' && item.actor && item.actor.username) {
      return `${origin}/u/${encodeURIComponent(item.actor.username)}`;
    }
    return `${origin}/notifications`;
  }

  function knownActorProfileUrl(item) {
    const origin = safeApiOrigin();
    const username = item && item.actor && item.actor.username ? String(item.actor.username).trim() : '';
    if (!username) return `${origin}/notifications`;
    return `${origin}/u/${encodeURIComponent(username)}`;
  }

  function detectForumProfileUsername() {
    const candidates = lgUsernameCandidates('');
    return candidates[0] || '';
  }

  function buildLolzGramProfileUrl(username) {
    const origin = safeApiOrigin();
    return `${origin}/u/${encodeURIComponent(String(username || '').trim())}`;
  }

  function buildLolzGramPostUrl(postId) {
    return `${safeApiOrigin()}/p/${Number(postId) || 0}`;
  }

  function resolveMediaUrl(url) {
    const value = String(url || '').trim();
    if (!value) return '';
    if (/^https?:\/\//i.test(value)) return value;
    const origin = safeApiOrigin();
    if (value.startsWith('/uploads/')) return `${origin}/api/media/${value.slice('/uploads/'.length)}`;
    if (value.startsWith('/')) return `${origin}${value}`;
    return `${origin}/${value}`;
  }

  function truncateText(text, max) {
    const value = String(text || '').trim();
    if (value.length <= max) return value;
    return `${value.slice(0, max).trimEnd()}…`;
  }

  function effectiveProfileBadge(user) {
    if (!user || typeof user !== 'object') return null;
    const hasVerified = Boolean(user.is_lzt_member || user.is_verified);
    const special = [];
    if (user.is_team_member) special.push('team');
    if (user.is_tester) special.push('tester');
    const pref = String(user.profile_badge || 'verified').toLowerCase();
    if (special.length && hasVerified) return special.includes(pref) ? pref : 'verified';
    if (special.length === 1) return special[0];
    if (special.length > 1) return special.includes(pref) ? pref : special[0];
    if (hasVerified) return 'verified';
    return null;
  }

  function createProfileBadgeIcon(kind) {
    const hintMap = {
      verified: ['Участник Lolzgram', 'Вход через', 'Lolzteam'],
      tester: ['Тестер LolzGram', 'Помогает улучшать проект'],
      team: ['Команда LolzGram', 'Разрабатывает проект'],
    };
    const wrap = el('span', {
      id: 'lg-profile-header-badge',
      className: `lg-profile-badge lg-profile-badge--${kind}`,
      'aria-hidden': 'true',
      title: '',
      'data-lg-hint': (hintMap[kind] || hintMap.verified).join('\n'),
    });
    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svg.setAttribute('viewBox', '0 0 24 24');
    svg.setAttribute('width', '14');
    svg.setAttribute('height', '14');
    svg.setAttribute('fill', 'none');
    svg.setAttribute('stroke', 'currentColor');
    svg.setAttribute('stroke-width', '2.5');
    svg.setAttribute('stroke-linecap', 'round');
    svg.setAttribute('stroke-linejoin', 'round');
    const map = {
      verified: [
        'M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z',
        'm9 12 2 2 4-4',
      ],
      tester: [
        'M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2',
        'M6.453 15h11.094',
        'M8.5 2h7',
      ],
      team: [
        'm18 16 4-4-4-4',
        'm6 8-4 4 4 4',
        'm14.5 4-5 16',
      ],
    };
    const lines = map[kind] || map.verified;
    for (const d of lines) {
      const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
      path.setAttribute('d', d);
      svg.appendChild(path);
    }
    wrap.appendChild(svg);
    wrap.addEventListener('mouseenter', () => showBadgeHint(wrap));
    wrap.addEventListener('mouseleave', hideBadgeHint);
    wrap.addEventListener('focus', () => showBadgeHint(wrap));
    wrap.addEventListener('blur', hideBadgeHint);
    return wrap;
  }

  let badgeHintNode = null;
  let badgeHintAnchor = null;

  function showBadgeHint(anchor) {
    if (!anchor || !anchor.isConnected) return;
    const raw = String(anchor.getAttribute('data-lg-hint') || '').trim();
    if (!raw) return;
    const lines = raw.split('\n').filter(Boolean);
    if (!lines.length) return;
    if (!badgeHintNode) {
      badgeHintNode = el('div', { className: 'lg-badge-hint' });
      document.body.appendChild(badgeHintNode);
    }
    badgeHintNode.innerHTML = '';
    lines.forEach((line, idx) => {
      const row = el('div', { className: idx === 0 ? 'lg-badge-hint__main' : 'lg-badge-hint__sub' }, line);
      badgeHintNode.appendChild(row);
    });
    badgeHintNode.classList.add('is-visible');
    badgeHintAnchor = anchor;
    const rect = anchor.getBoundingClientRect();
    const top = Math.max(8, rect.top + window.scrollY - 10);
    const left = rect.left + window.scrollX + rect.width / 2;
    badgeHintNode.style.top = `${top}px`;
    badgeHintNode.style.left = `${left}px`;
  }

  function hideBadgeHint() {
    if (!badgeHintNode) return;
    badgeHintNode.classList.remove('is-visible');
    badgeHintAnchor = null;
  }

  function findProfileUsernameAnchor() {
    const selectors = [
      '.memberHeader-name .username',
      '.memberHeader-name a.username',
      '.memberHeader-name a',
      'h1 .username',
      'h1.username',
      '.memberHeader h1',
      'h1',
    ];
    for (const sel of selectors) {
      const node = document.querySelector(sel);
      if (node && node.textContent && node.textContent.trim()) return node;
    }
    return null;
  }

  function injectForumProfileHeaderBadge(lgUser) {
    if (!lgUser || typeof lgUser !== 'object') return;
    const kind = effectiveProfileBadge(lgUser);
    const old = document.querySelector('#lg-profile-header-badge');
    if (old) old.remove();
    if (!kind) return;
    const usernameNode = findProfileUsernameAnchor();
    if (!usernameNode) return;
    let wrap = document.querySelector('#lg-profile-badge-wrap');
    if (!wrap) {
      wrap = el('span', { className: 'lg-profile-badge-wrap', id: 'lg-profile-badge-wrap' });
      usernameNode.parentNode.insertBefore(wrap, usernameNode);
      wrap.appendChild(usernameNode);
    }
    wrap.appendChild(createProfileBadgeIcon(kind));
  }

  function findProfileInfoRoot() {
    return document.querySelector('#profile_short, .profile_info_short');
  }

  function findNativeProfileSocialBlocks() {
    const scope = document.querySelector('.memberSummary, .profileSidebar, .sidebar, #content') || document;
    return Array.from(scope.querySelectorAll('.secondaryContent')).filter((block) => {
      if (block.id && block.id.startsWith('lg-')) return false;
      if (!block.querySelector('.avatarList')) return false;
      const link = block.querySelector('h3 a');
      if (!link) return false;
      const href = String(link.getAttribute('href') || '').toLowerCase();
      const text = String(link.textContent || '').toLowerCase();
      const isFollowers = href.includes('followers') || text.includes('подписчик');
      const isFollowing = href.includes('following') || text.includes('подписк');
      return isFollowers || isFollowing;
    });
  }

  function findProfileSocialInsertPoint() {
    const blocks = findNativeProfileSocialBlocks();
    if (!blocks.length) return null;
    const last = blocks[blocks.length - 1];
    return { parent: last.parentNode, after: last };
  }

  function placeLgSocialBlocks(followersBlock, followingBlock) {
    if (!followersBlock || !followingBlock) return;
    const anchor = findProfileSocialInsertPoint();
    if (anchor?.parent && anchor.after) {
      const ref = anchor.after;
      if (followersBlock.previousElementSibling !== ref) {
        ref.insertAdjacentElement('afterend', followersBlock);
      }
      if (followingBlock.previousElementSibling !== followersBlock) {
        followersBlock.insertAdjacentElement('afterend', followingBlock);
      }
      return;
    }
    const sidebar = document.querySelector('.memberSummary, .profileSidebar, .sidebar');
    if (sidebar) sidebar.append(followersBlock, followingBlock);
  }

  async function fetchLgFollowList(username, kind) {
    const key = `${String(username || '').toLowerCase()}:${kind}`;
    if (lgFollowCache.has(key)) return lgFollowCache.get(key);
    try {
      const list = await request(
        'GET',
        `/users/${encodeURIComponent(username)}/${kind}?limit=${LG_FOLLOW_PREVIEW}`,
        null,
        false,
        DEFAULTS.apiBase,
      );
      const items = Array.isArray(list) ? list : [];
      lgFollowCache.set(key, items);
      return items;
    } catch {
      return undefined;
    }
  }

  function buildLgFollowUserRow(user) {
    const li = el('li');
    const profileUrl = buildLolzGramProfileUrl(user.username);
    const label = nicknameLabel(user);
    const avatarLink = el('a', {
      className: 'avatar',
      href: profileUrl,
      target: '_blank',
      rel: 'noopener noreferrer',
      title: label,
    });
    const imgSpan = el('span', { className: 'img s' });
    if (user.avatar_url) imgSpan.style.backgroundImage = `url('${String(user.avatar_url)}')`;
    imgSpan.textContent = label.slice(0, 1).toUpperCase();
    avatarLink.appendChild(imgSpan);

    const memberInfo = el('div', { className: 'memberInfo' });
    const nameWrap = el('div');
    const nameLink = el('a', {
      className: 'notranslate username',
      href: profileUrl,
      target: '_blank',
      rel: 'noopener noreferrer',
      translate: 'no',
      dir: 'auto',
    }, label);
    applyUsernameStyle(nameLink, user);
    nameWrap.appendChild(nameLink);
    const userTitle = el('div', { className: 'userTitle' }, String(user.rank || 'Участник'));
    memberInfo.append(nameWrap, userTitle);
    li.append(avatarLink, memberInfo);
    return li;
  }

  function buildLgFollowBlock(id, title, count, profileUrl, users) {
    const block = el('div', { className: 'secondaryContent lg-social-block', id });
    const h3 = el('h3');
    const headLink = el('a', {
      href: profileUrl,
      target: '_blank',
      rel: 'noopener noreferrer',
    });
    headLink.append(
      el('span', { className: 'mainc' }, String(count)),
      document.createTextNode(` ${title}`),
    );
    h3.appendChild(headLink);
    const avatarList = el('div', { className: 'avatarList' });
    const ul = el('ul');
    const items = Array.isArray(users) ? users : [];
    if (!items.length) {
      const emptyLi = el('li');
      const emptyInfo = el('div', { className: 'memberInfo' });
      emptyInfo.appendChild(el('div', { className: 'userTitle' }, 'Пока никого'));
      emptyLi.appendChild(emptyInfo);
      ul.appendChild(emptyLi);
    } else {
      for (const user of items.slice(0, LG_FOLLOW_PREVIEW)) {
        ul.appendChild(buildLgFollowUserRow(user));
      }
    }
    avatarList.appendChild(ul);
    block.append(h3, avatarList);
    return block;
  }

  function renderLolzGramSocialBlocks(lgUser, followers, following) {
    const profileUrl = buildLolzGramProfileUrl(lgUser.username);
    const followersCount = Number(lgUser.followers_count) || (followers ? followers.length : 0);
    const followingCount = Number(lgUser.following_count) || (following ? following.length : 0);

    let followersBlock = document.querySelector('#lg-followers-block');
    let followingBlock = document.querySelector('#lg-following-block');
    if (!followersBlock) {
      followersBlock = buildLgFollowBlock(
        'lg-followers-block',
        'подписчиков LolzGram',
        followersCount,
        profileUrl,
        followers,
      );
    } else {
      followersBlock.querySelector('.mainc').textContent = String(followersCount);
      const ul = followersBlock.querySelector('.avatarList ul');
      if (ul) {
        ul.innerHTML = '';
        const items = Array.isArray(followers) ? followers : [];
        if (!items.length) {
          const emptyLi = el('li');
          const emptyInfo = el('div', { className: 'memberInfo' });
          emptyInfo.appendChild(el('div', { className: 'userTitle' }, 'Пока никого'));
          emptyLi.appendChild(emptyInfo);
          ul.appendChild(emptyLi);
        } else {
          for (const user of items.slice(0, LG_FOLLOW_PREVIEW)) ul.appendChild(buildLgFollowUserRow(user));
        }
      }
    }

    if (!followingBlock) {
      followingBlock = buildLgFollowBlock(
        'lg-following-block',
        'подписки LolzGram',
        followingCount,
        profileUrl,
        following,
      );
    } else {
      followingBlock.querySelector('.mainc').textContent = String(followingCount);
      const ul = followingBlock.querySelector('.avatarList ul');
      if (ul) {
        ul.innerHTML = '';
        const items = Array.isArray(following) ? following : [];
        if (!items.length) {
          const emptyLi = el('li');
          const emptyInfo = el('div', { className: 'memberInfo' });
          emptyInfo.appendChild(el('div', { className: 'userTitle' }, 'Пока никого'));
          emptyLi.appendChild(emptyInfo);
          ul.appendChild(emptyLi);
        } else {
          for (const user of items.slice(0, LG_FOLLOW_PREVIEW)) ul.appendChild(buildLgFollowUserRow(user));
        }
      }
    }
    placeLgSocialBlocks(followersBlock, followingBlock);
    return true;
  }

  async function injectLolzGramSocialBlocks(lgUser) {
    if (!lgUser || !lgUser.username) return false;
    const [followers, following] = await Promise.all([
      fetchLgFollowList(lgUser.username, 'followers'),
      fetchLgFollowList(lgUser.username, 'following'),
    ]);
    if (followers === undefined || following === undefined) return false;
    return renderLolzGramSocialBlocks(lgUser, followers, following);
  }

  async function fetchLgUserPostsPage(username, skip = 0, limit = LG_POSTS_PAGE_SIZE) {
    try {
      const list = await request(
        'GET',
        `/posts/user/${encodeURIComponent(username)}?skip=${Math.max(skip, 0)}&limit=${limit}`,
        null,
        false,
        DEFAULTS.apiBase,
      );
      return Array.isArray(list) ? list : [];
    } catch {
      return undefined;
    }
  }

  function resetLgPostsFeed() {
    if (!lgProfileTabState?.postsFeed) return;
    lgProfileTabState.postsFeed.observer?.disconnect();
    lgProfileTabState.postsFeed = null;
  }

  function findMemberProfileTabs() {
    return document.querySelector(
      'ul.member_tabs.tabs[data-panes="#ProfilePanes > li"], ul.tabs.member_tabs[data-panes="#ProfilePanes > li"], ul.member_tabs.mainTabs',
    );
  }

  function profileTabHashPath() {
    const path = String(window.location.pathname || '/').replace(/\/+$/, '') || '/';
    return `${path}#lgPosts`;
  }

  function removeLgProfilePostsTab() {
    resetLgPostsFeed();
    document.querySelector('#lgPostsTab')?.remove();
    document.querySelector('#lgPosts')?.remove();
    lgProfileTabState = null;
  }

  function buildLgProfilePostCard(post) {
    const card = el('a', {
      className: 'lg-profile-post',
      href: buildLolzGramPostUrl(post.id),
      target: '_blank',
      rel: 'noopener noreferrer',
    });
    const mediaWrap = el('div', { className: 'lg-profile-post__media' });
    const images = Array.isArray(post.images) && post.images.length
      ? post.images
      : (post.image_url ? [post.image_url] : []);
    const imgUrl = images[0] ? resolveMediaUrl(images[0]) : '';
    if (imgUrl) {
      mediaWrap.appendChild(el('img', { src: imgUrl, alt: '', loading: 'lazy' }));
    } else {
      mediaWrap.appendChild(el('div', { className: 'lg-profile-post__placeholder' }, 'Без медиа'));
    }
    if (post.media_type === 'video') {
      mediaWrap.appendChild(el('span', { className: 'lg-profile-post__video', 'aria-hidden': 'true' }, '▶'));
    }
    if (images.length > 1) {
      mediaWrap.appendChild(el('span', { className: 'lg-profile-post__stack' }, String(images.length)));
    }
    const body = el('div', { className: 'lg-profile-post__body' });
    if (post.caption) {
      body.appendChild(el('div', { className: 'lg-profile-post__caption' }, truncateText(post.caption, 140)));
    }
    const metaParts = [];
    if (Number(post.likes_count) > 0) metaParts.push(`♥ ${post.likes_count}`);
    if (Number(post.comments_count) > 0) metaParts.push(`💬 ${post.comments_count}`);
    if (post.created_at) metaParts.push(formatTime(post.created_at));
    if (metaParts.length) {
      body.appendChild(el('div', { className: 'lg-profile-post__meta muted' }, metaParts.join(' · ')));
    }
    card.append(mediaWrap, body);
    return card;
  }

  function appendLgProfilePosts(posts, grid) {
    for (const post of posts) grid.appendChild(buildLgProfilePostCard(post));
  }

  function initLgProfilePostsFeed(pane, lgUser) {
    if (!pane || !lgUser) return;
    resetLgPostsFeed();
    pane.innerHTML = '';
    const root = el('div', { className: 'lg-profile-posts-pane' });
    const head = el('div', { className: 'lg-profile-posts-head' });
    head.appendChild(el(
      'a',
      {
        href: buildLolzGramProfileUrl(lgUser.username),
        target: '_blank',
        rel: 'noopener noreferrer',
        className: 'lg-profile-posts-profile-link',
      },
      'Открыть профиль на LolzGram',
    ));
    const grid = el('div', { className: 'lg-profile-posts' });
    const status = el('div', { className: 'lg-profile-posts-status muted' });
    const sentinel = el('div', { className: 'lg-profile-posts-sentinel', 'aria-hidden': 'true' });
    root.append(head, grid, status, sentinel);
    pane.appendChild(root);

    const feed = {
      lgUser,
      grid,
      status,
      sentinel,
      skip: 0,
      loading: false,
      exhausted: false,
      totalCount: Number(lgUser.posts_count) || 0,
      observer: null,
    };

    const finishLoading = () => {
      feed.loading = false;
      status.textContent = '';
    };

    const loadMore = async () => {
      if (feed.loading || feed.exhausted) return;
      feed.loading = true;
      status.textContent = 'Загрузка...';
      const page = await fetchLgUserPostsPage(lgUser.username, feed.skip, LG_POSTS_PAGE_SIZE);
      if (page === undefined) {
        finishLoading();
        status.textContent = 'Не удалось загрузить посты';
        return;
      }
      if (!page.length) {
        feed.exhausted = true;
        finishLoading();
        if (feed.skip === 0) {
          grid.appendChild(el('div', { className: 'lg-profile-posts-empty muted' }, 'Пока нет постов на LolzGram.'));
        }
        feed.observer?.disconnect();
        return;
      }
      appendLgProfilePosts(page, grid);
      feed.skip += page.length;
      const reachedTotal = feed.totalCount > 0 && feed.skip >= feed.totalCount;
      if (page.length < LG_POSTS_PAGE_SIZE || reachedTotal) {
        feed.exhausted = true;
        feed.observer?.disconnect();
      }
      finishLoading();
    };

    feed.observer = new IntersectionObserver((entries) => {
      if (entries.some((entry) => entry.isIntersecting)) void loadMore();
    }, { root: null, rootMargin: '280px 0px', threshold: 0 });
    feed.observer.observe(sentinel);

    if (!lgProfileTabState) lgProfileTabState = {};
    lgProfileTabState.postsFeed = feed;
    void loadMore();
  }

  function activateLgProfilePostsTab() {
    if (!lgProfileTabState?.tabLi || !lgProfileTabState?.pane) return;
    const { tab, tabLi, pane, tabs, panes } = lgProfileTabState;
    Array.from(tabs.querySelectorAll('li')).forEach((li) => li.classList.remove('active'));
    tabLi.classList.add('active');
    Array.from(tabs.querySelectorAll('a')).forEach((a) => a.classList.remove('active'));
    if (tab) tab.classList.add('active');
    Array.from(panes.children).forEach((child) => {
      if (child === pane) {
        child.style.display = '';
        child.classList.add('active');
      } else {
        child.style.display = 'none';
        child.classList.remove('active');
      }
    });
  }

  function ensureLgProfilePostsTab(lgUser) {
    const tabs = findMemberProfileTabs();
    const panes = document.querySelector('#ProfilePanes');
    if (!tabs || !panes || !lgUser?.username) return false;

    const count = Number(lgUser.posts_count) || 0;
    let tabLi = document.querySelector('#lgPostsTab');
    let pane = document.querySelector('#lgPosts');
    const hashHref = profileTabHashPath();

    if (!tabLi) {
      tabLi = el('li', { id: 'lgPostsTab' });
      const tabLink = el('a', { href: hashHref });
      tabLink.append(
        document.createTextNode('LolzGram '),
        el('span', { className: 'muted', id: 'lgPostsTabCount' }, String(count)),
      );
      tabLi.appendChild(tabLink);

      const postingsTab = Array.from(tabs.querySelectorAll('li a')).find((a) => (
        String(a.getAttribute('href') || '').includes('#postings')
      ));
      if (postingsTab?.parentElement) {
        postingsTab.parentElement.insertAdjacentElement('afterend', tabLi);
      } else {
        tabs.appendChild(tabLi);
      }

      pane = el('li', { id: 'lgPosts', className: 'profileContent' });
      pane.style.display = 'none';
      panes.appendChild(pane);

      const onTabClick = (e) => {
        e.preventDefault();
        activateLgProfilePostsTab();
        if (window.history.replaceState) {
          window.history.replaceState(null, '', hashHref);
        } else {
          window.location.hash = 'lgPosts';
        }
      };
      tabLink.addEventListener('click', onTabClick);

      Array.from(tabs.querySelectorAll('li')).forEach((li) => {
        if (li === tabLi) return;
        const link = li.querySelector('a');
        if (!link) return;
        link.addEventListener('click', () => {
          tabLi.classList.remove('active');
          tabLink.classList.remove('active');
          pane.style.display = 'none';
          pane.classList.remove('active');
        }, true);
      });

      lgProfileTabState = {
        tab: tabLink,
        tabLi,
        pane,
        tabs,
        panes,
        username: lgUser.username,
      };
    } else {
      const countEl = document.querySelector('#lgPostsTabCount');
      if (countEl) countEl.textContent = String(count);
      if (!pane) pane = document.querySelector('#lgPosts');
      const prevFeed = lgProfileTabState?.postsFeed;
      const prevUser = String(lgProfileTabState?.username || '').toLowerCase();
      lgProfileTabState = {
        tab: tabLi.querySelector('a'),
        tabLi,
        pane,
        tabs,
        panes,
        username: lgUser.username,
      };
      const usernameKey = String(lgUser.username || '').toLowerCase();
      if (prevFeed && prevUser === usernameKey) lgProfileTabState.postsFeed = prevFeed;
    }

    const usernameKey = String(lgUser.username || '').toLowerCase();
    const needsFeedInit = !lgProfileTabState?.postsFeed
      || String(lgProfileTabState.username || '').toLowerCase() !== usernameKey;
    if (needsFeedInit) initLgProfilePostsFeed(pane, lgUser);
    if (window.location.hash === '#lgPosts') activateLgProfilePostsTab();
    return true;
  }

  async function injectLolzGramProfilePostsTab(lgUser) {
    if (!lgUser?.username) return false;
    return ensureLgProfilePostsTab(lgUser);
  }

  function appendProfileLinkRow(profileRoot, profileUrl) {
    if (document.querySelector('#lg-profile-link-row')) return true;
    let pairs = profileRoot.querySelector('.pairsJustified');
    if (!pairs) {
      pairs = el('div', { className: 'pairsJustified' });
      profileRoot.appendChild(pairs);
    }
      const row = el('div', { className: 'clear_fix profile_info_row', id: 'lg-profile-link-row' });
      const label = el('div', { className: 'label fl_l' }, 'LolzGram:');
      const labeled = el('div', { className: 'labeled' });
      const link = el(
        'a',
        {
          href: profileUrl,
          target: '_blank',
          rel: 'nofollow noopener noreferrer',
          className: 'externalLink',
        },
        'Открыть профиль',
      );
      const copyIcon = el('i', { className: 'far fa-clone', 'aria-hidden': 'true' });
      const copyButton = el('span', {
        className: 'copyButton test Tooltip',
        title: '',
        'data-cachedtitle': 'Скопировать ссылку LolzGram',
        'data-phr': 'Ссылка LolzGram была скопирована',
        'data-value': profileUrl,
      });
      copyButton.appendChild(copyIcon);
      copyButton.addEventListener('click', async (e) => {
        e.preventDefault();
        e.stopPropagation();
        const value = String(copyButton.getAttribute('data-value') || '');
        if (!value) return;
        try {
          await navigator.clipboard.writeText(value);
          showToast(copyButton.getAttribute('data-phr') || 'Скопировано');
        } catch {
          const ta = el('textarea', { style: { position: 'fixed', left: '-9999px', top: '0' } }, value);
          document.body.appendChild(ta);
          ta.focus();
          ta.select();
          document.execCommand('copy');
          ta.remove();
          showToast(copyButton.getAttribute('data-phr') || 'Скопировано');
        }
      });
      labeled.append(link, document.createTextNode(' '), copyButton);
      row.append(label, labeled);
      pairs.appendChild(row);
      return true;
  }

  async function injectLolzGramProfileLink() {
    if (profileLinkInjectLock) return;
    const profileRoot = findProfileInfoRoot();
    if (!profileRoot) return;
    const candidates = lgUsernameCandidates(detectForumProfileUsername());
    if (!candidates.length) return;
    const username = candidates[0];
    const hasRow = !!document.querySelector('#lg-profile-link-row');
    const hasBadge = !!document.querySelector('#lg-profile-header-badge');
    const hasSocial = !!document.querySelector('#lg-followers-block, #lg-following-block');
    const hasPostsTab = !!document.querySelector('#lgPostsTab');
    if (profileInjectDoneFor === username && hasRow && hasBadge && hasSocial && hasPostsTab) return;
    profileLinkInjectLock = true;
    try {
      const lgUser = await fetchLgUser(username);
      if (lgUser === undefined) return;
      if (!lgUser) {
        profileInjectDoneFor = username;
        return;
      }
      injectForumProfileHeaderBadge(lgUser);
      const profileUrl = buildLolzGramProfileUrl(lgUser.username || username);
      appendProfileLinkRow(profileRoot, profileUrl);
      await injectLolzGramSocialBlocks(lgUser);
      await injectLolzGramProfilePostsTab(lgUser);
      profileInjectDoneFor = username;
    } catch {
    } finally {
      profileLinkInjectLock = false;
    }
  }

  function resetProfileRootObserver() {
    if (profileRootObserver) {
      profileRootObserver.disconnect();
      profileRootObserver = null;
    }
  }

  function ensureProfileRootObserver() {
    const root = findProfileInfoRoot();
    if (!root) {
      resetProfileRootObserver();
      return;
    }
    if (profileRootObserver) return;
    profileRootObserver = new MutationObserver(() => {
      void injectLolzGramProfileLink();
    });
    profileRootObserver.observe(root, { childList: true, subtree: true });
  }

  function installNavigationHooks() {
    const tick = () => {
      profileInjectDoneFor = '';
      document.querySelector('#lg-followers-block')?.remove();
      document.querySelector('#lg-following-block')?.remove();
      removeLgProfilePostsTab();
      resetProfileRootObserver();
      syncInjectedUi();
    };
    window.addEventListener('popstate', tick);
    const wrapHistory = (fn) => function (...args) {
      const ret = fn.apply(this, args);
      queueMicrotask(tick);
      return ret;
    };
    history.pushState = wrapHistory(history.pushState);
    history.replaceState = wrapHistory(history.replaceState);
    document.addEventListener('xf:page-load', tick);
    document.addEventListener('xf:page-load-complete', tick);
  }

  function keepProfileInjectAlive() {
    window.setInterval(() => {
      const root = findProfileInfoRoot();
      if (!root) return;
      ensureProfileRootObserver();
      const needsRow = !document.querySelector('#lg-profile-link-row');
      const needsBadge = !document.querySelector('#lg-profile-header-badge');
      const needsSocial = !document.querySelector('#lg-followers-block');
      const needsPostsTab = !document.querySelector('#lgPostsTab');
      if (needsRow || needsBadge || needsSocial || needsPostsTab) void injectLolzGramProfileLink();
    }, 1500);
  }

  function needsAlertsTabMount() {
    if (nativeAlertsUi?.tab?.isConnected && nativeAlertsUi?.pane?.isConnected) return false;
    return !!(document.querySelector('.alertsTabsWrapper .alertsTabs') && document.querySelector('#AlertPanels'));
  }

  function syncInjectedUi() {
    const pathKey = String(window.location.pathname || '');
    if (profilePathKey !== pathKey) {
      profilePathKey = pathKey;
      profileInjectDoneFor = '';
      document.querySelector('#lg-followers-block')?.remove();
      document.querySelector('#lg-following-block')?.remove();
      removeLgProfilePostsTab();
      resetProfileRootObserver();
    }
    if (needsAlertsTabMount()) ensureNativeAlertsTab();
    if (findProfileInfoRoot()) {
      ensureProfileRootObserver();
      void injectLolzGramProfileLink();
    }
  }

  const debouncedSyncInjectedUi = debounce(syncInjectedUi, OBSERVER_DEBOUNCE_MS);

  function watchForProfileUi() {
    let tries = 0;
    const timer = window.setInterval(() => {
      tries += 1;
      if (findProfileInfoRoot()) {
        ensureProfileRootObserver();
        void injectLolzGramProfileLink();
      }
      if (tries >= 80) window.clearInterval(timer);
    }, 200);
  }

  function watchForAlertsUi() {
    if (!needsAlertsTabMount()) return;
    let tries = 0;
    const timer = window.setInterval(() => {
      tries += 1;
      if (!needsAlertsTabMount() || ensureNativeAlertsTab() || tries >= 24) {
        window.clearInterval(timer);
      }
    }, 200);
  }

  function setupLazyObservers() {
    installNavigationHooks();
    const obs = new MutationObserver(debouncedSyncInjectedUi);
    obs.observe(document.documentElement, { childList: true, subtree: true });
    watchForAlertsUi();
    watchForProfileUi();
    keepProfileInjectAlive();
    syncInjectedUi();
    void ensureApiReady().then(() => syncInjectedUi());
  }

  async function pollOnce(forceSyncUnread) {
    if (lock || !state.token) return;
    lock = true;
    try {
      const unreadResp = await request('GET', '/notifications/unread-count');
      const unreadCount = Number(unreadResp && unreadResp.count ? unreadResp.count : 0);
      const shouldLoadList = forceSyncUnread || unreadCount > state.lastUnread || unreadCount > 0;

      if (shouldLoadList) {
        const list = await request('GET', '/notifications?skip=0&limit=20');
        const items = Array.isArray(list) ? list : [];
        state.latestNotifications = items.slice(0, 20);
        renderNotificationsList();
        if (nativeAlertsUi?.pane?.style.display === 'block') renderNativeAlertsTab();
        const unreadItems = items.filter((n) => n && n.is_read === false);
        let newShown = 0;
        for (const n of unreadItems) {
          const id = Number(n.id || 0);
          if (!id || state.seenIds.has(id)) continue;
          state.seenIds.add(id);
          const payload = buildNotificationText(n);
          emitNotification(payload.title, payload.text, knownNotificationUrl(n));
          newShown += 1;
        }
        if (newShown > 0) {
          setHint(`Новых уведомлений: ${newShown}`);
        }
      }

      state.lastUnread = unreadCount;
      persistState();
      updateBadge(unreadCount);
      setStatus(
        state.profile
          ? `Подключено: ${state.profile.username} (unread: ${state.lastUnread})`
          : `Подключено (unread: ${state.lastUnread})`
      );
    } catch (err) {
      const msg = err instanceof Error ? err.message : 'Неизвестная ошибка';
      if (/401|403|Требуется вход|invalid|token/i.test(msg)) {
        clearAuth();
      } else {
        setHint(`Ошибка polling: ${msg}`);
      }
    } finally {
      lock = false;
    }
  }

  function restartPolling(forceSyncUnread = false) {
    if (pollTimer) {
      window.clearInterval(pollTimer);
      pollTimer = null;
    }
    if (!state.token) return;
    void pollOnce(forceSyncUnread);
    pollTimer = window.setInterval(() => {
      void pollOnce(false);
    }, state.pollMs);
  }

  function formatTime(iso) {
    try {
      return new Date(iso).toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit' });
    } catch {
      return '';
    }
  }

  function el(tag, props, text) {
    const node = document.createElement(tag);
    if (props) {
      Object.entries(props).forEach(([k, v]) => {
        if (k === 'style' && v && typeof v === 'object') Object.assign(node.style, v);
        else if (k in node) node[k] = v;
        else node.setAttribute(k, String(v));
      });
    }
    if (text != null) node.textContent = text;
    return node;
  }

  const ui = buildUi();

  function buildUi() {
    const styleTag = el('style', {}, `
      .lg-btn{
        appearance:none;border:0;cursor:pointer;
        height:36px;padding:0 16px;border-radius:10px;
        display:inline-flex;align-items:center;justify-content:center;gap:8px;
        font-size:14px;font-weight:600;line-height:1;color:#fff;
        background:linear-gradient(to bottom, rgb(34,142,93), rgb(18,76,50));
        transition:filter .14s ease, transform .06s ease, opacity .14s ease;
      }
      .lg-btn:hover{filter:brightness(1.1)}
      .lg-btn:active{filter:brightness(.97);transform:translateY(1px)}
      .lg-btn:focus-visible{outline:none;box-shadow:0 0 0 2px rgb(0,186,120),0 0 0 4px rgba(20,20,20,.95)}
      .lg-btn:disabled{opacity:.55;cursor:default;transform:none}
      .lg-hidden{display:none !important}
      .lg-native-list .avatar{position:relative;overflow:visible}
      .lg-type-badge{
        position:absolute;right:-2px;bottom:-2px;width:20px;height:20px;border-radius:999px;
        display:inline-flex;align-items:center;justify-content:center;color:#fff;
        box-shadow:0 0 0 2px rgb(20,20,20)
      }
      .lg-type-badge--like,.lg-type-badge--story_reaction{background:rgb(244,63,94)}
      .lg-type-badge--comment{background:rgb(14,165,233)}
      .lg-type-badge--follow{background:rgb(0,186,120)}
      .lg-type-badge--repost{background:rgb(16,185,129)}
      .lg-type-badge--tag{background:rgb(139,92,246)}
      .lg-type-badge--admin{background:rgb(245,158,11)}
      .lg-type-badge--default{background:rgb(148,148,148)}
      .lg-nick-gradient{display:inline-block;max-width:100%}
      .lg-profile-badge-wrap{
        display:inline-flex;align-items:center;gap:6px;vertical-align:middle;max-width:100%
      }
      .lg-profile-badge-wrap > :first-child{margin:0;line-height:1}
      .lg-profile-badge{
        display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;
        align-self:center;color:rgb(0,186,120)
      }
      .lg-profile-badge svg{display:block;vertical-align:middle}
      .lg-profile-badge:focus-visible{outline:2px solid rgba(0,186,120,.55);outline-offset:2px;border-radius:999px}
      .lg-profile-badge--tester{color:rgb(167,139,250)}
      .lg-profile-badge--team{color:rgb(251,191,36)}
      .lg-social-block{margin-top:14px}
      #lg-following-block{margin-top:10px}
      #lgPosts.profileContent{padding:14px 16px 18px;box-sizing:border-box}
      .lg-profile-posts-pane{display:flex;flex-direction:column;gap:14px}
      .lg-profile-posts-head{padding:0}
      .lg-profile-posts-profile-link{color:rgb(0,186,120);font-weight:600;text-decoration:none}
      .lg-profile-posts-profile-link:hover{text-decoration:underline}
      .lg-profile-posts{
        display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:14px;padding:0
      }
      .lg-profile-post{
        display:flex;flex-direction:column;border:1px solid rgb(48,48,48);border-radius:12px;
        overflow:hidden;background:rgb(28,28,28);text-decoration:none;color:inherit;transition:border-color .14s ease
      }
      .lg-profile-post:hover{border-color:rgb(0,186,120)}
      .lg-profile-post__media{position:relative;aspect-ratio:1;background:rgb(20,20,20);overflow:hidden}
      .lg-profile-post__media img{width:100%;height:100%;object-fit:cover;display:block}
      .lg-profile-post__placeholder{
        width:100%;height:100%;display:flex;align-items:center;justify-content:center;
        color:rgb(148,148,148);font-size:12px
      }
      .lg-profile-post__video{
        position:absolute;right:8px;bottom:8px;width:28px;height:28px;border-radius:999px;
        display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.65);color:#fff;font-size:12px
      }
      .lg-profile-post__stack{
        position:absolute;left:8px;top:8px;min-width:22px;height:22px;padding:0 6px;border-radius:999px;
        display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.65);color:#fff;font-size:11px
      }
      .lg-profile-post__body{padding:10px 12px 12px}
      .lg-profile-post__caption{font-size:13px;line-height:1.35;word-break:break-word}
      .lg-profile-post__meta{margin-top:8px;font-size:12px}
      .lg-profile-posts-empty{padding:4px 0}
      .lg-profile-posts-status{padding:6px 0 2px;font-size:12px;text-align:center;min-height:18px}
      .lg-profile-posts-sentinel{width:100%;height:1px;pointer-events:none}
      .lg-badge-hint{
        position:absolute;z-index:999999;pointer-events:none;transform:translate(-50%,calc(-100% - 8px));
        background:rgb(54,54,54);color:rgb(234,234,234);border:1px solid rgb(48,48,48);
        border-radius:8px;padding:6px 8px;line-height:1.2;white-space:nowrap;opacity:0;
        box-shadow:0 10px 28px rgba(0,0,0,.45);transition:opacity .12s ease;font-size:12px
      }
      .lg-badge-hint.is-visible{opacity:1}
      .lg-badge-hint__main{font-weight:600}
      .lg-badge-hint__sub{margin-top:2px;font-size:10px;color:rgba(234,234,234,.75)}
      .lg-auth-card{
        margin:12px;border:1px solid rgb(48,48,48);border-radius:22px;
        background:linear-gradient(180deg,rgba(28,28,28,.98),rgba(23,23,23,.98));
        padding:18px 18px 16px
      }
      .lg-auth-brand{display:inline-flex;max-width:100%}
      .lg-auth-brand img{height:42px;width:auto;display:block;max-width:100%;object-fit:contain;object-position:left}
      .lg-auth-text{
        margin:12px 0 16px;color:rgb(148,148,148);font-size:16px;line-height:1.45
      }
      .lg-auth-btn{width:100%;height:44px;border-radius:10px;font-size:13px}
    `);
    document.head.appendChild(styleTag);
    return {
      status: el('div', {}, ''),
      hint: el('div', {}, ''),
      list: el('div', {}, ''),
      empty: el('div', {}, ''),
      badgeTotal: el('span', {}, '0'),
      btnConnect: el('button', {}, ''),
      btnLogout: el('button', {}, ''),
      openPanel: () => {},
      closePanel: () => {},
      navItem: null,
    };
  }

  function makeBtn(text) {
    return el('button', {
      type: 'button',
      className: 'lg-btn',
    }, text);
  }

  function buildAuthCard(btnLogin) {
    const card = el('div', { className: 'lg-auth-card' });
    const brand = el('span', { className: 'lg-auth-brand' });
    const logo = el('img', { src: `${safeApiOrigin()}/Logo.svg`, alt: 'LolzGram', loading: 'lazy' });
    brand.appendChild(logo);
    const text = el('p', { className: 'lg-auth-text' }, 'Соцсеть для Lolzteam — посты, истории, Reels и Direct.');
    btnLogin.classList.add('lg-auth-btn');
    btnLogin.textContent = 'Войти через LZT';
    card.append(brand, text, btnLogin);
    return card;
  }

  function setStatus(text) {
    ui.status.textContent = text;
    const connected = !!state.token;
    ui.btnConnect.classList.toggle('lg-hidden', connected);
    ui.btnLogout.classList.toggle('lg-hidden', !connected);
  }

  function setHint(text) {
    ui.hint.textContent = text || '';
  }

  function updateBadge(count) {
    const n = Number(count) || 0;
    ui.badgeTotal.textContent = String(n);
    const wrap = ui.badgeTotal.parentElement;
    wrap.classList.toggle('Zero', n <= 0);
  }

  function renderNotificationsList() {
    ui.list.innerHTML = '';
    if (!state.token) {
      ui.empty.textContent = 'Для начала авторизуйтесь через OAuth';
      ui.empty.classList.remove('lg-hidden');
      return;
    }
    const items = Array.isArray(state.latestNotifications) ? state.latestNotifications : [];
    if (!items.length) {
      ui.empty.textContent = 'Пока нет уведомлений';
      ui.empty.classList.remove('lg-hidden');
      return;
    }
    ui.empty.classList.add('lg-hidden');
    for (const n of items.slice(0, 12)) {
      const a = el('a', { className: 'lg-alert-item', href: knownNotificationUrl(n), target: '_blank', rel: 'noopener noreferrer' });
      const avatar = el('div', { className: 'lg-alert-avatar' }, (n?.actor?.username || 'U').slice(0, 1).toUpperCase());
      const body = el('div', { className: 'lg-alert-body' });
      const actor = n?.actor?.username || 'Пользователь';
      const preview = n?.preview ? String(n.preview) : 'Новое событие';
      const title = el('div', { className: 'lg-alert-text' }, `${actor}: ${preview}`);
      const time = el('div', { className: 'lg-alert-time' }, formatTime(n?.created_at));
      body.append(title, time);
      a.append(avatar, body);
      ui.list.appendChild(a);
    }
  }

  function ensureNativeAlertsTab() {
    const tabs = document.querySelector('.alertsTabsWrapper .alertsTabs');
    const panels = document.querySelector('#AlertPanels');
    if (!tabs || !panels) return false;
    if (nativeAlertsUi?.tab?.isConnected && nativeAlertsUi?.pane?.isConnected) return true;

    const alertsMenu = tabs.closest('#AlertsMenu');
    if (alertsMenu) {
      alertsMenu.style.width = '520px';
      alertsMenu.style.minWidth = '520px';
    }

    const tab = el('li', { id: 'LgAlertsTab', dataId: 'lolzgram' }, 'LolzGram');
    tabs.appendChild(tab);

    const pane = el('div', {
      id: 'LgAlertsPane',
      className: 'listPlaceholder Scrollbar scrollbar-macosx scrollbar-dynamic',
      style: { display: 'none', maxHeight: '350px', overflowY: 'auto' },
    });
    const wrap = el('div', { className: 'alertsPopup' });
    const list = el('ol', { className: 'lg-native-list' });
    const empty = el('div', { className: 'lg-native-empty', style: { padding: '12px', color: 'var(--mutedTextColor, #949494)' } }, 'Загрузка...');
    const controls = el('div', { className: 'lg-native-controls', style: { padding: '12px', borderTop: '1px solid #303030', display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } });
    const btnLogin = makeBtn('Войти через LZT');
    const authCard = buildAuthCard(btnLogin);
    wrap.append(list, empty, authCard, controls);
    pane.appendChild(wrap);
    panels.appendChild(pane);

    const setActive = () => {
      Array.from(tabs.querySelectorAll('li')).forEach((li) => li.classList.remove('active'));
      tab.classList.add('active');
      Array.from(panels.children).forEach((p) => {
        if (p === pane) return;
        p.style.display = 'none';
      });
      pane.style.display = 'block';
    };

    tab.addEventListener('click', (e) => {
      e.preventDefault();
      setActive();
      renderNativeAlertsTab();
    });
    Array.from(tabs.querySelectorAll('li')).forEach((li) => {
      if (li === tab) return;
      li.addEventListener('click', () => {
        Array.from(panels.children).forEach((p) => {
          if (p === pane) return;
          p.style.display = '';
        });
        pane.style.display = 'none';
        tab.classList.remove('active');
      });
    });

    btnLogin.addEventListener('click', async () => {
      try {
        btnLogin.disabled = true;
        await startOAuth();
      } catch (err) {
        showToast(err instanceof Error ? err.message : 'OAuth ошибка');
      } finally {
        btnLogin.disabled = false;
      }
    });
    nativeAlertsUi = { tab, pane, list, empty, btnLogin, authCard, controls };
    renderNativeAlertsTab();
    return true;
  }

  function renderNativeAlertsTab() {
    if (!nativeAlertsUi) return;
    const { list, empty, btnLogin, authCard, controls } = nativeAlertsUi;
    list.innerHTML = '';
    const connected = !!state.token;
    authCard.classList.toggle('lg-hidden', connected);
    controls.classList.toggle('lg-hidden', !connected);
    btnLogin.classList.toggle('lg-hidden', connected);

    if (!connected) {
      list.classList.add('lg-hidden');
      empty.classList.add('lg-hidden');
      return;
    }
    list.classList.remove('lg-hidden');
    const items = Array.isArray(state.latestNotifications) ? state.latestNotifications : [];
    if (!items.length) {
      empty.textContent = 'Пока нет уведомлений LolzGram.';
      empty.classList.remove('lg-hidden');
      return;
    }
    empty.classList.add('lg-hidden');

    for (const n of items.slice(0, 20)) {
      const li = el('li', { className: 'Alert listItem PopupItemLink PopupItemLinkActive' });
      const actorProfileUrl = knownActorProfileUrl(n);
      const avatarLink = el('a', {
        className: 'avatar',
        href: actorProfileUrl,
        target: '_blank',
        rel: 'noopener noreferrer',
      });
      const actorName = resolveActorName(n?.actor);
      const avatarImg = el('img', {
        src: n?.actor?.avatar_url || '',
        alt: actorName || 'user',
        width: 48,
        height: 48,
        loading: 'lazy',
      });
      if (!n?.actor?.avatar_url) {
        avatarLink.textContent = (actorName || 'U').slice(0, 1).toUpperCase();
        avatarLink.style.display = 'flex';
        avatarLink.style.alignItems = 'center';
        avatarLink.style.justifyContent = 'center';
      } else {
        avatarLink.appendChild(avatarImg);
      }
      avatarLink.addEventListener('click', (e) => e.stopPropagation());
      avatarLink.appendChild(createTypeBadge(n?.type));

      const body = el('div', { className: 'listItemText' });
      const h3 = el('h3', {}, '');
      const actor = actorName || 'Пользователь';
      const fullText = notificationText(n);
      const suffix = fullText.startsWith(actor) ? fullText.slice(actor.length) : ` ${fullText}`;
      const hasActorUsername = !!(n && n.actor && n.actor.username);
      const actorNode = hasActorUsername
        ? el('a', {
          className: 'notranslate username',
          href: actorProfileUrl,
          target: '_blank',
          rel: 'noopener noreferrer',
          translate: 'no',
          dir: 'auto',
        }, actor)
        : el('span', { className: 'notranslate username', translate: 'no', dir: 'auto' }, actor);
      if (hasActorUsername) actorNode.addEventListener('click', (e) => e.stopPropagation());
      applyUsernameStyle(actorNode, n?.actor);
      h3.append(actorNode, document.createTextNode(suffix));
      const bottom = el('div', { className: 'bottom' });
      const timeGroup = el('div', { className: 'alertTimeGroup' });
      const time = el('abbr', { className: 'DateTime muted time', title: n?.created_at || '' }, formatTime(n?.created_at));
      timeGroup.appendChild(time);
      bottom.appendChild(timeGroup);
      body.append(h3, bottom);
      li.append(avatarLink, body);
      li.addEventListener('click', () => {
        window.open(knownNotificationUrl(n), '_blank', 'noopener,noreferrer');
      });
      list.appendChild(li);
    }
  }

  function showToast(text) {
    GM_notification({
      title: 'LolzGram',
      text: String(text || ''),
      timeout: 4500,
    });
  }

  const LG_TYPE_BADGE = {
    like: { variant: 'like', icon: 'heart', filled: true },
    story_reaction: { variant: 'story_reaction', icon: 'heart', filled: true },
    comment: { variant: 'comment', icon: 'messageCircle', filled: false },
    follow: { variant: 'follow', icon: 'userPlus', filled: false },
    repost: { variant: 'repost', icon: 'repeat2', filled: false },
    tag: { variant: 'tag', icon: 'atSign', filled: false },
    admin: { variant: 'admin', icon: 'sparkles', filled: false },
  };

  const LG_SVG_ICONS = {
    heart: ['M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z'],
    messageCircle: ['M7.9 20A9 9 0 1 0 4 16.1L2 22Z'],
    userPlus: ['M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2', 'M9 7a4 4 0 0 1 0-8 4 4 0 0 1 0 8', 'M19 8v6', 'M22 11h-6'],
    repeat2: ['m2 9 3-3 3 3', 'M13 18H7a4 4 0 0 1-4-4V9', 'm22 15-3 3-3-3', 'M11 6h6a4 4 0 0 1 4 4v5'],
    atSign: ['M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8', 'M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94'],
    sparkles: ['M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z'],
  };

  function createTypeBadge(type) {
    const t = String(type || '');
    const cfg = LG_TYPE_BADGE[t] || { variant: 'default', icon: 'sparkles', filled: false };
    const wrap = el('span', {
      className: `lg-type-badge lg-type-badge--${cfg.variant}`,
      'aria-hidden': 'true',
    });
    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svg.setAttribute('viewBox', '0 0 24 24');
    svg.setAttribute('width', '11');
    svg.setAttribute('height', '11');
    svg.setAttribute('fill', cfg.filled ? 'currentColor' : 'none');
    svg.setAttribute('stroke', 'currentColor');
    svg.setAttribute('stroke-width', '2.5');
    svg.setAttribute('stroke-linecap', 'round');
    svg.setAttribute('stroke-linejoin', 'round');
    const paths = LG_SVG_ICONS[cfg.icon] || LG_SVG_ICONS.sparkles;
    for (const d of paths) {
      const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
      path.setAttribute('d', d);
      svg.appendChild(path);
    }
    wrap.appendChild(svg);
    return wrap;
  }

  const UNIQUE_NICK_GRADIENT =
    'linear-gradient(180deg, #e8f0f4 0%, #c6dee3 22%, #9eb4be 55%, #6d848e 85%, #4a5c64 100%)';

  function stripLztDisplaySuffix(name) {
    const t = String(name || '').trim();
    if (!t) return t;
    if (/teamz$/i.test(t)) return t.slice(0, -1).trimEnd();
    if (t.length > 4 && t.endsWith('Z')) return t.slice(0, -1).trimEnd();
    return t;
  }

  function nicknameLabel(user) {
    if (!user) return 'Пользователь';
    const d = typeof user.display_name === 'string' ? user.display_name.trim() : '';
    return stripLztDisplaySuffix(d || user.username || 'Пользователь');
  }

  function notificationText(n) {
    const actor = nicknameLabel(n?.actor);
    const preview = n?.preview ? String(n.preview).trim() : '';
    switch (n?.type) {
      case 'like':
        return preview ? `${actor} оценил(а) пост «${preview}»` : `${actor} оценил(а) ваш пост`;
      case 'story_reaction':
        return preview ? `${actor} отреагировал(а) на историю «${preview}»` : `${actor} отреагировал(а) на вашу историю`;
      case 'comment':
        return preview ? `${actor} прокомментировал(а): «${preview}»` : `${actor} оставил(а) комментарий`;
      case 'follow':
        return `${actor} подписался(ась) на вас`;
      case 'repost':
        return preview ? `${actor} сделал(а) репост «${preview}»` : `${actor} сделал(а) репост`;
      case 'tag':
        return preview ? `${actor} отметил(а) вас: «${preview}»` : `${actor} отметил(а) вас`;
      case 'admin':
        return preview ? `${actor} (админ): «${preview}»` : `${actor} — сообщение администрации`;
      default:
        return preview ? `${actor}: ${preview}` : `${actor}: новое уведомление`;
    }
  }

  function isGradientBackground(bg) {
    return /gradient/i.test(bg);
  }

  function isClipText(style) {
    const clip = String(style.WebkitBackgroundClip || style.backgroundClip || '').toLowerCase();
    return clip === 'text';
  }

  function repairStoredStyle(style) {
    const bg = (style.background || '').trim();
    const shadow = String(style.textShadow || '').toLowerCase();
    const color = (style.color || '').trim().toLowerCase();
    if (bg.includes('#e8f0f4') && shadow.includes('#f00') && color === 'transparent') {
      const next = { ...style };
      delete next.background;
      delete next.WebkitBackgroundClip;
      delete next.backgroundClip;
      next.color = '#ffffff';
      return next;
    }
    return style;
  }

  function repairTransparentStyle(style) {
    const s = repairStoredStyle({ ...style });
    const color = (s.color || '').trim().toLowerCase();
    const bg = (s.background || '').trim();
    const clipText = isClipText(s);
    if (color && color !== 'transparent' && clipText && !bg) {
      const next = { ...s };
      delete next.WebkitBackgroundClip;
      delete next.backgroundClip;
      delete next.background;
      return next;
    }
    if (color === 'transparent' && !bg && s.textShadow && !clipText) {
      return {
        ...s,
        background: UNIQUE_NICK_GRADIENT,
        WebkitBackgroundClip: 'text',
        backgroundClip: 'text',
      };
    }
    return s;
  }

  function usernameStyleToCss(style) {
    if (!style || typeof style !== 'object') return null;
    const s = repairTransparentStyle(style);
    const css = {};
    const bg = (s.background || '').trim();
    const clipText = isClipText(s);
    if (s.textShadow) css.textShadow = String(s.textShadow);
    if (bg && (isGradientBackground(bg) || clipText)) {
      const useGradient = isGradientBackground(bg) || (clipText && bg.startsWith('#'));
      css.background = useGradient && !isGradientBackground(bg) ? UNIQUE_NICK_GRADIENT : bg;
      css.WebkitBackgroundClip = 'text';
      css.backgroundClip = 'text';
      css.color = 'transparent';
      css.WebkitTextFillColor = 'transparent';
      css.display = 'inline-block';
    } else if (clipText && !bg) {
      const fill = (s.color || '').trim();
      if (fill && fill.toLowerCase() !== 'transparent') {
        css.color = fill.toLowerCase() === 'white' ? '#ffffff' : fill;
      } else {
        css.background = UNIQUE_NICK_GRADIENT;
        css.WebkitBackgroundClip = 'text';
        css.backgroundClip = 'text';
        css.color = 'transparent';
        css.WebkitTextFillColor = 'transparent';
        css.display = 'inline-block';
      }
    } else {
      const color = (s.color || '').trim();
      if (color && color.toLowerCase() !== 'transparent') {
        css.color = color;
      } else if (bg) {
        css.color = bg;
      }
    }
    return Object.keys(css).length ? css : null;
  }

  function applyUsernameStyle(node, user) {
    const css = usernameStyleToCss(user?.username_style);
    if (!css) return;
    const useNested = css.WebkitBackgroundClip === 'text';
    if (useNested) {
      const inner = el('span', { className: 'lg-nick-gradient' });
      Object.assign(inner.style, css);
      inner.textContent = node.textContent;
      node.textContent = '';
      node.appendChild(inner);
      return;
    }
    Object.assign(node.style, css);
  }

  function resolveActorName(actor) {
    return nicknameLabel(actor);
  }

  function registerMenu() {
    GM_registerMenuCommand('LolzGram: открыть панель', () => {
      const tab = document.querySelector('#LgAlertsTab');
      if (tab) tab.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });
    GM_registerMenuCommand('LolzGram: принудительный poll', () => {
      void pollOnce(true);
    });
    GM_registerMenuCommand('LolzGram: logout', () => {
      clearAuth();
      restartPolling(false);
    });
    GM_registerMenuCommand('LolzGram: обновить профиль', () => {
      profileInjectDoneFor = '';
      lgUserCache.clear();
      lgFollowCache.clear();
      document.querySelector('#lg-followers-block')?.remove();
      document.querySelector('#lg-following-block')?.remove();
      removeLgProfilePostsTab();
      void injectLolzGramProfileLink();
    });
  }

  function ensureNavMounted() {}
  function installGlobalToggleHandlers() {}

  async function init() {
    registerMenu();
    installGlobalToggleHandlers();
    setHint('Ожидание...');
    try {
      const handled = await handleOAuthCallbackIfPresent();
      if (handled) return;
    } catch (err) {
      const msg = err instanceof Error ? err.message : 'OAuth ошибка';
      setStatus('Не подключено');
      setHint(msg);
      showToast(msg);
    }
    void ensureApiReady().then((ok) => {
      if (ok) setHint(`API: ${state.apiBase}`);
      else setHint('API пока недоступен, попробуйте позже');
    });
    setupLazyObservers();
    if (!state.token) {
      setStatus('Не подключено');
      setHint('Нажмите "Войти через OAuth"');
      renderNotificationsList();
      renderNativeAlertsTab();
      updateBadge(0);
      return;
    }
    const ok = await verifyToken();
    if (!ok) {
      clearAuth();
      showToast('Сессия истекла, войдите снова');
      return;
    }
    setStatus(`Подключено: ${state.profile.username}`);
    setHint('Проверка новых уведомлений...');
    restartPolling(true);
    renderNotificationsList();
    renderNativeAlertsTab();
  }

  void init();
})();