├── .gitignore ├── icons ├── icon16.png ├── icon24.png ├── icon32.png ├── icon48.png ├── icon64.png ├── icon128.png ├── icon256.png └── icon512.png ├── background.js ├── content.js ├── manifest.json ├── README.MD ├── styles.css └── story.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KimiZK-Dev/Story-Reaction/HEAD/icons/icon16.png -------------------------------------------------------------------------------- /icons/icon24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KimiZK-Dev/Story-Reaction/HEAD/icons/icon24.png -------------------------------------------------------------------------------- /icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KimiZK-Dev/Story-Reaction/HEAD/icons/icon32.png -------------------------------------------------------------------------------- /icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KimiZK-Dev/Story-Reaction/HEAD/icons/icon48.png -------------------------------------------------------------------------------- /icons/icon64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KimiZK-Dev/Story-Reaction/HEAD/icons/icon64.png -------------------------------------------------------------------------------- /icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KimiZK-Dev/Story-Reaction/HEAD/icons/icon128.png -------------------------------------------------------------------------------- /icons/icon256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KimiZK-Dev/Story-Reaction/HEAD/icons/icon256.png -------------------------------------------------------------------------------- /icons/icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KimiZK-Dev/Story-Reaction/HEAD/icons/icon512.png -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | chrome.runtime.onInstalled.addListener(() => { 2 | console.log("React Story Facebook Extension Installed"); 3 | }); 4 | 5 | chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 6 | if ( 7 | changeInfo.status === "complete" && 8 | tab.url?.includes("facebook.com/stories") 9 | ) { 10 | chrome.scripting 11 | .executeScript({ 12 | target: { tabId: tabId }, 13 | files: ["story.js"], 14 | }) 15 | .catch((err) => console.error("Script injection failed:", err)); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /content.js: -------------------------------------------------------------------------------- 1 | class ContentInjector { 2 | static injectScript(url, type = "") { 3 | return new Promise((resolve, reject) => { 4 | const script = document.createElement("script"); 5 | if (type) script.type = type; 6 | script.src = url; 7 | script.onload = () => { 8 | script.remove(); 9 | resolve(); 10 | }; 11 | script.onerror = reject; 12 | (document.head || document.documentElement).appendChild(script); 13 | }); 14 | } 15 | } 16 | 17 | if (window.location.href.includes("facebook.com/stories")) { 18 | ContentInjector.injectScript(chrome.runtime.getURL("story.js")).catch( 19 | (err) => console.error("Failed to inject story.js:", err) 20 | ); 21 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "React Story Facebook V5", 4 | "description": "Cải tiến thêm cảm xúc Story Facebook", 5 | "version": "1.0.5", 6 | "icons": { 7 | "16": "icons/icon16.png", 8 | "24": "icons/icon24.png", 9 | "32": "icons/icon32.png", 10 | "64": "icons/icon64.png", 11 | "128": "icons/icon128.png", 12 | "256": "icons/icon256.png", 13 | "512": "icons/icon512.png" 14 | }, 15 | "background": { 16 | "service_worker": "background.js" 17 | }, 18 | "content_scripts": [ 19 | { 20 | "matches": ["https://www.facebook.com/*"], 21 | "css": ["styles.css"], 22 | "js": ["content.js"] 23 | } 24 | ], 25 | "web_accessible_resources": [ 26 | { 27 | "resources": ["story.js"], 28 | "matches": ["https://www.facebook.com/*"] 29 | } 30 | ], 31 | "permissions": [ 32 | "activeTab", 33 | "scripting" 34 | ], 35 | "host_permissions": [ 36 | "https://www.facebook.com/*", 37 | "https://raw.githubusercontent.com/*" 38 | ] 39 | } -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # 🚀 Tiện Ích Thả Cảm Xúc Story Facebook 😄🎉 2 | 3 | Chào mừng bạn đến với **Tiện ích Thả Cảm Xúc Story Facebook**! 4 | Đây là một **tiện ích trình duyệt** cải tiến từ [dự án gốc của whoant](https://github.com/whoant/react-story-facebook), giúp bạn **thả biểu tượng cảm xúc (emoji) tùy chỉnh** lên các story Facebook một cách thông minh, mượt mà và thân thiện. Dự án đã được nâng cấp toàn diện với sự hỗ trợ của AI (Grok & Claude) nhằm tối ưu hiệu suất, giao diện và trải nghiệm người dùng. ✨ 5 | 6 | --- 7 | 8 | ## ✨ Tính Năng Nổi Bật 9 | 10 | * 🖱️ **Nút cảm xúc thông minh**: Hiển thị tự động khi mở story, nằm ở góc dưới bên phải, dễ thao tác. 11 | * 🔍 **Tìm kiếm emoji theo từ khóa hoặc ký tự**: Hiển thị kết quả ngay khi gõ. 12 | * 📚 **Emoji phân loại theo danh mục**: Như *Smileys & Emotion*, *Animals & Nature*, v.v... Giao diện tab dễ sử dụng. 13 | * 🎯 **Giữ bảng emoji mở sau khi chọn**: Cho phép chọn liên tục nhiều emoji mà không cần mở lại. 14 | * ⚡ **Emoji tải nhanh và có cache**: Dữ liệu emoji được lấy từ [emoji.json (UNPKG)](https://unpkg.com/emoji.json/emoji.json) và lưu cache cục bộ. 15 | * 🌙 **Tự động hỗ trợ chế độ tối**: Giao diện thay đổi theo theme trình duyệt. 16 | * 📱 **Tương thích cả desktop và mobile**: Thiết kế responsive phù hợp nhiều thiết bị. 17 | * 🔄 **Tự động phát hiện & hoạt động mượt mà trên story**: Dù giao diện Facebook thay đổi. 18 | 19 | --- 20 | 21 | ## 📋 Yêu Cầu 22 | 23 | * 🌐 **Trình duyệt hỗ trợ**: 24 | 25 | * Google Chrome 26 | * Microsoft Edge 27 | * Hoặc bất kỳ trình duyệt nào tương thích với **Chrome Extensions (Manifest V3)** 28 | 29 | * 🔐 **Quyền cần cấp**: 30 | 31 | * `https://www.facebook.com/*` – để nhận diện và tương tác với story. 32 | * `https://raw.githubusercontent.com/*` – để tải danh sách emoji. 33 | 34 | --- 35 | 36 | ## 🛠️ Hướng Dẫn Cài Đặt 37 | 38 | ### 1. Tải mã nguồn 39 | 40 | ```bash 41 | git clone https://github.com/KimiZK-Dev/Story-Reaction.git 42 | ``` 43 | 44 | ### 2. Cài vào Chrome 45 | 46 | 1. Truy cập `chrome://extensions/` 47 | 2. Bật **Developer mode** (Chế độ nhà phát triển) ở góc phải trên. 48 | 3. Nhấn **Load unpacked** → Chọn thư mục `Story-Reaction` vừa tải về. 49 | 50 | ### 3. Kiểm Tra 51 | 52 | * Mở story trên Facebook (URL dạng: `https://www.facebook.com/stories/...`) 53 | * Nút emoji sẽ hiển thị ở góc dưới bên phải. Nhấn vào để bắt đầu thả cảm xúc! 🎉 54 | 55 | --- 56 | 57 | ## 📖 Cách Sử Dụng 58 | 59 | 1. Truy cập một **Facebook story** bất kỳ. 60 | 2. Tìm **nút emoji nổi** ở góc dưới bên phải story. 61 | 3. **Chọn emoji**: 62 | 63 | * Gõ từ khóa để tìm nhanh. 64 | * Dùng tab để chuyển danh mục. 65 | 4. **Thả cảm xúc** bằng cách nhấn emoji bạn muốn – emoji sẽ được gắn vào story (giả lập tương tác). 66 | 5. Đóng bảng chọn bằng cách nhấn lại nút hoặc nhấn ra ngoài. 67 | 68 | --- 69 | 70 | ## 📁 Cấu Trúc Dự Án 71 | 72 | ``` 73 | 📦 Story-Reaction 74 | ├── background.js # Script nền xử lý sự kiện cài đặt & cấp quyền 75 | ├── content.js # Inject script vào story Facebook 76 | ├── story.js # Logic chính: giao diện, emoji, phản hồi 77 | ├── styles.css # Giao diện và hiệu ứng 78 | ├── icons/ # Icon tiện ích 79 | │ ├── icon16.png 80 | │ ├── icon48.png 81 | │ └── icon128.png 82 | └── manifest.json # Cấu hình tiện ích (Manifest V3) 83 | ``` 84 | 85 | --- 86 | 87 | ## 🧰 Khắc Phục Sự Cố 88 | 89 | ### ❌ Nút emoji không xuất hiện? 90 | 91 | * Đảm bảo URL đúng: `https://www.facebook.com/stories/...` 92 | * Kiểm tra console (DevTools) để xem thông báo lỗi (`F12 → Console`) 93 | * Facebook có thể thay đổi class hoặc DOM → Cần cập nhật lại `story.js` 94 | 95 | ### 🚫 Không tải được emoji? 96 | 97 | * Kiểm tra kết nối internet 98 | * Đảm bảo `manifest.json` có quyền `https://raw.githubusercontent.com/*` 99 | * Mở DevTools → tab **Network** → tìm yêu cầu đến `emoji.json` 100 | 101 | ### 🌀 Bảng emoji tự đóng sau khi chọn? 102 | 103 | * Đã được cải tiến để giữ bảng mở. Nếu vẫn xảy ra, hãy tải lại bản mới nhất. 104 | 105 | ### 🐌 Giao diện chậm hoặc không ổn định? 106 | 107 | * Đảm bảo trình duyệt được cập nhật. 108 | * Kiểm tra console để xem các lỗi JavaScript hoặc CSS. 109 | 110 | --- 111 | 112 | ## ⚠️ Lưu Ý Quan Trọng 113 | 114 | * 🔄 **Tương thích Facebook**: Nếu Facebook thay đổi DOM, bạn có thể cần chỉnh lại `querySelector` trong `story.js` 115 | * 🧠 **AI hỗ trợ cải tiến**: Dự án đã được nâng cấp bằng Grok & Claude (AI) để tăng độ ổn định, thân thiện và tối ưu hiệu năng. 116 | * 📦 **Nguồn emoji**: 117 | Emoji được lấy từ [UNPKG](https://unpkg.com/emoji.json/emoji.json), sau đó lưu vào: 118 | `https://raw.githubusercontent.com/KimiZK-Dev/Tao-lao/refs/heads/main/emoji.json` 119 | 120 | --- 121 | 122 | ## 💡 Góp Ý & Liên Hệ 123 | 124 | * 🐛 Gặp lỗi? → [Tạo issue tại GitHub](https://github.com/KimiZK-Dev/Story-Reaction/issues) 125 | * 🌟 Đóng góp ý tưởng hoặc mã nguồn? → Gửi pull request! 126 | * 💬 Cần hỗ trợ riêng? → Nhắn qua GitHub hoặc email trong hồ sơ cá nhân. 127 | 128 | --- 129 | 130 | ## 👨‍💻 Tác Giả & Ghi Nhận 131 | 132 | * Cải tiến bởi: **[KimiZK-Dev](https://github.com/KimiZK-Dev)** 133 | * Dựa trên dự án gốc: [whoant/react-story-facebook](https://github.com/whoant/react-story-facebook) 134 | * Hỗ trợ bởi AI: **Grok**, **Claude** 135 | 136 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* EMOJI REACTOR - Optimized Styles */ 2 | *, 3 | *::before, 4 | *::after { 5 | box-sizing: border-box; 6 | } 7 | 8 | .react-container { 9 | position: fixed; 10 | right: 20px; 11 | bottom: 20px; 12 | z-index: 1000; 13 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 14 | } 15 | 16 | .btn-react { 17 | width: 50px; 18 | height: 50px; 19 | border-radius: 50%; 20 | background: linear-gradient(145deg, #f0f0f0, #ffffff); 21 | border: none; 22 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 23 | cursor: pointer; 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | font-size: 18px; 28 | font-weight: bold; 29 | transition: all 0.25s ease; 30 | will-change: transform; 31 | } 32 | 33 | .btn-react:hover { 34 | transform: scale(1.05); 35 | box-shadow: 0 6px 10px rgba(0, 0, 0, 0.15); 36 | } 37 | 38 | /* Panel with animation */ 39 | .emoji-panel { 40 | opacity: 0; 41 | visibility: hidden; 42 | transform: translateY(10px); 43 | transition: opacity 0.25s ease, transform 0.25s ease, visibility 0.25s; 44 | position: absolute; 45 | bottom: 65px; 46 | right: 0; 47 | width: 320px; 48 | height: 420px; 49 | background: #fff; 50 | border-radius: 16px; 51 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); 52 | padding: 12px; 53 | border: 1px solid #e0e0e0; 54 | display: flex; 55 | flex-direction: column; 56 | box-sizing: border-box; 57 | will-change: transform, opacity; 58 | } 59 | 60 | .emoji-panel.show { 61 | opacity: 1; 62 | visibility: visible; 63 | transform: translateY(0); 64 | } 65 | 66 | /* Search input - Top section */ 67 | .emoji-search { 68 | width: 100%; 69 | padding: 10px 12px; 70 | border: 1px solid #e0e0e0; 71 | border-radius: 8px; 72 | font-size: 14px; 73 | outline: none; 74 | transition: border-color 0.2s; 75 | margin-bottom: 12px; 76 | background: #fafafa; 77 | flex-shrink: 0; 78 | } 79 | 80 | .emoji-search:focus { 81 | border-color: #0078ff; 82 | background: #ffffff; 83 | } 84 | 85 | .emoji-search::placeholder { 86 | color: #999; 87 | } 88 | 89 | /* Middle section - Emoji list */ 90 | .emoji-list-container { 91 | flex: 1; 92 | overflow-y: auto; 93 | overflow-x: hidden; 94 | margin-bottom: 12px; 95 | overscroll-behavior: contain; 96 | border-radius: 8px; 97 | background: #fafafa; 98 | padding: 8px; 99 | } 100 | 101 | .emoji-list-container::-webkit-scrollbar { 102 | width: 6px; 103 | } 104 | 105 | .emoji-list-container::-webkit-scrollbar-track { 106 | background: transparent; 107 | } 108 | 109 | .emoji-list-container::-webkit-scrollbar-thumb { 110 | background: #ccc; 111 | border-radius: 3px; 112 | } 113 | 114 | .emoji-list-container::-webkit-scrollbar-thumb:hover { 115 | background: #999; 116 | } 117 | 118 | .emoji-group { 119 | display: grid; 120 | grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); 121 | gap: 6px; 122 | margin: 0; 123 | padding: 0; 124 | list-style: none; 125 | } 126 | 127 | .emoji { 128 | width: 44px; 129 | height: 44px; 130 | display: flex; 131 | align-items: center; 132 | justify-content: center; 133 | font-size: 28px; 134 | border-radius: 10px; 135 | cursor: pointer; 136 | transition: all 0.2s ease; 137 | user-select: none; 138 | background: #ffffff; 139 | border: 1px solid transparent; 140 | } 141 | 142 | .emoji:hover { 143 | background: #e8f4fd; 144 | border-color: #0078ff; 145 | transform: scale(1.1); 146 | } 147 | 148 | .emoji:active { 149 | transform: scale(0.95); 150 | } 151 | 152 | /* Loading indicator */ 153 | .loading-indicator { 154 | width: 100%; 155 | height: 44px; 156 | display: flex; 157 | align-items: center; 158 | justify-content: center; 159 | font-size: 20px; 160 | color: #666; 161 | background: transparent; 162 | border: none; 163 | border-radius: 8px; 164 | margin-top: 8px; 165 | animation: pulse 1.5s ease-in-out infinite; 166 | } 167 | 168 | @keyframes pulse { 169 | 0%, 100% { opacity: 0.6; } 170 | 50% { opacity: 1; } 171 | } 172 | 173 | /* Bottom section - Group tabs */ 174 | .emoji-group-tabs { 175 | display: flex; 176 | flex-wrap: nowrap; 177 | overflow-x: auto; 178 | overflow-y: hidden; 179 | border-top: 1px solid #e0e0e0; 180 | padding-top: 8px; 181 | margin-top: 0; 182 | gap: 6px; 183 | flex-shrink: 0; 184 | scrollbar-width: thin; 185 | scrollbar-color: #ccc transparent; 186 | } 187 | 188 | .emoji-group-tabs::-webkit-scrollbar { 189 | height: 4px; 190 | } 191 | 192 | .emoji-group-tabs::-webkit-scrollbar-track { 193 | background: transparent; 194 | } 195 | 196 | .emoji-group-tabs::-webkit-scrollbar-thumb { 197 | background: #ccc; 198 | border-radius: 2px; 199 | } 200 | 201 | .emoji-tab { 202 | flex: 0 0 auto; 203 | min-width: 40px; 204 | height: 40px; 205 | padding: 8px; 206 | background: #f7f7f7; 207 | border-radius: 10px; 208 | cursor: pointer; 209 | font-size: 20px; 210 | white-space: nowrap; 211 | transition: all 0.2s ease; 212 | display: flex; 213 | align-items: center; 214 | justify-content: center; 215 | border: 1px solid transparent; 216 | user-select: none; 217 | } 218 | 219 | .emoji-tab:hover { 220 | background: #e8f4fd; 221 | border-color: #0078ff; 222 | transform: scale(1.05); 223 | } 224 | 225 | .emoji-tab.active { 226 | background: #0078ff; 227 | color: white; 228 | border-color: #0078ff; 229 | transform: scale(1.05); 230 | } 231 | 232 | .emoji-tab:active { 233 | transform: scale(0.95); 234 | } 235 | 236 | /* Responsive adjustments */ 237 | @media (max-width: 480px) { 238 | .emoji-panel { 239 | width: 280px; 240 | height: 380px; 241 | } 242 | 243 | .emoji-group { 244 | grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); 245 | gap: 4px; 246 | } 247 | 248 | .emoji { 249 | width: 40px; 250 | height: 40px; 251 | font-size: 24px; 252 | } 253 | 254 | .emoji-tab { 255 | min-width: 36px; 256 | height: 36px; 257 | font-size: 18px; 258 | } 259 | } 260 | 261 | /* Dark mode support */ 262 | @media (prefers-color-scheme: dark) { 263 | .emoji-panel { 264 | background: #2d2d2d; 265 | border-color: #444; 266 | color: #fff; 267 | } 268 | 269 | .emoji-search { 270 | background: #3d3d3d; 271 | border-color: #555; 272 | color: #fff; 273 | } 274 | 275 | .emoji-search::placeholder { 276 | color: #aaa; 277 | } 278 | 279 | .emoji-search:focus { 280 | border-color: #0078ff; 281 | background: #4d4d4d; 282 | } 283 | 284 | .emoji-list-container { 285 | background: #3d3d3d; 286 | } 287 | 288 | .emoji { 289 | background: #4d4d4d; 290 | border-color: transparent; 291 | } 292 | 293 | .emoji:hover { 294 | background: #1a4a66; 295 | border-color: #0078ff; 296 | } 297 | 298 | .emoji-group-tabs { 299 | border-top-color: #555; 300 | } 301 | 302 | .emoji-tab { 303 | background: #4d4d4d; 304 | color: #fff; 305 | } 306 | 307 | .emoji-tab:hover { 308 | background: #1a4a66; 309 | border-color: #0078ff; 310 | } 311 | } -------------------------------------------------------------------------------- /story.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | // Check if StoryReactor is already initialized 5 | if (window.StoryReactorInstance) { 6 | console.log('StoryReactor is already running, cleaning up...'); 7 | window.StoryReactorInstance.destroy(); 8 | window.StoryReactorInstance = null; 9 | } 10 | 11 | class StoryReactor { 12 | constructor() { 13 | this.emojiList = []; 14 | this.filteredEmojis = []; 15 | this.container = null; 16 | this.searchInput = null; 17 | this.emojiListElement = null; 18 | this.groupCache = new Map(); 19 | this.isInitialized = false; 20 | this.debounceTimeout = null; 21 | this.collator = new Intl.Collator(undefined, { sensitivity: 'base' }); 22 | this.observer = null; 23 | this.pollingInterval = null; 24 | this.cacheKey = 'emoji_cache'; 25 | this.cacheTTL = 24 * 60 * 60 * 1000; // 24 hours 26 | this.isAttached = false; 27 | this.retryCount = 0; 28 | this.maxRetries = 10; // Tăng số lần thử 29 | this.retryDelay = 500; // Giảm độ trễ để thử nhanh hơn 30 | this.currentPage = 0; 31 | this.itemsPerPage = 100; 32 | this.isLoading = false; 33 | this.hasMore = true; 34 | 35 | // Bind methods to preserve context 36 | this.handleNavigation = this.handleNavigation.bind(this); 37 | this.checkAndAttach = this.checkAndAttach.bind(this); 38 | this.handlePopState = this.handlePopState.bind(this); 39 | this.handleHashChange = this.handleHashChange.bind(this); 40 | 41 | // Throttle functions 42 | this.throttledAttach = this.throttle(this.attachToFooter.bind(this), 100); 43 | this.throttledNavigation = this.throttle(this.handleNavigation, 200); 44 | } 45 | 46 | // Utility function for throttling 47 | throttle(func, limit) { 48 | let inThrottle; 49 | return function(...args) { 50 | if (!inThrottle) { 51 | func.apply(this, args); 52 | inThrottle = true; 53 | setTimeout(() => inThrottle = false, limit); 54 | } 55 | }; 56 | } 57 | 58 | // Utility function for debouncing 59 | debounce(func, wait) { 60 | let timeout; 61 | return function(...args) { 62 | clearTimeout(timeout); 63 | timeout = setTimeout(() => func.apply(this, args), wait); 64 | }; 65 | } 66 | 67 | async init() { 68 | if (this.isInitialized) return; 69 | 70 | try { 71 | await this.loadEmojiData(); 72 | this.filteredEmojis = [...this.emojiList]; 73 | this.buildGroupCache(); 74 | this.setupNavigationListener(); 75 | this.startObserving(); 76 | this.startPolling(); // Bắt đầu polling để kiểm tra footer 77 | this.handleNavigation(); 78 | this.isInitialized = true; 79 | console.log('StoryReactor initialized successfully'); 80 | } catch (err) { 81 | console.error('Failed to initialize StoryReactor:', err); 82 | } 83 | } 84 | 85 | async loadEmojiData() { 86 | const cachedData = this.getCachedData(); 87 | if (cachedData) { 88 | this.emojiList = cachedData; 89 | return; 90 | } 91 | 92 | for (let i = 0; i < 3; i++) { 93 | try { 94 | const controller = new AbortController(); 95 | const timeoutId = setTimeout(() => controller.abort(), 8000); 96 | 97 | const response = await fetch('https://raw.githubusercontent.com/KimiZK-Dev/Tao-lao/refs/heads/main/emoji.json', { 98 | signal: controller.signal, 99 | cache: 'force-cache' 100 | }); 101 | 102 | clearTimeout(timeoutId); 103 | 104 | if (!response.ok) throw new Error(`HTTP ${response.status}`); 105 | 106 | this.emojiList = await response.json(); 107 | this.setCachedData(this.emojiList); 108 | return; 109 | } catch (err) { 110 | console.warn(`Fetch attempt ${i + 1} failed:`, err); 111 | if (i === 2) { 112 | this.emojiList = [ 113 | { codes: "1F600", value: "😀", name: "grinning face", group: "Smileys & Emotion" }, 114 | { codes: "1F602", value: "😂", name: "face with tears of joy", group: "Smileys & Emotion" }, 115 | { codes: "2764", value: "❤️", name: "red heart", group: "Smileys & Emotion" }, 116 | { codes: "1F44D", value: "👍", name: "thumbs up", group: "People & Body" }, 117 | { codes: "1F44E", value: "👎", name: "thumbs down", group: "People & Body" } 118 | ]; 119 | } 120 | } 121 | } 122 | } 123 | 124 | getCachedData() { 125 | try { 126 | const cachedData = localStorage.getItem(this.cacheKey); 127 | if (cachedData) { 128 | const { data, timestamp } = JSON.parse(cachedData); 129 | if (Date.now() - timestamp < this.cacheTTL) { 130 | return data; 131 | } 132 | } 133 | } catch (err) { 134 | console.warn('Failed to read cache:', err); 135 | } 136 | return null; 137 | } 138 | 139 | setCachedData(data) { 140 | try { 141 | localStorage.setItem(this.cacheKey, JSON.stringify({ 142 | data: data, 143 | timestamp: Date.now() 144 | })); 145 | } catch (err) { 146 | console.warn('Failed to cache data:', err); 147 | } 148 | } 149 | 150 | isStoryUrl() { 151 | const url = window.location.href; 152 | return url.includes('/stories/') && url.includes('facebook.com'); 153 | } 154 | 155 | buildGroupCache() { 156 | this.groupCache.clear(); 157 | this.emojiList.forEach(e => { 158 | const group = e.group || e.category; 159 | if (!this.groupCache.has(group)) { 160 | this.groupCache.set(group, []); 161 | } 162 | this.groupCache.get(group).push(e); 163 | }); 164 | } 165 | 166 | async setupUI() { 167 | const existingContainer = document.querySelector(".react-container"); 168 | if (existingContainer) { 169 | existingContainer.remove(); 170 | } 171 | 172 | this.container = document.createElement("div"); 173 | this.container.className = "react-container"; 174 | const accountLabel = document.createElement("div"); 175 | accountLabel.className = "account-label"; 176 | this.container.appendChild(accountLabel); 177 | 178 | const button = document.createElement("button"); 179 | button.className = "btn-react"; 180 | button.innerHTML = ``; 181 | 182 | const panel = document.createElement("div"); 183 | panel.className = "emoji-panel"; 184 | 185 | this.searchInput = document.createElement("input"); 186 | this.searchInput.placeholder = "Tìm kiếm biểu tượng cảm xúc..."; 187 | this.searchInput.className = "emoji-search"; 188 | this.searchInput.type = "text"; 189 | this.searchInput.autocomplete = "off"; 190 | 191 | const listContainer = document.createElement("div"); 192 | listContainer.className = "emoji-list-container"; 193 | this.emojiListElement = document.createElement("ul"); 194 | this.emojiListElement.className = "emoji-group"; 195 | 196 | listContainer.addEventListener('scroll', this.throttle(() => { 197 | this.handleScroll(listContainer); 198 | }, 100)); 199 | 200 | listContainer.appendChild(this.emojiListElement); 201 | panel.appendChild(this.searchInput); 202 | panel.appendChild(listContainer); 203 | this.container.appendChild(button); 204 | this.container.appendChild(panel); 205 | 206 | this.addEventListeners(button, panel); 207 | 208 | requestAnimationFrame(() => { 209 | this.currentPage = 0; 210 | this.hasMore = true; 211 | this.renderEmojis(this.filteredEmojis, true); 212 | this.renderGroupTabs(); 213 | }); 214 | } 215 | 216 | startObserving() { 217 | this.stopObserving(); 218 | 219 | const footerSelectors = [ 220 | "div[data-id] > div[role='contentinfo']", 221 | ".x11lhmoz.x78zum5.x1q0g3np", 222 | "[role='contentinfo']", 223 | ".x1cy8zhl.x9f619.x78zum5.x1q0g3np", 224 | ".x1yztbdb.x1sxj7lo", // Thêm selector mới cho Stories 225 | ".x1qjc9v5.x78zum5" // Selector bổ sung cho giao diện Stories 226 | ]; 227 | 228 | const observerCallback = (entries) => { 229 | entries.forEach(entry => { 230 | if (entry.isIntersecting && this.isStoryUrl()) { 231 | console.debug('Footer detected via IntersectionObserver:', entry.target); 232 | this.checkAndAttach(); 233 | } 234 | }); 235 | }; 236 | 237 | this.observer = new IntersectionObserver(observerCallback, { 238 | root: null, 239 | threshold: 0.1 240 | }); 241 | 242 | footerSelectors.forEach(selector => { 243 | const footer = document.querySelector(selector); 244 | if (footer) { 245 | console.debug('Observing footer:', selector); 246 | this.observer.observe(footer); 247 | } 248 | }); 249 | } 250 | 251 | startPolling() { 252 | // Dừng polling cũ nếu có 253 | this.stopPolling(); 254 | 255 | // Kiểm tra định kỳ mỗi 1 giây 256 | this.pollingInterval = setInterval(() => { 257 | if (this.isStoryUrl() && !this.isAttached) { 258 | console.debug('Polling: Checking for footer...'); 259 | this.checkAndAttach(); 260 | } 261 | }, 1000); 262 | } 263 | 264 | stopPolling() { 265 | if (this.pollingInterval) { 266 | clearInterval(this.pollingInterval); 267 | this.pollingInterval = null; 268 | } 269 | } 270 | 271 | stopObserving() { 272 | if (this.observer) { 273 | this.observer.disconnect(); 274 | this.observer = null; 275 | } 276 | } 277 | 278 | async checkAndAttach() { 279 | if (!this.isStoryUrl()) { 280 | console.debug('Not on story page, skipping attachment'); 281 | return; 282 | } 283 | 284 | // Chờ DOM ổn định 285 | await new Promise(resolve => setTimeout(resolve, 200)); 286 | 287 | if (!this.container) { 288 | console.debug('Creating UI for the first time'); 289 | await this.setupUI(); 290 | } 291 | 292 | if (this.container && !this.isAttached) { 293 | this.throttledAttach(); 294 | } 295 | } 296 | 297 | attachToFooter() { 298 | const footerSelectors = [ 299 | "div[data-id] > div[role='contentinfo']", 300 | ".x11lhmoz.x78zum5.x1q0g3np", 301 | "[role='contentinfo']", 302 | ".x1cy8zhl.x9f619.x78zum5.x1q0g3np", 303 | ".x1yztbdb.x1sxj7lo", // Thêm selector mới cho Stories 304 | ".x1qjc9v5.x78zum5" // Selector bổ sung 305 | ]; 306 | 307 | let footer = null; 308 | for (const selector of footerSelectors) { 309 | footer = document.querySelector(selector); 310 | if (footer) { 311 | console.debug('Footer found with selector:', selector); 312 | break; 313 | } 314 | } 315 | 316 | if (footer && this.container && !footer.contains(this.container)) { 317 | try { 318 | footer.appendChild(this.container); 319 | this.isAttached = true; 320 | this.retryCount = 0; 321 | console.log('StoryReactor attached to footer'); 322 | } catch (err) { 323 | console.error('Failed to attach to footer:', err); 324 | this.scheduleRetry(); 325 | } 326 | } else if (!footer) { 327 | console.debug('Footer not found, scheduling retry'); 328 | this.scheduleRetry(); 329 | } 330 | } 331 | 332 | scheduleRetry() { 333 | if (this.retryCount < this.maxRetries) { 334 | this.retryCount++; 335 | console.debug(`Scheduling retry ${this.retryCount}/${this.maxRetries}`); 336 | setTimeout(() => { 337 | if (this.isStoryUrl()) { 338 | this.attachToFooter(); 339 | } 340 | }, this.retryDelay * (this.retryCount + 1)); 341 | } else { 342 | console.warn('Max retries reached, stopping attachment attempts'); 343 | } 344 | } 345 | 346 | setupNavigationListener() { 347 | window.removeEventListener('popstate', this.handlePopState); 348 | window.removeEventListener('hashchange', this.handleHashChange); 349 | 350 | window.addEventListener('popstate', this.handlePopState); 351 | window.addEventListener('hashchange', this.handleHashChange); 352 | 353 | const originalPushState = history.pushState; 354 | const originalReplaceState = history.replaceState; 355 | 356 | history.pushState = (...args) => { 357 | originalPushState.apply(history, args); 358 | this.throttledNavigation(); 359 | }; 360 | 361 | history.replaceState = (...args) => { 362 | originalReplaceState.apply(history, args); 363 | this.throttledNavigation(); 364 | }; 365 | } 366 | 367 | handlePopState() { 368 | console.debug('Popstate event triggered'); 369 | this.throttledNavigation(); 370 | } 371 | 372 | handleHashChange() { 373 | console.debug('Hashchange event triggered'); 374 | this.throttledNavigation(); 375 | } 376 | 377 | handleNavigation() { 378 | this.isAttached = false; 379 | this.retryCount = 0; 380 | 381 | if (this.isStoryUrl()) { 382 | console.log('Navigated to story page'); 383 | this.checkAndAttach(); 384 | } else { 385 | console.log('Navigated away from story page'); 386 | if (this.container && this.container.parentNode) { 387 | this.container.remove(); 388 | this.isAttached = false; 389 | } 390 | } 391 | } 392 | 393 | handleScroll(container) { 394 | const scrollTop = container.scrollTop; 395 | const scrollHeight = container.scrollHeight; 396 | const clientHeight = container.clientHeight; 397 | 398 | if (scrollTop + clientHeight >= scrollHeight * 0.8 && !this.isLoading && this.hasMore) { 399 | this.loadMoreEmojis(); 400 | } 401 | } 402 | 403 | loadMoreEmojis() { 404 | if (this.isLoading || !this.hasMore) return; 405 | 406 | this.isLoading = true; 407 | this.currentPage++; 408 | 409 | setTimeout(() => { 410 | this.renderEmojis(this.filteredEmojis, false); 411 | this.isLoading = false; 412 | }, 50); 413 | } 414 | 415 | renderEmojis(list, reset = false) { 416 | if (!this.emojiListElement) return; 417 | 418 | requestAnimationFrame(() => { 419 | if (reset) { 420 | this.emojiListElement.innerHTML = ""; 421 | this.currentPage = 0; 422 | this.hasMore = true; 423 | } 424 | 425 | const startIndex = this.currentPage * this.itemsPerPage; 426 | const endIndex = startIndex + this.itemsPerPage; 427 | const emojisToRender = list.slice(startIndex, endIndex); 428 | 429 | this.hasMore = endIndex < list.length; 430 | 431 | if (emojisToRender.length === 0) { 432 | this.hasMore = false; 433 | return; 434 | } 435 | 436 | const frag = document.createDocumentFragment(); 437 | 438 | emojisToRender.forEach((e, index) => { 439 | const li = document.createElement("li"); 440 | li.className = "emoji"; 441 | li.textContent = e.value; 442 | li.title = e.name; 443 | li.dataset.emoji = e.value; 444 | li.style.animationDelay = `${index * 10}ms`; 445 | frag.appendChild(li); 446 | }); 447 | 448 | this.emojiListElement.appendChild(frag); 449 | 450 | if (this.hasMore && !this.emojiListElement.querySelector('.loading-indicator')) { 451 | const loadingIndicator = document.createElement("li"); 452 | loadingIndicator.className = "loading-indicator"; 453 | loadingIndicator.innerHTML = "⏳"; 454 | loadingIndicator.style.gridColumn = "1 / -1"; 455 | loadingIndicator.style.textAlign = "center"; 456 | loadingIndicator.style.padding = "10px"; 457 | loadingIndicator.style.fontSize = "16px"; 458 | this.emojiListElement.appendChild(loadingIndicator); 459 | } else if (!this.hasMore) { 460 | const loadingIndicator = this.emojiListElement.querySelector('.loading-indicator'); 461 | if (loadingIndicator) { 462 | loadingIndicator.remove(); 463 | } 464 | } 465 | }); 466 | } 467 | 468 | renderGroupTabs() { 469 | if (!this.container) return; 470 | 471 | const panel = this.container.querySelector(".emoji-panel"); 472 | if (!panel) return; 473 | 474 | const old = panel.querySelector(".emoji-group-tabs"); 475 | if (old) old.remove(); 476 | 477 | const tabContainer = document.createElement("div"); 478 | tabContainer.className = "emoji-group-tabs"; 479 | const frag = document.createDocumentFragment(); 480 | 481 | const allTab = document.createElement("div"); 482 | allTab.className = "emoji-tab active"; 483 | allTab.textContent = "🗂️"; 484 | allTab.title = "Tất cả"; 485 | allTab.dataset.group = "all"; 486 | frag.appendChild(allTab); 487 | 488 | this.groupCache.forEach((group, name) => { 489 | const emoji = group[0]; 490 | const tab = document.createElement("div"); 491 | tab.className = "emoji-tab"; 492 | tab.textContent = emoji.value; 493 | tab.title = name; 494 | tab.dataset.group = name; 495 | frag.appendChild(tab); 496 | }); 497 | 498 | tabContainer.appendChild(frag); 499 | panel.appendChild(tabContainer); 500 | } 501 | 502 | addEventListeners(button, panel) { 503 | button.addEventListener("click", (e) => { 504 | e.preventDefault(); 505 | e.stopPropagation(); 506 | panel.classList.toggle("show"); 507 | }); 508 | 509 | const debouncedSearch = this.debounce((term) => { 510 | this.filteredEmojis = this.emojiList.filter(em => { 511 | const name = em.name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); 512 | const search = term.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); 513 | return name.includes(search) || em.value.includes(search); 514 | }); 515 | this.currentPage = 0; 516 | this.hasMore = true; 517 | this.renderEmojis(this.filteredEmojis, true); 518 | }, 200); 519 | 520 | this.searchInput.addEventListener("input", (e) => { 521 | const term = e.target.value.toLowerCase(); 522 | debouncedSearch(term); 523 | }); 524 | 525 | this.emojiListElement.addEventListener('click', (e) => { 526 | const emojiEl = e.target.closest('.emoji'); 527 | if (emojiEl) { 528 | e.preventDefault(); 529 | e.stopPropagation(); 530 | this.handleReaction(emojiEl.dataset.emoji); 531 | } 532 | }); 533 | 534 | panel.addEventListener('click', (e) => { 535 | const tab = e.target.closest('.emoji-tab'); 536 | if (tab) { 537 | e.preventDefault(); 538 | e.stopPropagation(); 539 | 540 | this.filteredEmojis = tab.dataset.group === 'all' 541 | ? [...this.emojiList] 542 | : this.groupCache.get(tab.dataset.group) || []; 543 | 544 | this.currentPage = 0; 545 | this.hasMore = true; 546 | this.renderEmojis(this.filteredEmojis, true); 547 | 548 | panel.querySelectorAll(".emoji-tab").forEach(t => t.classList.remove("active")); 549 | tab.classList.add("active"); 550 | } 551 | }); 552 | 553 | document.addEventListener('click', (e) => { 554 | if (!this.container.contains(e.target)) { 555 | panel.classList.remove("show"); 556 | } 557 | }); 558 | } 559 | 560 | async handleReaction(emoji) { 561 | try { 562 | const [userId, fbDtsg, storyId] = await Promise.all([ 563 | this.getUserId(), 564 | this.getFbDtsg(), 565 | this.getStoryId() 566 | ]); 567 | 568 | console.debug('Reaction parameters:', { userId, fbDtsg, storyId, emoji }); 569 | 570 | if (!userId || !fbDtsg || !storyId) { 571 | throw new Error('Thiếu tham số bắt buộc'); 572 | } 573 | 574 | await this.reactStory(userId, fbDtsg, storyId, emoji); 575 | console.log('Gửi phản hồi thành công:', emoji); 576 | } catch (err) { 577 | console.error('Gửi phản hồi thất bại:', err); 578 | } 579 | } 580 | 581 | getStoryId() { 582 | const story = document.querySelector(".xh8yej3.x1n2onr6[data-id]") || 583 | document.querySelector("[data-id]"); 584 | let storyId = story?.dataset.id || ""; 585 | 586 | if (storyId && !storyId.startsWith('Uzpf')) { 587 | try { 588 | storyId = btoa(storyId); 589 | } catch (err) { 590 | console.warn('Không thể mã hóa storyId sang base64:', err); 591 | } 592 | } 593 | return storyId; 594 | } 595 | 596 | getFbDtsg() { 597 | const scriptTags = document.querySelectorAll('script'); 598 | for (const script of scriptTags) { 599 | const match = script.textContent.match(/"DTSGInitialData"[^"]*"token":"([^"]+)"/); 600 | if (match) return match[1]; 601 | } 602 | 603 | const match = document.documentElement.innerHTML.match(/"DTSGInitialData",\[],{"token":"(.+?)"/); 604 | return match?.[1] || ""; 605 | } 606 | 607 | getUserId() { 608 | const pageMatch = document.cookie.match(/i_user=(\d+)/); 609 | if (pageMatch) { 610 | return pageMatch[1]; 611 | } 612 | const userMatch = document.cookie.match(/c_user=(\d+)/); 613 | return userMatch?.[1] || ""; 614 | } 615 | 616 | async reactStory(userId, fbDtsg, storyId, reaction) { 617 | try { 618 | const variables = { 619 | input: { 620 | attribution_id_v2: `StoriesCometSuspenseRoot.react,comet.stories.viewer,unexpected,${Date.now()},952132,,,;CometHomeRoot.react,comet.home,via_cold_start,${Date.now()},682755,4748854339,,`, 621 | lightweight_reaction_actions: { offsets: [0], reaction }, 622 | message: reaction, 623 | story_id: storyId, 624 | story_reply_type: "LIGHT_WEIGHT", 625 | actor_id: userId, 626 | client_mutation_id: Math.floor(Math.random() * 1000000) 627 | } 628 | }; 629 | 630 | const body = new URLSearchParams({ 631 | av: userId, 632 | __user: userId, 633 | __a: 1, 634 | fb_dtsg: fbDtsg, 635 | fb_api_caller_class: "RelayModern", 636 | fb_api_req_friendly_name: "useStoriesSendReplyMutation", 637 | variables: JSON.stringify(variables), 638 | server_timestamps: true, 639 | doc_id: "9697491553691692" 640 | }); 641 | 642 | const response = await fetch("https://www.facebook.com/api/graphql/", { 643 | method: "POST", 644 | headers: { 645 | "Content-Type": "application/x-www-form-urlencoded", 646 | "Accept": "application/json", 647 | "X-FB-Friendly-Name": "useStoriesSendReplyMutation", 648 | "X-FB-LSD": "Bkr8euu7oUK5QiCzeEopBH" 649 | }, 650 | body: body.toString() 651 | }); 652 | 653 | if (!response.ok) { 654 | throw new Error(`HTTP ${response.status}: ${response.statusText}`); 655 | } 656 | 657 | const result = await response.json(); 658 | if (result.errors) { 659 | throw new Error('Lỗi GraphQL: ' + JSON.stringify(result.errors)); 660 | } 661 | 662 | return result; 663 | } catch (err) { 664 | console.error('Thử gửi phản hồi thất bại:', err); 665 | if (this.retryCount < this.maxRetries) { 666 | this.retryCount++; 667 | console.log(`Thử lại phản hồi... Lần ${this.retryCount}`); 668 | await new Promise(resolve => setTimeout(resolve, this.retryDelay * this.retryCount)); 669 | return this.reactStory(userId, fbDtsg, storyId, reaction); 670 | } 671 | throw err; 672 | } 673 | } 674 | 675 | destroy() { 676 | this.stopObserving(); 677 | this.stopPolling(); 678 | if (this.container && this.container.parentNode) { 679 | this.container.remove(); 680 | } 681 | window.removeEventListener('popstate', this.handlePopState); 682 | window.removeEventListener('hashchange', this.handleHashChange); 683 | clearTimeout(this.debounceTimeout); 684 | this.isInitialized = false; 685 | } 686 | } 687 | 688 | const reactor = new StoryReactor(); 689 | window.StoryReactorInstance = reactor; 690 | reactor.init(); 691 | 692 | const cleanup = () => { 693 | if (window.StoryReactorInstance) { 694 | window.StoryReactorInstance.destroy(); 695 | window.StoryReactorInstance = null; 696 | } 697 | }; 698 | 699 | window.addEventListener('beforeunload', cleanup); 700 | 701 | if (document.readyState === 'loading') { 702 | document.addEventListener('DOMContentLoaded', () => { 703 | reactor.handleNavigation(); 704 | }); 705 | } 706 | })(); --------------------------------------------------------------------------------