├── assets └── icon.png ├── screenshots ├── demo.png ├── downloadFromFacebook.png └── downloadFromMessenger.png ├── LICENSE ├── README.md ├── index.html ├── style.css └── script.js /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DuckCIT/Facebook-Messenger-JSON-Viewer/HEAD/assets/icon.png -------------------------------------------------------------------------------- /screenshots/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DuckCIT/Facebook-Messenger-JSON-Viewer/HEAD/screenshots/demo.png -------------------------------------------------------------------------------- /screenshots/downloadFromFacebook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DuckCIT/Facebook-Messenger-JSON-Viewer/HEAD/screenshots/downloadFromFacebook.png -------------------------------------------------------------------------------- /screenshots/downloadFromMessenger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DuckCIT/Facebook-Messenger-JSON-Viewer/HEAD/screenshots/downloadFromMessenger.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Nguyen Trong Duc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Facebook Messenger JSON Viewer 2 | 3 | ## Overview 4 | The Facebook Messenger JSON Viewer is a tool designed to help users view and analyze their Facebook Messenger data in a more readable format. This tool converts JSON files exported from Facebook Messenger into an easy-to-navigate interface. 5 | 6 | ## Features 7 | - **JSON Parsing**: Efficiently parses JSON files. 8 | - **User-Friendly Interface**: Displays messages in a clean and organized manner. 9 | - **Media Support**: Allows viewing of images, videos, and audio files. 10 | - **Customization Options**: Provides options to show/hide timestamps, sender names, and reactions. 11 | - **Responsive Design**: Adapts to different screen sizes for better usability on mobile devices. 12 | 13 | ## Usage 14 | 1. Export your Facebook Messenger data from Facebook. 15 | 2. Upload the JSON file to the Facebook Messenger JSON Viewer on our [GitHub Page](https://duckcit.github.io/Facebook-Messenger-JSON-Viewer). 16 | 3. **If you want to view images, videos, and audio, you must select Allow access to MEDIA folder and choose the folder containing the Messenger JSON file** 17 | 18 | **There are two cases for the media folder as follows** 19 | 1. Download JSON from Facebook, select the message folder (containing the JSON file) 20 | ![Example](screenshots/downloadFromFacebook.png) 21 | 2. Download JSON from Messenger end-to-end, select the media folder 22 | ![Example](screenshots/downloadFromMessenger.png) 23 | 24 | ## Detailed Features 25 | 26 | ### JSON Parsing 27 | The tool efficiently parses JSON files exported from Facebook Messenger, ensuring that the data is accurately represented in the viewer. 28 | 29 | ### Customization Options 30 | Users can customize the display of messages with the following options: 31 | - **Show My Name**: Display or hide the user's name in the messages. 32 | - **Show Their Name**: Display or hide the other participants' names in the messages. 33 | - **Show Timestamps**: Display or hide the timestamps of the messages. 34 | - **Show Reactions**: Display or hide reactions to the messages. 35 | 36 | **DEMO** 37 | ![Demo](screenshots/demo.png) 38 | 39 | ## License 40 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more information. 41 | 42 | ## Contact 43 | For any questions or feedback, please contact us at duckcitvn@gmail.com 44 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Facebook Messenger JSON Viewer 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 |
16 |
17 |
18 | Facebook Messenger JSON Viewer 19 |
20 | 21 | © 2025 DuckCIT. All rights reserved | 22 | Src & Usage 23 | 24 |
25 | 26 | 32 |
33 |
34 |
35 | Select a Messenger JSON file to view 36 | 37 |
38 | Allow access to MEDIA folder 39 | 40 | To view images or videos, click the button and select the folder containing them. 41 | 42 |
43 |
44 | View from the perspective of: 45 |
46 | 47 |
48 | Customization 49 |
50 | 54 | 58 | 62 | 66 |
67 |
68 | 69 |
70 | 71 |
72 | Search messages 73 |
74 | 76 | 77 | 78 |
79 | 85 |
86 |
87 |
88 | 97 |
98 | 99 | 100 | 111 |
112 | 113 | 114 |
115 |

116 | Conversation: unknown 117 |

