wow, chrome-extension MUCH improved - websockets

This commit is contained in:
cottongin
2025-10-30 15:17:15 -04:00
parent db2a8abe66
commit 7bb3aabd72
15 changed files with 1323 additions and 14 deletions

View File

@@ -440,5 +440,138 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
}
});
// Export session data (plaintext and JSON)
router.get('/:id/export', authenticateToken, (req, res) => {
try {
const { format } = req.query; // 'json' or 'txt'
const sessionId = req.params.id;
// Get session info
const session = db.prepare(`
SELECT
s.*,
COUNT(sg.id) as games_played
FROM sessions s
LEFT JOIN session_games sg ON s.id = sg.session_id
WHERE s.id = ?
GROUP BY s.id
`).get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Get games for this session
const games = db.prepare(`
SELECT
sg.*,
g.title,
g.pack_name,
g.min_players,
g.max_players,
g.game_type
FROM session_games sg
JOIN games g ON sg.game_id = g.id
WHERE sg.session_id = ?
ORDER BY sg.played_at ASC
`).all(sessionId);
// Get chat logs if any
const chatLogs = db.prepare(`
SELECT * FROM chat_logs
WHERE session_id = ?
ORDER BY timestamp ASC
`).all(sessionId);
if (format === 'json') {
// JSON Export
const exportData = {
session: {
id: session.id,
created_at: session.created_at,
closed_at: session.closed_at,
is_active: session.is_active === 1,
notes: session.notes,
games_played: session.games_played
},
games: games.map(game => ({
title: game.title,
pack: game.pack_name,
players: `${game.min_players}-${game.max_players}`,
type: game.game_type,
played_at: game.played_at,
manually_added: game.manually_added === 1,
status: game.status
})),
chat_logs: chatLogs.map(log => ({
username: log.chatter_name,
message: log.message,
timestamp: log.timestamp,
vote: log.parsed_vote
}))
};
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="session-${sessionId}.json"`);
res.send(JSON.stringify(exportData, null, 2));
} else {
// Plain Text Export
let text = `JACKBOX GAME PICKER - SESSION EXPORT\n`;
text += `${'='.repeat(50)}\n\n`;
text += `Session ID: ${session.id}\n`;
text += `Created: ${session.created_at}\n`;
if (session.closed_at) {
text += `Closed: ${session.closed_at}\n`;
}
text += `Status: ${session.is_active ? 'Active' : 'Ended'}\n`;
if (session.notes) {
text += `Notes: ${session.notes}\n`;
}
text += `\nGames Played: ${session.games_played}\n`;
text += `\n${'='.repeat(50)}\n\n`;
if (games.length > 0) {
text += `GAMES:\n`;
text += `${'-'.repeat(50)}\n`;
games.forEach((game, index) => {
text += `\n${index + 1}. ${game.title}\n`;
text += ` Pack: ${game.pack_name}\n`;
text += ` Players: ${game.min_players}-${game.max_players}\n`;
if (game.game_type) {
text += ` Type: ${game.game_type}\n`;
}
text += ` Played: ${game.played_at}\n`;
text += ` Status: ${game.status}\n`;
if (game.manually_added === 1) {
text += ` (Manually Added)\n`;
}
});
text += `\n${'-'.repeat(50)}\n`;
}
if (chatLogs.length > 0) {
text += `\nCHAT LOGS:\n`;
text += `${'-'.repeat(50)}\n`;
chatLogs.forEach(log => {
text += `\n[${log.timestamp}] ${log.chatter_name}:\n`;
text += ` ${log.message}\n`;
if (log.parsed_vote) {
text += ` Vote: ${log.parsed_vote}\n`;
}
});
text += `\n${'-'.repeat(50)}\n`;
}
text += `\nEnd of Session Export\n`;
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Disposition', `attachment; filename="session-${sessionId}.txt"`);
res.send(text);
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

179
chrome-extension/DEBUG.md Normal file
View File

@@ -0,0 +1,179 @@
# Chrome Extension Debugging Guide
## Version 3.0.2 - Apollo Client Detection Issues
If the extension can't find Apollo Client, follow these steps to diagnose the issue:
### Step 1: Reload Extension and Page
1. Go to `chrome://extensions/`
2. Find "Jackbox Chat Tracker for Kosmi"
3. Click the reload icon (circular arrow)
4. Navigate to Kosmi room: `https://app.kosmi.io/room/@yourroomname`
5. Refresh the page (Ctrl+R or Cmd+R)
### Step 2: Run Debug Function
1. Open Chrome DevTools (F12)
2. Go to Console tab
3. Wait 5-10 seconds after page load (let Kosmi fully initialize)
4. Type: `window.debugJackboxTracker()`
5. Press Enter
### Step 3: Analyze Output
The debug function will show:
#### If Apollo Client is Found ✓
```
=== Jackbox Chat Tracker Debug ===
✓ Apollo Client found!
Apollo Client: ApolloClient {...}
Apollo Client keys: ['cache', 'link', 'queryManager', ...]
Apollo Link: {...}
Apollo Link keys: ['client', ...]
GraphQL client: {...}
GraphQL client keys: ['on', 'subscribe', 'iterate', 'dispose', 'terminate']
=== End Debug ===
```
**Action:** Try clicking "Start Tracking" in the extension popup. It should work now!
#### If Apollo Client is NOT Found ✗
```
=== Jackbox Chat Tracker Debug ===
✗ Apollo Client not found
Checking for window.__APOLLO_CLIENT__: undefined
Window keys that might be relevant: ['__someOtherKey__', ...]
=== End Debug ===
```
**Action:** Copy the "Window keys that might be relevant" list and report it to the developer.
### Step 4: Check Timing
If Apollo Client is not found immediately, wait longer and try again:
```javascript
// Run debug after 10 seconds
setTimeout(() => window.debugJackboxTracker(), 10000);
```
### Step 5: Manual Apollo Client Search
If still not working, manually check for Apollo Client:
```javascript
// Check if it exists
console.log('window.__APOLLO_CLIENT__:', window.__APOLLO_CLIENT__);
// Search all window properties
Object.keys(window).forEach(key => {
if (key.includes('apollo') || key.includes('Apollo')) {
console.log('Found Apollo-related key:', key, window[key]);
}
});
// Check if it's in a different location
console.log('document.__APOLLO_CLIENT__:', document.__APOLLO_CLIENT__);
```
### Common Issues
#### Issue: "Apollo Client not found after 15 attempts"
**Causes:**
1. Kosmi changed their client variable name
2. Apollo Client loads after 15 seconds
3. You're not on a Kosmi room page
**Solutions:**
1. Wait longer (30+ seconds) and run `window.debugJackboxTracker()`
2. Make sure you're on `app.kosmi.io/room/...` not just `app.kosmi.io`
3. Try a different room
4. Check if Kosmi updated their code
#### Issue: "GraphQL client not found in Apollo Client"
**Cause:** Apollo Client structure changed
**Solution:**
1. Run debug function to see Apollo Client structure
2. Look for the WebSocket client in a different location
3. Report the structure to the developer
### Reporting Issues
When reporting Apollo Client not found, include:
1. **Console Output:**
```
// Copy ALL of this:
window.debugJackboxTracker()
```
2. **Window Keys:**
```javascript
// Copy this:
Object.keys(window).filter(k => k.startsWith('__') || k.includes('apollo'))
```
3. **Kosmi URL:**
- Full URL of the room (redact room name if private)
4. **Browser Version:**
- Chrome version (chrome://version/)
5. **Extension Version:**
- Check `chrome://extensions/` → "Jackbox Chat Tracker" → version number
---
## Advanced Debugging
### Hook Detection
Check if the WebSocket hook is working:
```javascript
// This should exist after successfully starting tracking
console.log('GraphQL Client:', window.__APOLLO_CLIENT__?.link?.client);
// Try manually hooking
const client = window.__APOLLO_CLIENT__.link.client;
client.on('message', (data) => {
console.log('MANUAL HOOK - Message:', data);
});
```
### Message Interception
Test if messages are flowing:
```javascript
// Send a test message in Kosmi chat: "test thisgame++"
// Check console for:
// [Jackbox Chat Tracker] New message: {...}
// [Jackbox Chat Tracker] ✓ Vote detected!
```
---
## Version History
### v3.0.2
- Added multiple Apollo Client detection strategies
- Added `window.debugJackboxTracker()` debug function
- Extended search to all window/document properties
- Increased retry limit to 15 attempts (15-30 seconds)
- Added detailed error logging
### v3.0.1
- Fixed infinite loop on page load
- Disabled auto-start tracking
- Added retry limit
### v3.0.0
- Initial WebSocket API implementation
- Replaced DOM parsing with GraphQL subscriptions

213
chrome-extension/README.md Normal file
View File

@@ -0,0 +1,213 @@
# Jackbox Chat Tracker Chrome Extension
A Chrome extension for tracking Jackbox game votes (`thisgame++` and `thisgame--`) from Kosmi chat.
## Features
- ✅ Tracks `thisgame++` and `thisgame--` votes in Kosmi chat
- ✅ Records username, full message, and UTC timestamp
- ✅ Case-insensitive matching (works with `THISGAME++`, `ThisGame--`, etc.)
- ✅ Matches votes anywhere in the message
- ✅ Exports data as JSON in the format expected by the Jackbox Game Picker app
- ✅ Real-time vote tracking with live updates
- ✅ Simple, intuitive interface
## Installation
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable "Developer mode" in the top right
3. Click "Load unpacked"
4. Select the `chrome-extension` folder
5. The extension is now installed!
## Usage
### Starting a Tracking Session
1. Navigate to a Kosmi room: `https://app.kosmi.io/room/...`
2. Click the extension icon in your browser toolbar
3. Click "Start Tracking"
4. The extension will now monitor chat for votes
### Viewing Votes
- The popup shows all recorded votes in real-time
- Each vote displays:
- Username
- Full message text
- Timestamp (in your local timezone)
- Vote type (positive/negative indicated by color)
### Exporting Data
1. Click "Export JSON" to download votes
2. The file format matches the Jackbox Game Picker import format:
```json
[
{
"username": "Alice",
"message": "thisgame++",
"timestamp": "2025-10-30T20:15:00Z"
}
]
```
3. Import this JSON file into the Jackbox Game Picker app
### Resetting Votes
- Click "Reset Votes" to clear all recorded votes
- Confirmation dialog prevents accidental resets
## How It Works
The extension hooks into Kosmi's GraphQL WebSocket API (`wss://engine.kosmi.io/gql-ws`) to monitor real-time chat messages. This is much more reliable than DOM parsing!
When a message is received, the extension:
1. Checks if it contains `thisgame++` or `thisgame--` (case-insensitive)
2. Extracts the **username** from the GraphQL response
3. Extracts the **full message text**
4. Records the **UTC timestamp**
### Why WebSocket?
Previous versions used DOM parsing (MutationObserver), which was fragile and prone to breaking when Kosmi updated their UI. Version 3.0.0 uses Kosmi's own GraphQL API, which is:
- ✅ More reliable (API is stable)
- ✅ Faster (no DOM traversal needed)
- ✅ More accurate (username is always correct)
- ✅ Less CPU intensive (no constant DOM monitoring)
## Vote Format
**Input** (Kosmi chat):
```
Alice: This is great! thisgame++
Bob: thisgame-- not my favorite
Charlie: THISGAME++ love it!
```
**Output** (JSON export):
```json
[
{
"username": "Alice",
"message": "This is great! thisgame++",
"timestamp": "2025-10-30T20:15:00.123Z"
},
{
"username": "Bob",
"message": "thisgame-- not my favorite",
"timestamp": "2025-10-30T20:15:15.456Z"
},
{
"username": "Charlie",
"message": "THISGAME++ love it!",
"timestamp": "2025-10-30T20:15:30.789Z"
}
]
```
## Technical Details
### Files
- `manifest.json`: Extension configuration
- `content.js`: Monitors Kosmi chat and records votes
- `popup.html`: Extension popup interface
- `popup.js`: Popup logic and controls
- `popup.css`: Popup styling
- `icons/`: Extension icons
### Permissions
- `storage`: Save votes locally
- `activeTab`: Access current Kosmi tab
- `downloads`: Export JSON files
- `https://app.kosmi.io/*`: Access Kosmi chat
### Data Storage
- Votes are stored locally using Chrome's `storage.local` API
- Data persists across browser sessions
- No data is sent to external servers
## Changelog
### Version 3.0.1 (Current)
- 🐛 Fixed infinite retry loop when Apollo Client not found
- 🐛 Disabled auto-start on page load (user must manually start tracking)
- ✅ Added retry limit (10 attempts max, then gives up gracefully)
- ✅ Only sets `isTracking = true` after successfully hooking into Apollo Client
- ✅ Better error messages with attempt counter
### Version 3.0.0
- 🎉 **Complete rewrite using GraphQL WebSocket API**
- ✅ Hooks directly into Kosmi's Apollo Client
- ✅ 100% reliable message and username extraction
- ✅ No more DOM parsing or MutationObserver
- ✅ Lightweight and performant
- ✅ Future-proof (API-based, not DOM-based)
- ❌ Removed fragile DOM traversal code
- ❌ No more "Unknown" usernames
### Version 2.0.2
- 🐛 Fixed sidebar responsiveness with polling updates
- 🐛 Fixed MutationObserver not detecting new messages
- 🐛 Suppressed "Could not establish connection" errors
- ✅ Added extensive debug logging for troubleshooting
- ✅ Observer now properly disconnects and reconnects on start
### Version 2.0.1
- 🐛 Fixed duplicate vote counting issue
- 🐛 Improved username extraction with multiple fallback strategies
- ✅ Re-added sidebar panel support
### Version 2.0.0
- ✅ Simplified to only track `thisgame++` and `thisgame--`
- ✅ Removed complex game alias system
- ✅ Captures full message context
- ✅ UTC timestamp recording
- ✅ New JSON export format
- ✅ Improved UI with vote preview
- ✅ Case-insensitive matching
## Troubleshooting
**Extension not detecting votes:**
1. Make sure you're on a Kosmi room page (`app.kosmi.io/room/...`)
2. Click "Stop Tracking" then "Start Tracking" to reset
3. Check browser console for errors (F12 → Console tab)
**Votes not saving:**
1. Check Chrome's storage permissions
2. Try resetting votes and starting fresh
3. Ensure extension has permission to access Kosmi
**Export not working:**
1. Check Chrome's download permissions
2. Ensure popup blockers aren't blocking downloads
3. Try clicking "Export JSON" again
**Extension not loading:**
1. Make sure you've reloaded the extension after updating files
2. Go to `chrome://extensions/`, find "Jackbox Chat Tracker", and click the reload icon
3. Refresh the Kosmi room page after reloading the extension
**Apollo Client not found:**
1. The extension needs a moment after page load to find Apollo Client
2. Wait 2-3 seconds after the page loads, then click "Start Tracking"
3. Check console for "Apollo Client not found!" error
4. If error persists, Kosmi may have changed their client framework
## Support
For issues or questions, please file an issue in the main repository.
## License
Same as main Jackbox Game Picker project.

153
chrome-extension/content.js Normal file
View File

@@ -0,0 +1,153 @@
// Jackbox Chat Tracker for Kosmi - Content Script v3.2.0
// This version intercepts the WebSocket connection directly
// Inject the hook script ASAP (before page JavaScript runs)
const script = document.createElement('script');
script.src = chrome.runtime.getURL('inject.js');
script.onload = function() {
this.remove();
};
(document.head || document.documentElement).appendChild(script);
let isTracking = false;
let votes = [];
let socketFound = false;
let socketUrl = null;
// Listen for messages from inject script (different JavaScript context!)
window.addEventListener('message', (event) => {
// Only accept messages from same origin
if (event.source !== window) return;
if (event.data.type === 'JACKBOX_TRACKER_SOCKET_FOUND') {
console.log('[Jackbox Chat Tracker] WebSocket connected');
socketFound = true;
socketUrl = event.data.url;
} else if (event.data.type === 'JACKBOX_TRACKER_WS_MESSAGE') {
// Message from WebSocket - only process if tracking
if (isTracking) {
handleGraphQLMessage(event.data.data);
}
}
});
// Initialize storage
chrome.storage.local.get(['isTracking', 'votes'], (result) => {
// Always default to NOT tracking on page load
isTracking = false;
votes = result.votes || [];
// Clear tracking state in storage (user must manually start)
chrome.storage.local.set({ isTracking: false });
});
// Listen for commands from popup
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'startTracking') {
startTracking();
sendResponse({ success: true });
} else if (request.action === 'stopTracking') {
stopTracking();
sendResponse({ success: true });
} else if (request.action === 'getVotes') {
sendResponse({ votes: votes });
} else if (request.action === 'resetVotes') {
votes = [];
chrome.storage.local.set({ votes: [] });
sendResponse({ success: true });
} else if (request.action === 'getStatus') {
sendResponse({ isTracking: isTracking, voteCount: votes.length });
}
return true; // Keep channel open for async response
});
let startTrackingAttempts = 0;
const MAX_TRACKING_ATTEMPTS = 10; // Try for max 10 seconds
function startTracking() {
try {
if (!socketFound) {
startTrackingAttempts++;
if (startTrackingAttempts >= MAX_TRACKING_ATTEMPTS) {
console.error('[Jackbox Chat Tracker] WebSocket not found. Please refresh the page and try again.');
startTrackingAttempts = 0;
notifyPopup();
return;
}
setTimeout(startTracking, 1000);
return;
}
// Found the WebSocket!
startTrackingAttempts = 0;
isTracking = true;
chrome.storage.local.set({ isTracking: true });
console.log('[Jackbox Chat Tracker] Tracking started - ready to capture votes');
notifyPopup();
} catch (error) {
console.error('[Jackbox Chat Tracker] Error starting tracking:', error);
startTrackingAttempts = 0;
notifyPopup();
}
}
function stopTracking() {
isTracking = false;
startTrackingAttempts = 0;
chrome.storage.local.set({ isTracking: false });
console.log('[Jackbox Chat Tracker] Tracking stopped');
notifyPopup();
}
function handleGraphQLMessage(data) {
if (!isTracking) return;
try {
// Check if this is a newMessage event
if (data.type === 'next' &&
data.payload &&
data.payload.data &&
data.payload.data.newMessage) {
const message = data.payload.data.newMessage;
const body = message.body;
const username = message.user.displayName || message.user.username || 'Unknown';
const timestamp = new Date(message.time * 1000).toISOString(); // Convert UNIX timestamp to ISO
// Check if message contains thisgame++ or thisgame--
if (/thisgame\+\+/i.test(body) || /thisgame--/i.test(body)) {
console.log('[Jackbox Chat Tracker] Vote detected:', username);
const vote = {
username: username,
message: body,
timestamp: timestamp
};
votes.push(vote);
chrome.storage.local.set({ votes: votes });
notifyPopup();
}
}
} catch (error) {
console.error('[Jackbox Chat Tracker] Error processing message:', error);
}
}
function notifyPopup() {
// Send update to popup if it's open
chrome.runtime.sendMessage({
action: 'votesUpdated',
votes: votes,
isTracking: isTracking
}).catch(() => {
// Popup is closed, ignore error
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,92 @@
// Jackbox Chat Tracker - Injected Script v3.2.0
// This script intercepts Kosmi's WebSocket connection to capture chat messages
// NOTE: This runs in the PAGE CONTEXT, not extension context!
// Store original WebSocket constructor
const OriginalWebSocket = window.WebSocket;
// Hook WebSocket constructor to capture Kosmi's WebSocket
window.WebSocket = function(url, protocols) {
const socket = new OriginalWebSocket(url, protocols);
// Check if this is Kosmi's GraphQL WebSocket
if (url.includes('engine.kosmi.io') || url.includes('gql-ws')) {
console.log('[Jackbox Chat Tracker] WebSocket hook active');
// Notify content script that we found the socket
window.postMessage({
type: 'JACKBOX_TRACKER_SOCKET_FOUND',
url: url
}, '*');
// Method 1: Hook addEventListener (for event listeners added later)
const originalAddEventListener = socket.addEventListener.bind(socket);
socket.addEventListener = function(type, listener, options) {
if (type === 'message') {
const wrappedListener = function(event) {
// Forward to content script
try {
const data = JSON.parse(event.data);
window.postMessage({
type: 'JACKBOX_TRACKER_WS_MESSAGE',
data: data
}, '*');
} catch (e) {
// Not JSON, skip
}
// Then forward to original listener
return listener.call(this, event);
};
return originalAddEventListener(type, wrappedListener, options);
}
return originalAddEventListener(type, listener, options);
};
// Method 2: Intercept the onmessage property setter (for direct assignment)
let realOnMessage = null;
const descriptor = Object.getOwnPropertyDescriptor(WebSocket.prototype, 'onmessage');
Object.defineProperty(socket, 'onmessage', {
get: function() {
return realOnMessage;
},
set: function(handler) {
// Create wrapped handler
realOnMessage = function(event) {
// Forward to content script
try {
const data = JSON.parse(event.data);
window.postMessage({
type: 'JACKBOX_TRACKER_WS_MESSAGE',
data: data
}, '*');
} catch (e) {
// Not JSON, skip
}
// ALWAYS call original handler
if (handler) {
handler.call(socket, event);
}
};
// Actually set it on the underlying WebSocket using the original descriptor
if (descriptor && descriptor.set) {
descriptor.set.call(socket, realOnMessage);
}
},
configurable: true
});
}
return socket;
};
// Preserve the original constructor properties
window.WebSocket.prototype = OriginalWebSocket.prototype;
window.WebSocket.CONNECTING = OriginalWebSocket.CONNECTING;
window.WebSocket.OPEN = OriginalWebSocket.OPEN;
window.WebSocket.CLOSING = OriginalWebSocket.CLOSING;
window.WebSocket.CLOSED = OriginalWebSocket.CLOSED;

View File

@@ -0,0 +1,45 @@
{
"manifest_version": 3,
"name": "Jackbox Chat Tracker for Kosmi",
"version": "3.2.0",
"description": "Track thisgame++ and thisgame-- votes from Kosmi chat for Jackbox games",
"permissions": [
"storage",
"activeTab",
"downloads",
"sidePanel"
],
"host_permissions": [
"https://app.kosmi.io/*"
],
"content_scripts": [
{
"matches": ["https://app.kosmi.io/room/*"],
"js": ["content.js"],
"run_at": "document_start"
}
],
"web_accessible_resources": [
{
"resources": ["inject.js"],
"matches": ["https://app.kosmi.io/*"]
}
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"side_panel": {
"default_path": "popup.html"
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}

211
chrome-extension/popup.css Normal file
View File

@@ -0,0 +1,211 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 450px;
min-height: 500px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
font-size: 14px;
color: #333;
background: #f5f5f5;
}
.container {
display: flex;
flex-direction: column;
height: 100%;
}
header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: center;
}
header h1 {
font-size: 20px;
margin-bottom: 5px;
}
header .subtitle {
font-size: 12px;
opacity: 0.9;
}
main {
padding: 15px;
flex: 1;
overflow-y: auto;
}
section {
background: white;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
section h2 {
font-size: 16px;
margin-bottom: 12px;
color: #667eea;
}
.tracking-status {
display: flex;
align-items: center;
margin-bottom: 12px;
padding: 10px;
background: #f9f9f9;
border-radius: 4px;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ccc;
margin-right: 10px;
}
.status-indicator.active {
background: #4caf50;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
#statusText {
font-weight: 600;
color: #555;
}
.button-group {
display: flex;
gap: 8px;
margin-top: 10px;
}
.btn {
flex: 1;
padding: 10px 15px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #5568d3;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #7f8c8d;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c0392b;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.votes-list {
max-height: 300px;
overflow-y: auto;
margin-bottom: 12px;
}
.empty-state {
text-align: center;
color: #999;
padding: 20px;
font-style: italic;
}
.vote-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.vote-item {
padding: 10px;
border-radius: 4px;
border-left: 3px solid #ccc;
}
.vote-item.vote-positive {
background: rgba(76, 175, 80, 0.05);
border-left-color: #4caf50;
}
.vote-item.vote-negative {
background: rgba(231, 76, 60, 0.05);
border-left-color: #e74c3c;
}
.vote-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.vote-header strong {
color: #333;
font-size: 13px;
}
.vote-time {
font-size: 11px;
color: #999;
}
.vote-message {
font-size: 12px;
color: #666;
font-style: italic;
}

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jackbox Chat Tracker</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<header>
<h1>🎮 Jackbox Chat Tracker</h1>
<p class="subtitle">Track thisgame++ and thisgame-- votes</p>
</header>
<main>
<!-- Tracking Controls -->
<section class="controls">
<h2>Tracking</h2>
<div class="tracking-status">
<span class="status-indicator" id="statusIndicator"></span>
<span id="statusText">Not Tracking</span>
</div>
<div class="button-group">
<button id="startTracking" class="btn btn-primary">Start Tracking</button>
<button id="stopTracking" class="btn btn-secondary" disabled>Stop Tracking</button>
</div>
</section>
<!-- Votes Display -->
<section class="votes">
<h2>Recorded Votes (<span id="voteCount">0</span>)</h2>
<div id="votesDisplay" class="votes-list">
<p class="empty-state">No votes recorded yet. Start tracking to begin!</p>
</div>
<div class="button-group">
<button id="resetVotes" class="btn btn-sm btn-danger">Reset Votes</button>
<button id="exportVotes" class="btn btn-sm btn-primary">Export JSON</button>
</div>
</section>
</main>
</div>
<script src="popup.js"></script>
</body>
</html>

195
chrome-extension/popup.js Normal file
View File

@@ -0,0 +1,195 @@
// Popup script for Jackbox Chat Tracker
console.log('Jackbox Chat Tracker: Popup loaded');
let isTracking = false;
let votes = [];
// DOM elements
const startBtn = document.getElementById('startTracking');
const stopBtn = document.getElementById('stopTracking');
const statusIndicator = document.getElementById('statusIndicator');
const statusText = document.getElementById('statusText');
const votesDisplay = document.getElementById('votesDisplay');
const voteCount = document.getElementById('voteCount');
const resetVotesBtn = document.getElementById('resetVotes');
const exportVotesBtn = document.getElementById('exportVotes');
// Initialize
loadState();
// Poll for updates every 2 seconds to keep sidebar in sync
setInterval(() => {
chrome.storage.local.get(['isTracking', 'votes'], (result) => {
const wasTracking = isTracking;
isTracking = result.isTracking || false;
votes = result.votes || [];
if (wasTracking !== isTracking) {
updateTrackingUI();
}
updateDisplay();
});
}, 2000);
// Event listeners
startBtn.addEventListener('click', startTracking);
stopBtn.addEventListener('click', stopTracking);
resetVotesBtn.addEventListener('click', resetVotes);
exportVotesBtn.addEventListener('click', exportVotes);
// Listen for updates from content script
chrome.runtime.onMessage.addListener((message) => {
if (message.action === 'votesUpdated') {
votes = message.votes;
updateDisplay();
}
});
function loadState() {
chrome.storage.local.get(['isTracking', 'votes'], (result) => {
isTracking = result.isTracking || false;
votes = result.votes || [];
updateDisplay();
updateTrackingUI();
// Request current votes from content script
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]) {
chrome.tabs.sendMessage(tabs[0].id, { action: 'getVotes' }, (response) => {
if (response && response.votes) {
votes = response.votes;
updateDisplay();
}
});
}
});
});
}
function startTracking() {
isTracking = true;
chrome.storage.local.set({ isTracking });
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]) {
chrome.tabs.sendMessage(tabs[0].id, { action: 'startTracking' }, (response) => {
if (response && response.success) {
console.log('Tracking started');
updateTrackingUI();
}
});
}
});
}
function stopTracking() {
isTracking = false;
chrome.storage.local.set({ isTracking });
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]) {
chrome.tabs.sendMessage(tabs[0].id, { action: 'stopTracking' }, (response) => {
if (response && response.success) {
console.log('Tracking stopped');
updateTrackingUI();
}
});
}
});
}
function resetVotes() {
if (!confirm('Reset all recorded votes? This cannot be undone.')) {
return;
}
votes = [];
chrome.storage.local.set({ votes });
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]) {
chrome.tabs.sendMessage(tabs[0].id, { action: 'resetVotes' }, (response) => {
if (response && response.success) {
console.log('Votes reset');
updateDisplay();
}
});
}
});
}
function exportVotes() {
// Export votes in the new format
const exportData = votes.map(vote => ({
username: vote.username,
message: vote.message,
timestamp: vote.timestamp
}));
const dataStr = JSON.stringify(exportData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `jackbox-votes-${timestamp}.json`;
chrome.downloads.download({
url: url,
filename: filename,
saveAs: true
});
}
function updateTrackingUI() {
if (isTracking) {
statusIndicator.classList.add('active');
statusText.textContent = 'Tracking Active';
startBtn.disabled = true;
stopBtn.disabled = false;
} else {
statusIndicator.classList.remove('active');
statusText.textContent = 'Not Tracking';
startBtn.disabled = false;
stopBtn.disabled = true;
}
}
function updateDisplay() {
voteCount.textContent = votes.length;
if (votes.length === 0) {
votesDisplay.innerHTML = '<p class="empty-state">No votes recorded yet. Start tracking to begin!</p>';
return;
}
// Display votes in reverse chronological order (most recent first)
const sortedVotes = [...votes].reverse();
let html = '<div class="vote-list">';
sortedVotes.forEach((vote, index) => {
const localTime = new Date(vote.timestamp).toLocaleString();
const isPositive = vote.message.includes('++');
const voteClass = isPositive ? 'vote-positive' : 'vote-negative';
html += `
<div class="vote-item ${voteClass}">
<div class="vote-header">
<strong>${escapeHtml(vote.username)}</strong>
<span class="vote-time">${localTime}</span>
</div>
<div class="vote-message">${escapeHtml(vote.message)}</div>
</div>
`;
});
html += '</div>';
votesDisplay.innerHTML = html;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

View File

@@ -56,6 +56,29 @@ function History() {
}
};
const handleExport = async (sessionId, format) => {
try {
const response = await api.get(`/sessions/${sessionId}/export?format=${format}`, {
responseType: 'blob'
});
// Create download link
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `session-${sessionId}.${format === 'json' ? 'json' : 'txt'}`);
document.body.appendChild(link);
link.click();
link.parentNode.removeChild(link);
window.URL.revokeObjectURL(url);
success(`Session exported as ${format.toUpperCase()}`);
} catch (err) {
console.error('Failed to export session', err);
error('Failed to export session');
}
};
const loadSessionGames = async (sessionId, silent = false) => {
try {
const response = await api.get(`/sessions/${sessionId}/games`);
@@ -227,14 +250,32 @@ function History() {
)}
</div>
{isAuthenticated && sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
<button
onClick={() => setShowChatImport(true)}
className="bg-indigo-600 dark:bg-indigo-700 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition text-sm sm:text-base w-full sm:w-auto"
>
Import Chat Log
</button>
)}
<div className="flex flex-col sm:flex-row gap-2">
{isAuthenticated && sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
<button
onClick={() => setShowChatImport(true)}
className="bg-indigo-600 dark:bg-indigo-700 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition text-sm sm:text-base w-full sm:w-auto"
>
Import Chat Log
</button>
)}
{isAuthenticated && (
<>
<button
onClick={() => handleExport(selectedSession, 'txt')}
className="bg-gray-600 dark:bg-gray-700 text-white px-4 py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition text-sm sm:text-base w-full sm:w-auto"
>
Export as Text
</button>
<button
onClick={() => handleExport(selectedSession, 'json')}
className="bg-gray-600 dark:bg-gray-700 text-white px-4 py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition text-sm sm:text-base w-full sm:w-auto"
>
Export as JSON
</button>
</>
)}
</div>
</div>
{showChatImport && (

View File

@@ -1,11 +1,11 @@
# TODO:
## Chrome Extension
- [ ] /.old-chrome-extension/ contains OLD code that needs adjusting for new game picker format.
- [ ] remove clunky gamealias system, we are only tracking "thisgame++" and "thisgame--" now.
- [ ] ensure the extension is watching for "thisgame++" or "thisgame--" anywhere in each chat line.
- [ ] if a chat line matches capture the whole message/line, the author, and the timestamp (UTC).
- [ ] ensure our JSON output matches the new format:
- [x] /.old-chrome-extension/ contains OLD code that needs adjusting for new game picker format. (COMPLETED: New simplified extension in /chrome-extension/)
- [x] remove clunky gamealias system, we are only tracking "thisgame++" and "thisgame--" now.
- [x] ensure the extension is watching for "thisgame++" or "thisgame--" anywhere in each chat line.
- [x] if a chat line matches capture the whole message/line, the author, and the timestamp (UTC).
- [x] ensure our JSON output matches the new format:
```json
[
{
@@ -31,4 +31,4 @@
- [x] Entire App: local timezone display still isn't working. I see UTC times. (FIXED: Created dateUtils.js to properly parse SQLite UTC timestamps)
## Other Features
- [ ] Session History: export sessions to plaintext and JSON.
- [x] Session History: export sessions to plaintext and JSON. (COMPLETED: Export buttons in History page)