fix reddit chat on mobile

Simple fix to make reddit chats usable in browsers on mobile devices

// ==UserScript==
// @name         fix reddit chat on mobile
// @namespace    https://gist.github.com/nuckle/92100273f64a8d18d0010082fff0b587
// @version      1.0
// @description  Simple fix to make reddit chats usable in browsers on mobile devices
// @author       nuckle
// @match        *://chat.reddit.com/*
// @grant        unsafeWindow
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
	'use strict';

	const shadowRoots = new Set();
	const listeners = new WeakMap();

	const originalAttachShadow = Element.prototype.attachShadow;

	Element.prototype.attachShadow = function () {
		const shadowRoot = originalAttachShadow.apply(this, arguments);

		// Remove duplicates
		let isDeleted = false;

		// Clean up
		shadowRoots.forEach((shadowRootSet) => {
			if (
				shadowRootSet.host.innerHTML === shadowRoot.host.innerHTML &&
				shadowRootSet.host.nodeName === shadowRoot.host.nodeName &&
				shadowRootSet.host.dataset.changed !== 'true'
			) {
				shadowRoots.delete(shadowRootSet);
				isDeleted = true;
			}
		});

		if (!isDeleted) shadowRoots.add(shadowRoot);

		return shadowRoot;
	};

	function debounce(func, delay) {
		let timer;
		return function (...args) {
			clearTimeout(timer);
			timer = setTimeout(() => func(...args), delay);
		};
	}

	function updateEventListener(element, eventType, callback) {
		if (
			!element ||
			typeof callback !== 'function' ||
			typeof eventType !== 'string'
		)
			return;

		const boundCallback = callback.bind(element);

		if (listeners.has(element)) {
			const existingListeners = listeners.get(element);
			if (
				existingListeners.some(
					(listener) => listener.callback.toString() === callback.toString(),
				)
			) {
				// Listener already registered.
				return;
			}
		}

		// Cleanup any duplicate listeners
		element.removeEventListener(eventType, boundCallback);
		element.addEventListener(eventType, boundCallback);

		// Track the listener for later removal
		if (!listeners.has(element)) {
			listeners.set(element, []);
		}
		listeners.get(element).push({ eventType, callback });
	}

	function adjustTextareaHeight(textarea) {
		setTimeout(function () {
			textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
		}, 1);
	}

	function updateTextareaHeight(textarea) {
		textarea.style.height = 'auto';
		textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
	}

	function createCustomDesktopStyleClass(shadow, className, styles) {
		const styleElement = document.createElement('style');
		let styleString = '@media (min-width: 768px) {';
		styleString += `.${className} {`;

		for (const [property, value] of Object.entries(styles)) {
			styleString += `${property}: ${value}!important; `;
		}

		styleString += '}}';
		styleElement.textContent = styleString;
		shadow.appendChild(styleElement);
	}

	function createCustomMobileStyleClass(shadow, className, styles) {
		const styleElement = document.createElement('style');
		let styleString = '@media (max-width: 768px) {';
		styleString += `.${className} {`;

		for (const [property, value] of Object.entries(styles)) {
			styleString += `${property}: ${value}!important; `;
		}

		styleString += '}}';
		styleElement.textContent = styleString;
		shadow.appendChild(styleElement);
	}

	// Styles for div.container (under shadow DOM)

	const hiddenChatStyles = {
		'grid-template-columns': 'auto 0',
	};

	const visibleChatStyles = {
		'grid-template-columns': '0 auto',
	};

	const hiddenStyles = {
		display: 'none',
	};

	const containerVisibleClass = 'container--navbar-visible';
	const containerHiddenClass = 'container--navbar-hidden';
	const toggleBtnClass = 'custom-js-hide-button';
	const hiddenElClass = 'hidden';
	const toggleBtnText = 'Toggle';

	let existingMainContainer = null;

	// A function to track if shadowRoot was changed
	// (we don't want to delete changed shadowRoots)
	function setChanged(shadowRootHost) {
		shadowRootHost.dataset.changed = true;
	}

	function applyStyles() {
		const toggleChatWindow = () => {
			if (existingMainContainer) {
				const isVisible = existingMainContainer?.classList.contains(
					containerVisibleClass,
				);

				const chatOverlay = existingMainContainer?.querySelector(
					'rs-room-overlay-manager',
				);

				const chatThreads = existingMainContainer?.querySelector('rs-threads-view');

				// To avoid a blank screen
				if (chatOverlay || chatThreads) {
					// To prevent 'Read more' messages when chat overlay has 0 width
					chatOverlay?.classList.toggle(hiddenElClass, !isVisible);

					existingMainContainer?.classList.toggle(containerHiddenClass, isVisible);
					existingMainContainer?.classList.toggle(containerVisibleClass, !isVisible);
				}
			}
		};

		const showChatWindow = () => {
			if (existingMainContainer) {
				existingMainContainer?.classList.remove(containerHiddenClass);
				existingMainContainer?.classList.add(containerVisibleClass);

				const chatOverlay = existingMainContainer?.querySelector(
					'rs-room-overlay-manager',
				);
				chatOverlay?.classList.add(hiddenElClass);
			}
		};

		const hideChatWindow = () => {
			if (existingMainContainer) {
				existingMainContainer?.classList.add(containerHiddenClass);
				existingMainContainer?.classList.remove(containerVisibleClass);

				const chatOverlay = existingMainContainer?.querySelector(
					'rs-room-overlay-manager',
				);
				chatOverlay?.classList.remove(hiddenElClass);
			}
		};

		const createToggleButton = (parentElement) => {
			const button = document.createElement('button');
			button.textContent = toggleBtnText;
			button.classList.add(toggleBtnClass);
			updateEventListener(button, 'click', toggleChatWindow);
			parentElement.appendChild(button);
			createCustomDesktopStyleClass(parentElement, toggleBtnClass, hiddenStyles);
			return button;
		};

		shadowRoots.forEach((shadow) => {
			if (!(shadow instanceof ShadowRoot)) {
				return;
			}
			const header = shadow?.querySelector('main header.flex');
			const container = shadow?.querySelector('div.container');
			const createRoomBtn = shadow?.querySelector('rs-room-creation-button');
			const existingBtnContainer = createRoomBtn?.parentNode;
			const composerTextArea = shadow?.querySelector(
				'rs-textarea-auto-size textarea',
			);

			const chatRoomLinks = shadow?.querySelectorAll('rs-rooms-nav-room');

			// Exclude aria-label to not interact with button from Chat settings
			const backBtn = shadow?.querySelector(
				'main > header > button.button-small.button-plain.icon.inline-flex.text-tone-2.back-icon-display:not([aria-label])',
			);

			const settingsBtn = shadow?.querySelector(
				'button.text-tone-2.button-small.button-plain.button.inline-flex[aria-label="Open chat settings"]',
			);
			const createChatBtn = shadow?.querySelector(
				'a.button-plain[href="/room/create"]',
			);
			const cancelBtn = Array.from(
				shadow.querySelectorAll('form .buttons button.button-secondary'),
			).find((btn) => btn.textContent.trim() === 'Cancel');

			const btnElements = shadow?.querySelectorAll(
				'div.border-solid > div.flex > li.relative.list-none.mt-0[role="presentation"]',
			);
			const requestBtn = btnElements[0];
			const threadsBtn = btnElements[1] || btnElements[0];

			const startChatBtn = shadow?.querySelector(
				'form div.buttons button.button-primary[type="submit"]',
			);

			const welcomeScreen = container?.querySelector('rs-welcome-screen');

			// Avoid getting "stuck"
			if (welcomeScreen) {
				setTimeout(() => {
					showChatWindow();
				}, 300);
			}

			// Fix scroll "jumping" when user is entering a message
			if (composerTextArea) {
				setChanged(shadow.host);
				composerTextArea.style.overflowY = 'auto';
				const inputCallback = (e) => {
					e.stopImmediatePropagation();

					debounce(() => {
						adjustTextareaHeight(composerTextArea);
					}, 150)();
				};

				const focusoutCallback = () => {
					updateTextareaHeight(composerTextArea);
				};
				updateEventListener(composerTextArea, 'input', inputCallback);
				updateEventListener(composerTextArea, 'focusout', focusoutCallback);
			}

			// Initialize the main container if not already set
			if (!existingMainContainer && container) {
				setChanged(shadow.host);
				existingMainContainer = container;
				createCustomMobileStyleClass(
					shadow,
					containerVisibleClass,
					hiddenChatStyles,
				);

				// hide overlay to fix false truncated messages
				createCustomMobileStyleClass(
					shadow,
					'container--navbar-visible rs-room-overlay-manager',
					hiddenStyles,
				);

				createCustomMobileStyleClass(
					shadow,
					containerHiddenClass,
					visibleChatStyles,
				);
				createCustomMobileStyleClass(shadow, hiddenElClass, hiddenStyles);
				if (!container?.classList.contains(containerVisibleClass)) {
					container?.classList.add(containerVisibleClass);
				}
			}

			// Create and attach toggle button in header if it doesn't exist
			if (header && !header?.querySelector(`button.${toggleBtnClass}`)) {
				setChanged(shadow.host);
				createToggleButton(header);
			}

			// Create and attach toggle button in navbar if it doesn't exist
			if (
				createRoomBtn &&
				existingBtnContainer &&
				!existingBtnContainer?.querySelector(`button.${toggleBtnClass}`)
			) {
				setChanged(shadow.host);
				createToggleButton(existingBtnContainer);
			}

			if (
				chatRoomLinks.length > 0 ||
				backBtn ||
				settingsBtn ||
				threadsBtn ||
				requestBtn ||
				createChatBtn ||
				startChatBtn ||
				cancelBtn
			) {
				function handleButtonClick(element, eventType, callback) {
					if (element) {
						updateEventListener(element, eventType, callback);
					}
				}

				setChanged(shadow.host);
				const showCallback = () => showChatWindow();
				const hideCallback = () => hideChatWindow();

				handleButtonClick(cancelBtn, 'click', showCallback);
				handleButtonClick(createChatBtn, 'click', hideCallback);
				handleButtonClick(settingsBtn, 'click', hideCallback);
				handleButtonClick(backBtn, 'click', showCallback);
				handleButtonClick(requestBtn, 'click', showCallback);
				handleButtonClick(threadsBtn, 'click', hideCallback);
				handleButtonClick(startChatBtn, 'click', hideCallback);

				chatRoomLinks?.forEach((chatRoomLink) => {
					handleButtonClick(chatRoomLink, 'click', hideCallback);
				});
			}
		});
	}

	window.Element.prototype.attachShadowOri =
		window.Element.prototype.attachShadow;

	window.Element.prototype.attachShadow = function (obj) {
		obj.mode = 'open';

		applyStyles();

		return this.attachShadowOri(obj);
	};
})();