Синхронизация lolz.live и LolzGram: OAuth, уведомления и профильные интеграции
// ==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();
})();