118 |
119 |
120 |
No data available
121 |
122 |
123 | 124 | 125 | 154 | 155 | 156 | 157 | 158 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | /* WebKit custom scrollbar */ 2 | *::-webkit-scrollbar { 3 | width: 12px; 4 | } 5 | 6 | *::-webkit-scrollbar-thumb { 7 | background: var(--muted); 8 | border-radius: 10px; 9 | /* transparent border + background-clip gives a visual gap between content and thumb */ 10 | border: 3px solid transparent; 11 | background-clip: padding-box; 12 | } 13 | 14 | *::-webkit-scrollbar-track { 15 | background: transparent; 16 | } 17 | 18 | /* add a little right padding on common scrollable containers so content doesn't touch the scrollbar */ 19 | #chat, .settings, .search-results { padding-right: 10px; } 20 | 21 | /* Theme variables */ 22 | :root { 23 | --bg: #ffffff; 24 | --panel-bg: #f6f8fb; 25 | --text: #111213; 26 | --muted: #6b6f76; 27 | --accent: #0084ff; 28 | --me-bg: #0084ff; 29 | --me-text: #ffffff; 30 | --them-bg: #e9eef8; 31 | --highlight: rgba(255,230,0,0.15); 32 | --border: #e6e9ee; 33 | /* hover overlay colors (light/dark) */ 34 | --hover-overlay: rgba(0,0,0,0.04); 35 | --hover-overlay-dark: rgba(255,255,255,0.04); 36 | } 37 | 38 | .dark { 39 | --bg: #0b1116; 40 | --panel-bg: #0f161b; 41 | --text: #e6eef8; 42 | --muted: #9aa4b2; 43 | --accent: #3ea6ff; 44 | --me-bg: #1667d6; 45 | --me-text: #f7fbff; 46 | --them-bg: #0f2a3a; 47 | --highlight: rgba(78,141,255,0.08); 48 | --border: #1b2330; 49 | } 50 | 51 | body { 52 | font-family: Arial, sans-serif; 53 | background: var(--bg); 54 | color: var(--text); 55 | margin: 0; 56 | } 57 | 58 | /* Typography */ 59 | h3 { margin: 15px; position: relative; font-size: 16px; } 60 | .settings strong { font-size: 16px; color: var(--text); } 61 | 62 | .placeholder { width:150px; height:60px; display:flex; align-items:center; justify-content:center; border-radius:10px; background:#ddd; color:#555; transition:all .2s ease; } 63 | .preview:hover { transform: scale(1.05); } 64 | .preview-video, .preview { max-width:250px; max-height:300px; } 65 | 66 | /* Layout */ 67 | #line { border-bottom: 1.5px solid var(--border); } 68 | .container { display:grid; grid-template-columns:3.5fr 6.5fr; height:100vh; } 69 | 70 | /* Settings Panel */ 71 | .settings { max-height: calc(100vh - 130px); overflow:auto; } 72 | 73 | .sub-container { margin:20px 0 20px 20px; background:var(--panel-bg); padding:15px; border-radius:10px; box-shadow:0 2px 6px rgba(0,0,0,0.06); position:relative; } 74 | .title-row { display:flex; align-items:center; justify-content:space-between; gap:12px; } 75 | .title-text { color:var(--text); } 76 | 77 | /* Settings button inline next to title */ 78 | .global-settings-btn { width:40px; height:36px; border-radius:8px; padding:6px; background:var(--panel-bg); border:none; cursor:pointer; color:var(--text); display:flex; align-items:center; justify-content:center; } 79 | .global-settings-btn svg { display:block; width:18px; height:18px; } 80 | .global-settings-btn .bar { transform-origin:50% 50%; transition: transform 180ms cubic-bezier(.2,.9,.2,1), opacity 130ms linear; } 81 | .global-settings-btn.open .vert { transform: rotate(45deg) translateY(0); } 82 | .global-settings-btn.open .horiz { transform: rotate(45deg) translateX(0); } 83 | 84 | /* Menu anchored to sub-container */ 85 | .global-settings-menu { position:absolute; right:24px; top:62px; z-index:2000; background:var(--panel-bg); border-radius:8px; padding:8px; box-shadow:0 2px 10px rgba(118, 121, 128, 0.12); } 86 | .global-settings-menu[aria-hidden="true"] { display:none; } 87 | .global-settings-menu .menu-section { min-width:220px; display:flex; flex-direction:column; gap:8px; } 88 | 89 | /* Links & small controls */ 90 | .footer a, .download-info a { color:var(--accent); } 91 | .btn-secondary { background:transparent; border:1px solid var(--muted); padding:6px 8px; border-radius:6px; color:var(--text); } 92 | 93 | #tips { color:var(--muted); font-size:12px; font-style:italic; margin-top:8px; display:block; } 94 | 95 | .settings input[type="file"] { margin:10px 0; padding:5px; display:block; border:1px solid var(--border); border-radius:5px; background:var(--panel-bg); cursor:pointer; } 96 | 97 | .choiceForm { display:flex; flex-direction:column; gap:5px; } 98 | .settings form { display:flex; flex-direction:column; gap:5px; margin-top:5px; } 99 | .settings label { display:flex; align-items:center; gap:5px; font-size:14px; cursor:pointer; } 100 | .settings input[type="checkbox"] { transform:scale(1.2); cursor:pointer; } 101 | 102 | /* Chat container */ 103 | .chat-container { display:flex; flex-direction:column; width:calc(100% - 60px); margin:20px auto; background:var(--panel-bg); padding:0 10px; border-radius:10px; box-shadow:0 2px 6px rgba(0,0,0,0.06); } 104 | #chat { overflow-y:auto; max-height:80vh; padding:10px; } 105 | 106 | /* Message */ 107 | .message { margin:5px; padding:10px 15px; border-radius:15px; max-width:50%; width:fit-content; word-break:break-word; overflow-wrap:break-word; } 108 | .from-me { background:var(--me-bg); color:var(--me-text); text-align:left; margin-left:auto; } 109 | .from-them { background:var(--them-bg); color:var(--text); text-align:left; margin-right:auto; } 110 | .sender-name { font-weight:bold; margin-bottom:5px; } 111 | .from-me .sender-name { color:var(--me-text); display:none; } 112 | .from-them .sender-name { color:var(--text); margin-right:10px; } 113 | .message-content { line-height:1.4; } 114 | .from-me .message-content { color:var(--me-text); } 115 | .from-them .message-content { color:var(--text); } 116 | .reaction { font-size:14px; margin-top:5px; text-align:right; } 117 | .timestamp { font-size:12px; margin-top:5px; display:none; } 118 | .from-me .timestamp { color: rgba(255,255,255,0.85); } 119 | .from-them .timestamp { color: var(--muted); } 120 | 121 | img, video { max-width:100%; border-radius:10px; margin:5px; cursor:pointer; transition:all .3s ease; object-fit:cover; } 122 | 123 | /* Loading & chunks */ 124 | #loading { text-align:center; height:100%; justify-content:center; align-items:center; display:flex; color:var(--muted); margin-bottom:20px; } 125 | .message-chunk { min-height:100px; } 126 | 127 | /* Footer */ 128 | .footer { margin:5px 0; display:block; color:var(--muted); } 129 | .footer a { color:var(--accent); text-decoration:none; } 130 | .footer a:hover { text-decoration:underline; } 131 | 132 | /* Misc */ 133 | .download-info strong { display:block; margin-bottom:8px; } 134 | .download-info a { display:block; color:var(--accent); font-size:0.9em; text-decoration:none; margin:6px 0; } 135 | .download-info a:hover { text-decoration:underline; } 136 | 137 | /* Search */ 138 | .search-section { margin-top:12px; margin-bottom:16px; } 139 | .search-controls { display:flex; gap:8px; margin:8px 0; } 140 | .search-controls input[type="search"] { flex:1; padding:8px; border-radius:6px; border:1px solid var(--border); margin: 2px;} 141 | .search-controls button { padding:8px 12px; background:var(--accent); color:white; border:none; border-radius:6px; cursor:pointer; } 142 | .search-controls button[type="button"] { background:var(--muted); } 143 | .message-content strong, .snippet strong { font-weight:700; background:var(--highlight); padding:0 2px; border-radius:2px; } 144 | .search-progress { display:none; align-items:center; gap:8px; margin:6px 0; } 145 | .search-progress .bar { width:100%; height:8px; background:var(--border); border-radius:6px; overflow:hidden; } 146 | .search-progress .fill { height:100%; background:var(--accent); width:0%; transition:width .2s linear; } 147 | .search-progress .progress-text { min-width:80px; font-size:12px; color:var(--muted); } 148 | .search-results { max-height:200px; overflow:auto; margin-top:8px; border:1px solid var(--border); padding:6px; border-radius:6px; background:var(--panel-bg); } 149 | .search-result-item { padding:6px; border-radius:6px; cursor:pointer; } 150 | .search-result-item:hover { background: rgba(0,0,0,0.03); } 151 | .search-result-item .snippet { color:var(--text); font-size:13px; } 152 | .search-result-item .meta { color:var(--muted); font-size:12px; margin-top:4px; } 153 | 154 | .highlight-target { box-shadow:0 0 0 3px rgba(24,119,242,0.15); border-radius:12px; } 155 | .temporary-highlight { animation: highlightFlash 2s ease both; } 156 | @keyframes highlightFlash { from{ box-shadow:0 0 0 6px rgba(24,119,242,0.25);} to{ box-shadow:0 0 0 0 rgba(24,119,242,0);} } 157 | 158 | /* Responsive */ 159 | @media (max-width:768px) { 160 | .container { grid-template-columns: 1fr; height:auto; } 161 | .chat-container { width: calc(100% - 20px); margin-top:10px; box-shadow:none; border-radius:0; } 162 | .settings { width:100%; box-shadow:none; border-radius:0; } 163 | } 164 | 165 | .menu-item { display:flex; align-items:center; justify-content:space-between; padding:8px; } 166 | 167 | .switch { position:relative; display:inline-block; width:46px; height:26px; } 168 | .switch input { opacity:0; width:0; height:0; } 169 | .slider { position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background:#ccc; transition:.16s; border-radius:26px; } 170 | .slider:before { position:absolute; content:""; height:20px; width:20px; left:4px; bottom:3px; background:white; transition:.16s; border-radius:50%; } 171 | .switch input:checked + .slider { background:var(--accent); } 172 | .switch input:checked + .slider:before { transform: translateX(20px); } 173 | 174 | /* Loading */ 175 | #loading { 176 | text-align: center; 177 | height: 100%; 178 | justify-content: center; 179 | align-items: center; 180 | display: flex; 181 | color: var(--muted); 182 | margin-bottom: 20px; 183 | } 184 | .message-chunk { 185 | min-height: 100px; 186 | } 187 | 188 | /* Footer */ 189 | .footer { 190 | margin: 5px 0; 191 | display: block; 192 | color: var(--muted); 193 | } 194 | 195 | a { 196 | color: var(--accent); 197 | text-decoration: none; 198 | } 199 | 200 | a:hover { 201 | text-decoration: underline; 202 | } 203 | 204 | /* Options */ 205 | .options { 206 | display: none; 207 | } 208 | 209 | .options strong { 210 | display: block; 211 | margin-bottom: 8px; 212 | } 213 | 214 | /* Download Info */ 215 | .download-info strong { 216 | display: block; 217 | margin-bottom: 8px; 218 | } 219 | 220 | .download-info a { 221 | display: block; 222 | color: #1877f2; 223 | font-size: 0.9em; 224 | text-decoration: none; 225 | margin: 6px 0; 226 | } 227 | 228 | .download-info a:hover { 229 | text-decoration: underline; 230 | } 231 | 232 | /* Search */ 233 | .search-section { 234 | margin-top: 12px; 235 | margin-bottom: 16px; 236 | } 237 | .search-controls { 238 | display: flex; 239 | gap: 8px; 240 | margin: 8px 0; 241 | } 242 | .search-controls input[type="search"] { 243 | flex: 1; 244 | padding: 8px; 245 | border-radius: 6px; 246 | border: 1px solid #ccc; 247 | } 248 | .search-controls button { 249 | padding: 8px 12px; 250 | background: var(--accent); 251 | color: white; 252 | border: none; 253 | border-radius: 6px; 254 | cursor: pointer; 255 | } 256 | .search-controls button[type="button"] { background: var(--muted); } 257 | 258 | .message-content strong, .snippet strong { font-weight: 700; background: var(--highlight); padding: 0 2px; border-radius: 2px; } 259 | .search-progress { display: none; align-items: center; gap: 8px; margin: 6px 0; } 260 | .search-progress .bar { width: 100%; height: 8px; background: var(--border); border-radius: 6px; overflow: hidden; } 261 | .search-progress .fill { height: 100%; background: var(--accent); width: 0%; transition: width 0.2s linear; } 262 | .search-progress .progress-text { min-width: 80px; font-size: 12px; color: var(--muted);} 263 | .search-results { max-height: 200px; overflow: auto; margin-top: 8px; border: 1px solid var(--border); padding: 6px; border-radius: 6px; background: var(--panel-bg); } 264 | .search-result-item { padding: 6px; border-radius: 6px; cursor: pointer; } 265 | .search-result-item:hover { background: rgba(0,0,0,0.03); } 266 | .search-result-item .snippet { color: var(--text); font-size: 13px; } 267 | .search-result-item .meta { color: var(--muted); font-size: 12px; margin-top: 4px; } 268 | .highlight-target { box-shadow: 0 0 0 3px rgba(24, 119, 242, 0.15); border-radius: 12px; } 269 | .temporary-highlight { animation: highlightFlash 2s ease both; } 270 | @keyframes highlightFlash { from { box-shadow: 0 0 0 6px rgba(24,119,242,0.25);} to { box-shadow: 0 0 0 0 rgba(24,119,242,0);} } 271 | 272 | /* Responsive */ 273 | @media (max-width: 768px) { 274 | .container { 275 | flex-direction: column; 276 | display: flex; 277 | align-items: center; 278 | height: auto; 279 | } 280 | 281 | .chat-container { 282 | display: flex; 283 | width: auto; 284 | min-width: calc(100% - 20px); 285 | border-radius: 0; 286 | box-shadow: none; 287 | margin-top: 10px; 288 | max-height: 100%; 289 | } 290 | 291 | .settings { 292 | width: auto; 293 | max-height: 100%; 294 | margin: 0px; 295 | border-radius: 0; 296 | box-shadow: none; 297 | } 298 | 299 | } 300 | 301 | .menu-item { display: flex; align-items: center; justify-content: space-between; padding: 8px; } 302 | 303 | /* Toggle switch */ 304 | .switch { position: relative; display: inline-block; width: 46px; height: 26px; } 305 | .switch input { opacity: 0; width: 0; height: 0; } 306 | .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background: #ccc; transition: .2s; border-radius: 26px; } 307 | .slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 4px; bottom: 3px; background: white; transition: .2s; border-radius: 50%; } 308 | .switch input:checked + .slider { background: var(--accent); } 309 | .switch input:checked + .slider:before { transform: translateX(20px); } 310 | 311 | /* Trust / Privacy modal */ 312 | .trust-modal { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; z-index:4000; } 313 | .trust-modal[aria-hidden="true"] { display:none; } 314 | .trust-backdrop { position:fixed; inset:0; background:rgba(10,15,25,0.55); backdrop-filter: blur(3px); } 315 | .trust-card { position:relative; width:min(740px, 92%); background:var(--panel-bg); color:var(--text); border-radius:12px; box-shadow:0 18px 50px rgba(0,0,0,0.45); padding:18px; z-index:4100; max-height:80vh; overflow:auto; } 316 | .trust-header h2 { margin:0 0 8px 0; font-size:18px; } 317 | .trust-body { font-size:14px; color:var(--muted); line-height:1.5; } 318 | .trust-body p { margin:10px 0; color:var(--text); } 319 | .trust-row { display:flex; align-items:center; gap:8px; margin-top:12px; font-size:13px; color:var(--muted); } 320 | .trust-footer { display:flex; align-items:center; justify-content:space-between; margin-top:14px; } 321 | .trust-actions { display:flex; gap:8px; } 322 | .btn { padding:8px 12px; border-radius:8px; border:1px solid transparent; cursor:pointer; } 323 | .btn-primary { background:var(--accent); color:white; border-color:rgba(0,0,0,0.06); } 324 | .btn-secondary { background:transparent; color:var(--text); border:1px solid var(--border); } 325 | .btn-link { color:var(--accent); text-decoration:none; font-weight:600; margin-right:12px; } 326 | .clickable { position:relative; overflow:visible; } 327 | .clickable::after { 328 | content: ''; 329 | position: absolute; 330 | inset: 0; 331 | border-radius: inherit; 332 | background: transparent; 333 | pointer-events: none; 334 | transition: background .16s ease, transform .16s ease; 335 | } 336 | .clickable:hover::after, .clickable:focus::after { background: var(--hover-overlay); } 337 | .dark .clickable:hover::after, .dark .clickable:focus::after { background: var(--hover-overlay-dark); } 338 | 339 | /* Apply to common interactive selectors */ 340 | .global-settings-btn, .btn, .btn-secondary, .search-controls button, .media-preview, .search-result-item { position:relative; } 341 | .global-settings-btn:hover, .btn:hover, .btn-secondary:hover, .search-controls button:hover, .media-preview:hover, .search-result-item:hover { transform: translateY(-1px); } 342 | 343 | /* Apply overlay pseudo to common interactive selectors so hover effect works without extra classes */ 344 | .global-settings-btn::after, .btn::after, .btn-secondary::after, .search-controls button::after, .media-preview::after, .search-result-item::after { 345 | content: ''; 346 | position: absolute; 347 | inset: 0; 348 | border-radius: inherit; 349 | background: transparent; 350 | pointer-events: none; 351 | transition: background .16s ease; 352 | } 353 | .global-settings-btn:hover::after, .btn:hover::after, .btn-secondary:hover::after, .search-controls button:hover::after, .media-preview:hover::after, .search-result-item:hover::after { 354 | background: var(--hover-overlay); 355 | } 356 | .dark .global-settings-btn:hover::after, .dark .btn:hover::after, .dark .btn-secondary:hover::after, .dark .search-controls button:hover::after, .dark .media-preview:hover::after, .dark .search-result-item:hover::after { 357 | background: var(--hover-overlay-dark); 358 | } 359 | 360 | @media (max-width:520px) { 361 | .trust-card { padding:12px; } 362 | .trust-header h2 { font-size:16px; } 363 | } -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | // Handle file upload 2 | document.getElementById("fileInput").addEventListener("change", handleFileUpload); 3 | 4 | let currentJsonFileName = null; 5 | let currentJsonFileSize = null; 6 | let currentJsonFileModified = null; 7 | const CHUNK_SIZE = 50; 8 | let renderedMessages = new Map(); 9 | let observer = null; 10 | 11 | // Storage wrapper: namespace keys and fallback to cookies if localStorage unavailable 12 | const STORAGE_PREFIX = 'fmjv_' + (window.location.hostname || 'local') + '_'; 13 | 14 | function setCookie(name, value, days = 365) { 15 | try { 16 | const expires = new Date(Date.now() + days * 864e5).toUTCString(); 17 | document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value) + '; expires=' + expires + '; path=/'; 18 | } catch(e) {} 19 | } 20 | 21 | function getCookie(name) { 22 | try { 23 | const cookies = document.cookie ? document.cookie.split('; ') : []; 24 | for (let c of cookies) { 25 | const [k,v] = c.split('='); 26 | if (decodeURIComponent(k) === name) return decodeURIComponent(v || ''); 27 | } 28 | } catch(e) {} 29 | return null; 30 | } 31 | 32 | function storageSet(key, value) { 33 | const k = STORAGE_PREFIX + key; 34 | try { localStorage.setItem(k, String(value)); return; } catch(e) {} 35 | try { setCookie(k, String(value)); } catch(e) {} 36 | } 37 | 38 | function storageGet(key) { 39 | const k = STORAGE_PREFIX + key; 40 | try { const v = localStorage.getItem(k); if (v !== null) return v; } catch(e) {} 41 | try { const v = getCookie(k); if (v !== null) return v; } catch(e) {} 42 | return null; 43 | } 44 | 45 | function storageRemove(key) { 46 | const k = STORAGE_PREFIX + key; 47 | try { localStorage.removeItem(k); } catch(e) {} 48 | try { setCookie(k, '', -1); } catch(e) {} 49 | } 50 | 51 | function handleFileUpload(event) { 52 | const file = event.target.files[0]; 53 | if (!file) return; 54 | 55 | // If a different file (by name, size or modified time) is selected, clear previous search index. 56 | // Do NOT clear uploaded media here so a single uploaded media folder can be reused across multiple JSON files. 57 | if (currentJsonFileName && (currentJsonFileName !== file.name || currentJsonFileSize !== file.size || currentJsonFileModified !== file.lastModified)) { 58 | try { __searchIndex = null; } catch(e){} 59 | try { if (searchInput) searchInput.value = ''; } catch(e){} 60 | try { if (searchResultsEl) searchResultsEl.innerHTML = ''; } catch(e){} 61 | try { 62 | if (searchProgress) { 63 | searchProgress.querySelector('.fill').style.width = '0%'; 64 | searchProgress.querySelector('.progress-text').innerText = 'Idle'; 65 | searchProgress.style.display = 'none'; 66 | } 67 | } catch(e){} 68 | } 69 | 70 | currentJsonFileName = file.name; 71 | currentJsonFileSize = file.size; 72 | currentJsonFileModified = file.lastModified; 73 | 74 | const options = document.getElementsByClassName("options")[0]; 75 | const loading = document.getElementById("loading"); 76 | const chatContainer = document.getElementById("chat"); 77 | 78 | options.style.display = "block"; 79 | loading.innerHTML = "Loading..."; 80 | loading.style.display = "flex"; 81 | chatContainer.scrollTop = 0; 82 | chatContainer.innerHTML = ""; 83 | 84 | const reader = new FileReader(); 85 | reader.onload = (e) => processFileContent(e.target.result); 86 | reader.readAsText(file, 'utf-8'); 87 | } 88 | 89 | function processFileContent(content) { 90 | try { 91 | let data; 92 | const isThreadPathFormat = content.includes('"thread_path"'); 93 | 94 | if (isThreadPathFormat) { 95 | const replaced = content.replace(/\\u00([a-f0-9]{2})|\\u([a-f0-9]{4})/gi, (match, p1, p2) => { 96 | const code = p1 ? parseInt(p1, 16) : parseInt(p2, 16); 97 | return String.fromCharCode(code); 98 | }); 99 | const decoded = decodeURIComponent(escape(replaced)); 100 | data = JSON.parse(decoded); 101 | data.messages = data.messages.reverse(); 102 | } else { 103 | data = JSON.parse(content); 104 | } 105 | setupChatInterface(data); 106 | } catch (error) { 107 | alert("Invalid JSON file!"); 108 | } 109 | } 110 | 111 | function setupChatInterface(data) { 112 | window.currentChatData = data; 113 | // reset search index for new chat 114 | __searchIndex = null; 115 | 116 | const participants = data.participants.map(p => (typeof p === 'string' ? p : p.name)); 117 | const threadName = data.threadName || data.title || data.threadPath || "Untitled"; 118 | 119 | document.getElementById("threadName").innerText = threadName; 120 | setupRadioButtons(participants); 121 | 122 | // after building radios, determine selected 123 | let selectedValue = (document.querySelector('input[name="choice"]:checked') || {}).value; 124 | 125 | setupCheckboxListeners(); 126 | // render using the selected perspective 127 | renderMessages(data, selectedValue); 128 | } 129 | 130 | function setupRadioButtons(participants) { 131 | const radioForm = document.getElementById("radioForm"); 132 | radioForm.innerHTML = ""; 133 | const saved = (storageGet('selectedPerspective') || null); 134 | participants.forEach((participant, index) => { 135 | const label = document.createElement("label"); 136 | const input = document.createElement("input"); 137 | 138 | input.type = "radio"; 139 | input.name = "choice"; 140 | input.id = `option${index + 1}`; 141 | input.value = participant; 142 | // restore saved selection if it matches, otherwise keep default on first 143 | if (saved && saved === participant) input.checked = true; 144 | else if (!saved && index === 0) input.checked = true; 145 | 146 | // when changed, persist and re-render using current chat data (if present) 147 | input.addEventListener('change', () => { 148 | try { storageSet('selectedPerspective', input.value); } catch(e){} 149 | if (window.currentChatData) renderMessages(window.currentChatData, input.value); 150 | }); 151 | 152 | label.appendChild(input); 153 | label.appendChild(document.createTextNode(` ${participant}`)); 154 | 155 | radioForm.appendChild(label); 156 | }); 157 | } 158 | 159 | function setupCheckboxListeners() { 160 | const checkboxConfig = [ 161 | { id: "showTime", class: ".timestamp" }, 162 | { id: "showMyName", class: ".from-me .sender-name" }, 163 | { id: "showTheirName", class: ".from-them .sender-name" }, 164 | { id: "showReacts", class: ".reaction" } 165 | ]; 166 | 167 | checkboxConfig.forEach(({ id, class: className }) => { 168 | const input = document.getElementById(id); 169 | if (!input) return; 170 | // restore saved state 171 | try { 172 | const saved = storageGet('ui_' + id); 173 | if (saved !== null) { 174 | input.checked = saved === '1'; 175 | } 176 | } catch(e) {} 177 | 178 | // listener to apply and persist 179 | input.addEventListener("change", function() { 180 | const elements = document.querySelectorAll(className); 181 | elements.forEach(el => el.style.display = this.checked ? "block" : "none"); 182 | try { storageSet('ui_' + id, this.checked ? '1' : '0'); } catch(e){} 183 | }); 184 | 185 | // trigger change once to apply initial visibility 186 | input.dispatchEvent(new Event('change')); 187 | }); 188 | } 189 | 190 | function renderMessages(data, selectedValue) { 191 | const chatContainer = document.getElementById("chat"); 192 | const loading = document.getElementById("loading"); 193 | 194 | chatContainer.style.display = "none"; 195 | loading.innerHTML = "Loading messages..."; 196 | loading.style.display = "flex"; 197 | 198 | if (observer) { 199 | observer.disconnect(); 200 | } 201 | 202 | renderedMessages.clear(); 203 | chatContainer.innerHTML = ""; 204 | 205 | if (!data.messages.length) { 206 | loading.innerHTML = "No messages"; 207 | chatContainer.style.display = "block"; 208 | return; 209 | } 210 | 211 | const messageChunks = chunkArray(data.messages, CHUNK_SIZE); 212 | 213 | messageChunks.forEach((chunk, index) => { 214 | const chunkContainer = document.createElement("div"); 215 | chunkContainer.classList.add("message-chunk"); 216 | chunkContainer.dataset.chunkIndex = index; 217 | chatContainer.appendChild(chunkContainer); 218 | }); 219 | 220 | observer = new IntersectionObserver((entries) => { 221 | entries.forEach(entry => { 222 | if (entry.isIntersecting) { 223 | const chunkIndex = parseInt(entry.target.dataset.chunkIndex); 224 | renderChunk(chunkIndex, messageChunks[chunkIndex], selectedValue); 225 | } 226 | }); 227 | }, { 228 | root: chatContainer, 229 | threshold: 0.1, 230 | rootMargin: "200px" 231 | }); 232 | 233 | document.querySelectorAll(".message-chunk").forEach(chunk => { 234 | observer.observe(chunk); 235 | }); 236 | 237 | setTimeout(() => { 238 | loading.style.display = "none"; 239 | chatContainer.style.display = "block"; 240 | }, 100); 241 | } 242 | 243 | // Media handling 244 | let mediaFiles = {}; 245 | let mediaTypes = {}; 246 | const mediaFolderInput = document.getElementById("mediaFolder"); 247 | 248 | mediaFolderInput.addEventListener("change", function(event) { 249 | const files = event.target.files; 250 | if (!files.length) { 251 | return; 252 | } 253 | 254 | const chatContainer = document.getElementById("chat"); 255 | const loading = document.getElementById("loading"); 256 | chatContainer.style.display = "none"; 257 | loading.innerHTML = "Processing media..."; 258 | loading.style.display = "flex"; 259 | 260 | processMediaFiles(files).then(() => { 261 | if (window.currentChatData) { 262 | renderMessages(window.currentChatData, 263 | document.querySelector('input[name="choice"]:checked').value); 264 | loading.style.display = "none"; 265 | chatContainer.style.display = "block"; 266 | } 267 | }); 268 | }); 269 | 270 | async function processMediaFiles(files) { 271 | const BATCH_SIZE = 20; 272 | const fileArray = Array.from(files); 273 | 274 | resetMedia(); 275 | 276 | for (let i = 0; i < fileArray.length; i += BATCH_SIZE) { 277 | const batch = fileArray.slice(i, i + BATCH_SIZE); 278 | 279 | await Promise.all(batch.map(file => { 280 | return new Promise(resolve => { 281 | const fileURL = URL.createObjectURL(file); 282 | const relativePath = file.webkitRelativePath || file.name; // Preserve folder structure if available 283 | mediaFiles[relativePath] = fileURL; 284 | mediaTypes[relativePath] = getMediaType(file.name); 285 | resolve(); 286 | }); 287 | })); 288 | } 289 | console.log("Media files processed:", Object.keys(mediaFiles)); 290 | } 291 | 292 | function resetMedia() { 293 | Object.values(mediaFiles).forEach(url => URL.revokeObjectURL(url)); 294 | mediaFiles = {}; 295 | mediaTypes = {}; 296 | } 297 | 298 | function getMediaType(filename) { 299 | const extension = filename.split('.').pop().toLowerCase(); 300 | if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) return "image"; 301 | if (["mp4", "webm", "ogg"].includes(extension)) return "video"; 302 | if (["mp3", "wav", "aac", "ogg"].includes(extension)) return "audio"; 303 | return "unknown"; 304 | } 305 | 306 | // Highlight helpers: diacritic-insensitive matching by building a normalized mapping 307 | function buildNormalizedMap(original) { 308 | const mapping = []; // mapping[normalizedPos] = originalIndex 309 | let normalized = ''; 310 | for (let i = 0; i < original.length; i++) { 311 | const ch = original[i]; 312 | const n = ch.normalize('NFD').replace(/\p{Diacritic}/gu, ''); 313 | for (let k = 0; k < n.length; k++) { 314 | mapping.push(i); 315 | normalized += n[k]; 316 | } 317 | } 318 | return { normalized: normalized.toLowerCase(), mapping }; 319 | } 320 | 321 | function findRangesForToken(original, tokenNorm) { 322 | const { normalized, mapping } = buildNormalizedMap(original); 323 | const token = tokenNorm; 324 | const ranges = []; 325 | let start = 0; 326 | while (true) { 327 | const idx = normalized.indexOf(token, start); 328 | if (idx === -1) break; 329 | const origStart = mapping[idx]; 330 | const origEnd = mapping[idx + token.length - 1] + 1; // exclusive 331 | ranges.push([origStart, origEnd]); 332 | start = idx + token.length; 333 | } 334 | return ranges; 335 | } 336 | 337 | function mergeRanges(ranges) { 338 | if (!ranges.length) return []; 339 | ranges.sort((a,b)=>a[0]-b[0]); 340 | const out = [ranges[0].slice()]; 341 | for (let i = 1; i < ranges.length; i++) { 342 | const cur = ranges[i]; 343 | const last = out[out.length-1]; 344 | if (cur[0] <= last[1]) { 345 | last[1] = Math.max(last[1], cur[1]); 346 | } else out.push(cur.slice()); 347 | } 348 | return out; 349 | } 350 | 351 | function highlightText(original, query) { 352 | if (!query || !original) return escapeHtml(original); 353 | const qNorm = normalizeForSearch(query); 354 | const tokens = qNorm.split(' ').filter(Boolean); 355 | if (!tokens.length) return escapeHtml(original); 356 | 357 | let allRanges = []; 358 | for (const t of tokens) { 359 | const ranges = findRangesForToken(original, t); 360 | allRanges = allRanges.concat(ranges); 361 | } 362 | if (!allRanges.length) return escapeHtml(original); 363 | const merged = mergeRanges(allRanges); 364 | // build HTML with 365 | let out = ''; 366 | let lastIdx = 0; 367 | for (const [s,e] of merged) { 368 | out += escapeHtml(original.slice(lastIdx, s)); 369 | out += '' + escapeHtml(original.slice(s, e)) + ''; 370 | lastIdx = e; 371 | } 372 | out += escapeHtml(original.slice(lastIdx)); 373 | return out; 374 | } 375 | 376 | function createMessageHTML(msg, highlightQuery) { 377 | const sender = msg.senderName || msg.sender_name || "Unknown"; 378 | const rawText = msg.text || msg.content || ""; 379 | const text = highlightQuery ? highlightText(String(rawText), highlightQuery) : escapeHtml(String(rawText)); 380 | const timestamp = msg.timestamp || msg.timestamp_ms || 0; 381 | // Combine all possible media arrays 382 | const mediaItems = [].concat( 383 | msg.media || [], 384 | msg.photos || [], 385 | msg.videos || [], 386 | msg.audio || [], 387 | msg.audio_files || [], // Add support for audio_files 388 | msg.gifs || [] 389 | ); 390 | 391 | return ` 392 |
${escapeHtml(sender)}
393 |
394 | ${text} 395 | ${mediaItems.map(media => { 396 | const fileName = media.uri.split(/[\\\/]/).pop().toLowerCase(); // Normalize to lowercase 397 | const matchingFile = Object.keys(mediaFiles).find(f => f.toLowerCase().endsWith(fileName)); 398 | const fileURL = matchingFile ? mediaFiles[matchingFile] : null; 399 | // Determine media type based on file extension, overriding JSON context if needed 400 | const extension = fileName.split('.').pop().toLowerCase(); 401 | const mediaType = extension === "mp4" ? "video" : (matchingFile ? mediaTypes[matchingFile] : getMediaType(fileName)); 402 | 403 | if (mediaType === "image") { 404 | return fileURL 405 | ? `Image` 406 | : `[ Image not found ]`; 407 | } else if (mediaType === "video") { 408 | return fileURL 409 | ? `` 410 | : `[ Video not found ]`; 411 | } else if (mediaType === "audio") { 412 | return fileURL 413 | ? `` 414 | : `[ Audio not found ]`; 415 | } 416 | return `[ Media not found ]`; 417 | }).join("")} 418 | ${msg.reactions?.length ? `
${msg.reactions.map(r => `${escapeHtml(r.actor)}: ${escapeHtml(r.reaction)}`).join(", ")}
` : ""} 419 |
${new Date(timestamp).toLocaleString()}
420 |
421 | `; 422 | } 423 | 424 | function chunkArray(array, size) { 425 | const result = []; 426 | for (let i = 0; i < array.length; i += size) { 427 | result.push(array.slice(i, i + size)); 428 | } 429 | return result; 430 | } 431 | 432 | window.addEventListener("beforeunload", () => { 433 | if (observer) observer.disconnect(); 434 | Object.values(mediaFiles).forEach(url => URL.revokeObjectURL(url)); 435 | renderedMessages.clear(); 436 | }); 437 | 438 | // ------------------ Search implementation ------------------ 439 | const searchInput = document.getElementById('searchInput'); 440 | const searchBtn = document.getElementById('searchBtn'); 441 | const searchResultsEl = document.getElementById('searchResults'); 442 | const searchProgress = document.getElementById('searchProgress'); 443 | 444 | // hide results by default until an explicit search runs 445 | if (searchResultsEl) searchResultsEl.style.display = 'none'; 446 | 447 | // Small utility: normalize strings (lowercase, remove diacritics, collapse whitespace) 448 | function normalizeForSearch(str) { 449 | if (!str) return ''; 450 | // Unicode normalize and remove diacritics 451 | const normalized = str.normalize('NFD').replace(/\p{Diacritic}/gu, ''); 452 | return normalized.toLowerCase().replace(/\s+/g, ' ').trim(); 453 | } 454 | 455 | // Build a lightweight search index when messages are loaded 456 | function buildSearchIndex(messages) { 457 | // index: array of { text, normalized, sender, timestamp, idx } 458 | const idx = []; 459 | for (let i = 0; i < messages.length; i++) { 460 | const m = messages[i]; 461 | const parts = []; 462 | if (m.text) parts.push(typeof m.text === 'string' ? m.text : (m.content || '')); 463 | if (m.content) parts.push(m.content); 464 | if (m.senderName) parts.push(m.senderName); 465 | // include reactions summary 466 | if (m.reactions && m.reactions.length) parts.push(m.reactions.map(r => r.reaction + ' ' + (r.actor||'')).join(' ')); 467 | // include media filenames 468 | const mediaItems = [].concat(m.media || [], m.photos || [], m.videos || [], m.audio || [], m.audio_files || [], m.gifs || []); 469 | mediaItems.forEach(mi => { if (mi && mi.uri) parts.push(mi.uri); }); 470 | 471 | const text = parts.join(' '); 472 | idx.push({ text, normalized: normalizeForSearch(text), sender: m.senderName || m.sender_name || 'Unknown', timestamp: m.timestamp || m.timestamp_ms || 0, idx: i }); 473 | } 474 | return idx; 475 | } 476 | 477 | // Simple fuzzy scoring: combination of substring match, token overlap, and Levenshtein distance on small strings 478 | function fuzzyScore(query, target) { 479 | if (!query || !target) return 0; 480 | if (target.includes(query)) return 100 + Math.min(50, query.length); // strong boost for substring 481 | 482 | // token overlap 483 | const qTokens = query.split(' '); 484 | const tTokens = target.split(' '); 485 | let overlap = 0; 486 | for (const qt of qTokens) { 487 | for (const tt of tTokens) { 488 | if (tt.includes(qt) || qt.includes(tt)) { overlap += 1; break; } 489 | } 490 | } 491 | const tokenScore = overlap * 10; 492 | 493 | // small Levenshtein distance for short tokens (cheap implementation) 494 | function lev(a,b){ 495 | const m=a.length,n=b.length; if(m*n===0) return m+n; const dp = Array(m+1).fill(0).map(()=>Array(n+1).fill(0)); 496 | for(let i=0;i<=m;i++) dp[i][0]=i; for(let j=0;j<=n;j++) dp[0][j]=j; 497 | for(let i=1;i<=m;i++) for(let j=1;j<=n;j++) dp[i][j]=a[i-1]===b[j-1]?dp[i-1][j-1]:Math.min(dp[i-1][j]+1,dp[i][j-1]+1,dp[i-1][j-1]+1); 498 | return dp[m][n]; 499 | } 500 | 501 | const shortQuery = query.length > 30 ? query.slice(0,30) : query; 502 | const dist = lev(shortQuery, target.slice(0, shortQuery.length+10)); 503 | const distScore = Math.max(0, 30 - dist); 504 | 505 | return tokenScore + distScore; 506 | } 507 | 508 | // Asynchronous batched search to keep UI responsive and report progress 509 | async function performSearch(query, index, onProgress) { 510 | const results = []; 511 | const normalizedQuery = normalizeForSearch(query); 512 | if (!normalizedQuery) return results; 513 | 514 | const BATCH = 500; // tuned for responsiveness 515 | for (let i = 0; i < index.length; i += BATCH) { 516 | const batch = index.slice(i, i + BATCH); 517 | for (const item of batch) { 518 | const score = fuzzyScore(normalizedQuery, item.normalized); 519 | if (score > 0) results.push({ score, item }); 520 | } 521 | if (onProgress) onProgress(Math.min(100, Math.round(((i + BATCH) / index.length) * 100))); 522 | // yield to UI 523 | await new Promise(r => setTimeout(r, 0)); 524 | } 525 | results.sort((a,b) => b.score - a.score); 526 | return results; 527 | } 528 | 529 | // Global search index 530 | let __searchIndex = null; 531 | 532 | // Hook up search actions 533 | searchBtn?.addEventListener('click', startSearch); 534 | searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') startSearch(); }); 535 | const clearSearchBtn = document.getElementById('clearSearchBtn'); 536 | clearSearchBtn?.addEventListener('click', clearSearch); 537 | 538 | // Update highlights live when user edits the search box (but debounce) 539 | let _highlightTimeout = null; 540 | searchInput?.addEventListener('input', () => { 541 | clearTimeout(_highlightTimeout); 542 | _highlightTimeout = setTimeout(() => { 543 | const q = (searchInput.value || '').trim(); 544 | // if input cleared, hide results box 545 | if (!q) { 546 | if (searchResultsEl) searchResultsEl.style.display = 'none'; 547 | } 548 | updateHighlightsAcrossDOM(q); 549 | }, 250); 550 | }); 551 | 552 | async function startSearch() { 553 | const q = searchInput.value || ''; 554 | if (!window.currentChatData || !window.currentChatData.messages) return; 555 | 556 | // build index lazily 557 | if (!__searchIndex) { 558 | searchProgress.style.display = 'flex'; 559 | searchProgress.querySelector('.progress-text').innerText = 'Indexing...'; 560 | await new Promise(r => setTimeout(r, 0)); 561 | __searchIndex = buildSearchIndex(window.currentChatData.messages); 562 | } 563 | 564 | // perform search 565 | searchProgress.style.display = 'flex'; 566 | searchProgress.querySelector('.fill').style.width = '0%'; 567 | searchProgress.querySelector('.progress-text').innerText = 'Searching...'; 568 | searchResultsEl.innerHTML = ''; 569 | if (searchResultsEl) searchResultsEl.style.display = 'block'; 570 | 571 | const results = await performSearch(q, __searchIndex, (p) => { 572 | searchProgress.querySelector('.fill').style.width = p + '%'; 573 | searchProgress.querySelector('.progress-text').innerText = `Searching ${p}%`; 574 | }); 575 | 576 | // done 577 | searchProgress.querySelector('.fill').style.width = '100%'; 578 | searchProgress.querySelector('.progress-text').innerText = `Found ${results.length} matches`; 579 | setTimeout(()=>{ searchProgress.style.display = 'none'; }, 800); 580 | 581 | // show top results 582 | if (!results.length) { 583 | searchResultsEl.innerHTML = '
No results
'; 584 | return; 585 | } 586 | 587 | const maxResults = Math.min(50, results.length); 588 | const frag = document.createDocumentFragment(); 589 | for (let i = 0; i < maxResults; i++) { 590 | const r = results[i]; 591 | const el = document.createElement('div'); 592 | el.className = 'search-result-item'; 593 | el.dataset.idx = r.item.idx; 594 | const time = new Date(r.item.timestamp).toLocaleString(); 595 | // Use the original message text/content for snippet (avoid sender/reactions that were added to the index) 596 | const originalMsg = (window.currentChatData && window.currentChatData.messages && window.currentChatData.messages[r.item.idx]) || null; 597 | const rawText = originalMsg ? (originalMsg.text || originalMsg.content || '') : (r.item.text || ''); 598 | const rawSnippet = String(rawText).slice(0, 240); 599 | const highlightedSnippet = highlightText(rawSnippet, q); 600 | el.innerHTML = `
${highlightedSnippet}
${escapeHtml(r.item.sender)} • ${time}
`; 601 | el.addEventListener('click', () => jumpToMessage(r.item.idx)); 602 | frag.appendChild(el); 603 | } 604 | searchResultsEl.appendChild(frag); 605 | 606 | // Also update currently rendered chunks to show highlights for the active query 607 | updateHighlightsAcrossDOM(q); 608 | } 609 | 610 | function escapeHtml(s){ return (s||'').replace(/[&<>"']/g, c=>({ '&':'&','<':'<','>':'>','"':'"',"'":"'" })[c]); } 611 | 612 | // Jump to message index: ensure its chunk is rendered, scroll into view and highlight transiently 613 | async function jumpToMessage(messageIndex) { 614 | const chatContainer = document.getElementById('chat'); 615 | // compute which chunk 616 | const chunkIndex = Math.floor(messageIndex / CHUNK_SIZE); 617 | 618 | // render that chunk synchronously if not yet rendered 619 | const chunkContainer = document.querySelector(`.message-chunk[data-chunk-index="${chunkIndex}"]`); 620 | if (chunkContainer && !renderedMessages.has(chunkIndex)) { 621 | // find data.messages slice 622 | const start = chunkIndex * CHUNK_SIZE; 623 | const msgs = window.currentChatData.messages.slice(start, start + CHUNK_SIZE); 624 | renderChunk(chunkIndex, msgs, document.querySelector('input[name="choice"]:checked').value); 625 | } 626 | 627 | // small timeout to allow DOM update 628 | await new Promise(r => setTimeout(r, 20)); 629 | 630 | // select the message element within the chunk 631 | // messages are appended in order; find the Nth message within earlier chunks 632 | let cumulative = 0; 633 | for (let i = 0; i <= chunkIndex; i++) { 634 | const c = document.querySelector(`.message-chunk[data-chunk-index="${i}"]`); 635 | if (!c) continue; 636 | const count = c.querySelectorAll('.message').length; 637 | cumulative += count; 638 | } 639 | 640 | // Find the global message element by data attribute: we'll mark message elements with data-msg-index when rendering 641 | const msgEl = document.querySelector(`.message[data-msg-index="${messageIndex}"]`); 642 | if (!msgEl) { 643 | // try to search inside chunk by approximate position 644 | const chunk = document.querySelector(`.message-chunk[data-chunk-index="${chunkIndex}"]`); 645 | if (chunk) { 646 | const children = Array.from(chunk.querySelectorAll('.message')); 647 | const localIdx = messageIndex - chunkIndex * CHUNK_SIZE; 648 | const candidate = children[localIdx] || children[Math.max(0, localIdx-1)]; 649 | if (candidate) { 650 | await scrollAndHighlight(candidate); 651 | return; 652 | } 653 | } 654 | return; 655 | } 656 | await scrollAndHighlight(msgEl); 657 | } 658 | 659 | function clearSearch() { 660 | if (searchInput) searchInput.value = ''; 661 | searchResultsEl.innerHTML = ''; 662 | if (searchResultsEl) searchResultsEl.style.display = 'none'; 663 | if (searchProgress) { 664 | searchProgress.querySelector('.fill').style.width = '0%'; 665 | searchProgress.querySelector('.progress-text').innerText = 'Idle'; 666 | searchProgress.style.display = 'none'; 667 | } 668 | updateHighlightsAcrossDOM(''); 669 | } 670 | 671 | // Re-render highlights inside already-rendered message DOM nodes without reconstructing everything 672 | function updateHighlightsAcrossDOM(query) { 673 | // For each rendered .message, find its text node(s) inside .message-content and replace innerHTML accordingly 674 | const msgEls = document.querySelectorAll('.message'); 675 | const q = query || ''; 676 | msgEls.forEach(el => { 677 | // find original text: try to reconstruct from dataset or fallback to current textContent 678 | // We didn't store raw text per element, so safely re-extract from the current DOM but first strip existing 679 | const contentEl = el.querySelector('.message-content'); 680 | if (!contentEl) return; 681 | // Build a plain-text by cloning and removing strong tags 682 | const clone = contentEl.cloneNode(true); 683 | // remove media previews and reactions/timestamp to preserve them 684 | // note: do NOT remove