I decided I wanted to make some changes to my Facebook profile. Specifically, I wanted to remove all of the old posts, especially those which I may not stand by still today. I was a kid once and kids say silly, embarrassing things, including announcing their "rediscovered love for Oasis" (why was this a post).
Naturally, I started by investigating Facebook's own tools (lack lustre and full of manual labour). Then I checked out some extensions which offered the precise functionality I was looking for but... Required pricey subscriptions to delete more than so many posts.
So, I gave up and opened up Claude. Then I low-key vibe-coded for the next hour π
Here's my very polished Chrome extension...
Screenshot extension at work.
Chrome extensions are made up of a handful of requisite files...
manifest.json
{ "manifest_version": 3, "name": "Facebook Activity Log Trasher", "version": "1.0", "description": "Manage Facebook activity history via GraphQL", "permissions": [ "cookies", "storage", "scripting" ], "host_permissions": [ "*://*.facebook.com/*" ], "action": { "default_popup": "popup.html" }, "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["*://*.facebook.com/*"], "js": ["content.js"], "run_at": "document_start" } ] }
popup.html
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <style> body { width: 400px; padding: 15px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } h2 { margin-top: 0; font-size: 16px; } .section { margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #ddd; } input, select { width: 100%; padding: 8px; margin: 5px 0; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; } button { width: 100%; padding: 10px; margin: 5px 0; background: #1877f2; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; } button:hover { background: #166fe5; } button:disabled { background: #ccc; cursor: not-allowed; } .danger { background: #dc3545; } .danger:hover { background: #c82333; } #status { margin-top: 10px; padding: 10px; border-radius: 4px; font-size: 13px; max-height: 200px; overflow-y: auto; } .success { background: #d4edda; color: #155724; } .error { background: #f8d7da; color: #721c24; } .info { background: #d1ecf1; color: #0c5460; } #postsList { max-height: 300px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; padding: 10px; margin: 10px 0; } .post-item { padding: 8px; margin: 5px 0; background: #f8f9fa; border-radius: 4px; font-size: 12px; } .post-item input[type="checkbox"] { width: auto; margin-right: 8px; } label { font-size: 13px; font-weight: 500; display: block; margin-top: 8px; } </style> </head> <body> <h2>Facebook Activity Manager</h2> <div class="section"> <button id="testConnection">Test Connection</button> </div> <div class="section"> <label>Year (optional):</label> <input type="number" id="year" placeholder="e.g., 2009" min="2004" max="2025"> <label>Posts per page:</label> <select id="count"> <option value="25">25</option> <option value="50">50</option> <option value="100">100</option> </select> <button id="loadPosts">Load Posts</button> <button id="loadMore" disabled>Load More</button> </div> <div id="postsList" style="display: none;"></div> <div class="section"> <button id="selectAll" disabled>Select All</button> <button id="deselectAll" disabled>Deselect All</button> <button id="deleteSelected" class="danger" disabled>Delete Selected Posts</button> </div> <div class="section"> <button id="loadAllPosts" disabled>Load All Posts (Auto-load until done)</button> </div> <div id="status"></div> <script src="popup.js"></script> </body> </html>
popup.js
// popup.js - Handles the extension popup UI interactions const statusDiv = document.getElementById('status'); const postsListDiv = document.getElementById('postsList'); let loadedPosts = []; let currentCursor = null; let isAutoLoading = false; function showStatus(message, type = 'info') { statusDiv.textContent = message; statusDiv.className = type; } // Helper function to send messages with error handling async function sendMessageToTab(tabId, message) { try { // Try sending the message directly first const response = await chrome.tabs.sendMessage(tabId, message); return response; } catch (error) { // If that fails, try injecting the content script try { await chrome.scripting.executeScript({ target: { tabId: tabId }, files: ['content.js'] }); // Wait a bit for the script to initialize await new Promise(resolve => setTimeout(resolve, 200)); // Try sending the message again const response = await chrome.tabs.sendMessage(tabId, message); return response; } catch (retryError) { throw new Error(`Failed to communicate with page. Try refreshing the Facebook tab.`); } } } // Extract story IDs from the GraphQL response function extractPosts(response) { try { console.log('Extracting posts from response:', response); // Navigate the response structure - posts are at data.node.activity_log_stories.edges const edges = response?.data?.node?.activity_log_stories?.edges || []; console.log('Found edges:', edges.length); const posts = edges.map(edge => ({ storyId: edge.node?.id, text: edge.node?.message?.text || edge.node?.title?.text || 'No text', timestamp: edge.node?.creation_time })); console.log('Extracted posts:', posts); return posts; } catch (error) { console.error('Error extracting posts:', error); return []; } } // Extract pagination cursor from response function extractCursor(response) { try { return response?.data?.node?.activity_log_stories?.page_info?.end_cursor || null; } catch (error) { return null; } } // Display posts in the UI function displayPosts(posts) { if (posts.length === 0) { postsListDiv.innerHTML = '<p>No posts found</p>'; postsListDiv.style.display = 'block'; return; } postsListDiv.innerHTML = posts.map((post, index) => ` <div class="post-item"> <input type="checkbox" id="post-${index}" data-story-id="${post.storyId}"> <label for="post-${index}" style="display: inline;"> ${post.text.substring(0, 100)}${post.text.length > 100 ? '...' : ''} ${post.timestamp ? `(${new Date(post.timestamp * 1000).toLocaleDateString()})` : ''} </label> </div> `).join(''); postsListDiv.style.display = 'block'; document.getElementById('deleteSelected').disabled = false; document.getElementById('selectAll').disabled = false; document.getElementById('deselectAll').disabled = false; } // Test if we can get the required tokens from Facebook document.getElementById('testConnection').addEventListener('click', async () => { showStatus('Testing connection...', 'info'); try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab.url.includes('facebook.com')) { showStatus('Please navigate to Facebook first', 'error'); return; } const response = await sendMessageToTab(tab.id, { action: 'getTokens' }); if (response.success) { showStatus(`β Connected! User ID: ${response.tokens.userId}`, 'success'); } else { showStatus('β Could not find auth tokens. Make sure you\'re logged in.', 'error'); } } catch (error) { showStatus(`Error: ${error.message}`, 'error'); } }); // Load posts document.getElementById('loadPosts').addEventListener('click', async () => { showStatus('Loading posts...', 'info'); currentCursor = null; try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab.url.includes('facebook.com')) { showStatus('Please navigate to Facebook first', 'error'); return; } const year = document.getElementById('year').value || null; const count = parseInt(document.getElementById('count').value); const response = await sendMessageToTab(tab.id, { action: 'loadPosts', year: year ? parseInt(year) : null, count: count, cursor: null }); if (response.success) { loadedPosts = extractPosts(response.result); currentCursor = extractCursor(response.result); displayPosts(loadedPosts); showStatus(`β Loaded ${loadedPosts.length} posts`, 'success'); document.getElementById('loadMore').disabled = !currentCursor; document.getElementById('loadAllPosts').disabled = !currentCursor; } else { showStatus(`β Failed: ${response.error}`, 'error'); } } catch (error) { showStatus(`Error: ${error.message}`, 'error'); } }); // Load more posts (pagination) document.getElementById('loadMore').addEventListener('click', async () => { if (!currentCursor) return; showStatus('Loading more posts...', 'info'); try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const year = document.getElementById('year').value || null; const count = parseInt(document.getElementById('count').value); const response = await sendMessageToTab(tab.id, { action: 'loadPosts', year: year ? parseInt(year) : null, count: count, cursor: currentCursor }); if (response.success) { const newPosts = extractPosts(response.result); loadedPosts = [...loadedPosts, ...newPosts]; currentCursor = extractCursor(response.result); displayPosts(loadedPosts); showStatus(`β Loaded ${loadedPosts.length} total posts`, 'success'); document.getElementById('loadMore').disabled = !currentCursor; document.getElementById('loadAllPosts').disabled = !currentCursor; } else { showStatus(`β Failed: ${response.error}`, 'error'); } } catch (error) { showStatus(`Error: ${error.message}`, 'error'); } }); // Delete selected posts document.getElementById('deleteSelected').addEventListener('click', async () => { const checkboxes = postsListDiv.querySelectorAll('input[type="checkbox"]:checked'); const storyIds = Array.from(checkboxes).map(cb => cb.dataset.storyId); if (storyIds.length === 0) { showStatus('No posts selected', 'error'); return; } if (!confirm(`Are you sure you want to delete ${storyIds.length} post(s)?`)) { return; } try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); // Facebook allows max 250 posts per delete request, so batch them const BATCH_SIZE = 250; const batches = []; for (let i = 0; i < storyIds.length; i += BATCH_SIZE) { batches.push(storyIds.slice(i, i + BATCH_SIZE)); } let totalDeleted = 0; for (let i = 0; i < batches.length; i++) { const batch = batches[i]; showStatus(`Deleting batch ${i + 1}/${batches.length} (${batch.length} posts)...`, 'info'); const response = await sendMessageToTab(tab.id, { action: 'deleteActivity', storyIds: batch, categoryKey: 'MANAGEPOSTSPHOTOSANDVIDEOS' }); if (response.success) { totalDeleted += batch.length; // Small delay between batches to avoid rate limiting if (i < batches.length - 1) { await new Promise(resolve => setTimeout(resolve, 500)); } } else { showStatus(`β Failed at batch ${i + 1}: ${response.error}`, 'error'); break; } } if (totalDeleted > 0) { showStatus(`β Successfully deleted ${totalDeleted} post(s)`, 'success'); // Remove deleted posts from the display loadedPosts = loadedPosts.filter(post => !storyIds.includes(post.storyId)); displayPosts(loadedPosts); } } catch (error) { showStatus(`Error: ${error.message}`, 'error'); } }); // Select all posts document.getElementById('selectAll').addEventListener('click', () => { const checkboxes = postsListDiv.querySelectorAll('input[type="checkbox"]'); checkboxes.forEach(cb => cb.checked = true); showStatus(`Selected ${checkboxes.length} posts`, 'info'); }); // Deselect all posts document.getElementById('deselectAll').addEventListener('click', () => { const checkboxes = postsListDiv.querySelectorAll('input[type="checkbox"]'); checkboxes.forEach(cb => cb.checked = false); showStatus('Deselected all posts', 'info'); }); // Load all posts automatically document.getElementById('loadAllPosts').addEventListener('click', async () => { if (!currentCursor || isAutoLoading) return; isAutoLoading = true; document.getElementById('loadAllPosts').disabled = true; document.getElementById('loadPosts').disabled = true; document.getElementById('loadMore').disabled = true; const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const year = document.getElementById('year').value || null; const count = parseInt(document.getElementById('count').value); let batchCount = 0; let errorCount = 0; const MAX_ERRORS = 3; // Stop after 3 consecutive errors try { while (currentCursor && isAutoLoading && errorCount < MAX_ERRORS) { batchCount++; showStatus(`Auto-loading batch ${batchCount}... (${loadedPosts.length} posts so far)`, 'info'); try { const response = await sendMessageToTab(tab.id, { action: 'loadPosts', year: year ? parseInt(year) : null, count: count, cursor: currentCursor }); if (response.success) { const newPosts = extractPosts(response.result); // Reset error count on success errorCount = 0; // Stop if we got fewer posts than requested (reached the end) if (newPosts.length < count) { loadedPosts = [...loadedPosts, ...newPosts]; currentCursor = null; displayPosts(loadedPosts); showStatus(`β Finished! Loaded all ${loadedPosts.length} posts`, 'success'); break; } loadedPosts = [...loadedPosts, ...newPosts]; currentCursor = extractCursor(response.result); displayPosts(loadedPosts); // Small delay to avoid rate limiting await new Promise(resolve => setTimeout(resolve, 500)); } else { errorCount++; console.error(`Batch ${batchCount} failed:`, response.error); if (errorCount >= MAX_ERRORS) { showStatus(`β Stopped after ${MAX_ERRORS} errors. Loaded ${loadedPosts.length} posts. Error: ${response.error}`, 'error'); } else { showStatus(`β Error in batch ${batchCount}, retrying... (${errorCount}/${MAX_ERRORS})`, 'info'); await new Promise(resolve => setTimeout(resolve, 1000)); } } } catch (error) { errorCount++; console.error(`Batch ${batchCount} exception:`, error); if (errorCount >= MAX_ERRORS) { showStatus(`β Stopped after ${MAX_ERRORS} errors. Loaded ${loadedPosts.length} posts. You can still use these posts.`, 'error'); } else { showStatus(`β Error in batch ${batchCount}, retrying... (${errorCount}/${MAX_ERRORS})`, 'info'); await new Promise(resolve => setTimeout(resolve, 1000)); } } } } finally { isAutoLoading = false; document.getElementById('loadPosts').disabled = false; document.getElementById('loadMore').disabled = !currentCursor; document.getElementById('loadAllPosts').disabled = !currentCursor; // Make sure UI is updated with whatever we got if (loadedPosts.length > 0) { displayPosts(loadedPosts); } } });
content.js
// content.js - Runs in the context of Facebook pages // Wait for page to be ready function waitForPageReady() { return new Promise((resolve) => { if (document.readyState === 'complete') { resolve(); } else { window.addEventListener('load', resolve); } }); } // Extract authentication tokens from the page function getAuthTokens() { try { // Method 1: Try to get DTSG from form input let dtsgToken = document.querySelector('[name="fb_dtsg"]')?.value; // Method 2: Try to get from JavaScript global variables if (!dtsgToken && typeof window !== 'undefined') { // Facebook often stores tokens in require/define modules const scripts = document.querySelectorAll('script'); for (const script of scripts) { const text = script.textContent || ''; // Look for DTSGInitialData const dtsgMatch = text.match(/"DTSGInitialData"[^}]*"token":"([^"]+)"/); if (dtsgMatch) { dtsgToken = dtsgMatch[1]; break; } // Alternative pattern const dtsgMatch2 = text.match(/\["DTSGInitialData"[^\]]*\]\s*,\s*\[.*?"token":"([^"]+)"/); if (dtsgMatch2) { dtsgToken = dtsgMatch2[1]; break; } } } // Method 3: Try document.cookie for DTSG if (!dtsgToken) { const cookieMatch = document.cookie.match(/fb_dtsg=([^;]+)/); if (cookieMatch) { dtsgToken = decodeURIComponent(cookieMatch[1]); } } // Get LSD token let lsdToken = document.querySelector('[name="lsd"]')?.value; if (!lsdToken) { const scripts = document.querySelectorAll('script'); for (const script of scripts) { const text = script.textContent || ''; const lsdMatch = text.match(/"LSD"[^}]*"token":"([^"]+)"/); if (lsdMatch) { lsdToken = lsdMatch[1]; break; } } } // Get User ID from cookie const userId = document.cookie.match(/c_user=(\d+)/)?.[1]; // Get jazoest token let jazoest = document.querySelector('[name="jazoest"]')?.value; if (!jazoest) { const scripts = document.querySelectorAll('script'); for (const script of scripts) { const text = script.textContent || ''; const jazoestMatch = text.match(/jazoest["\s]*[:=]["\s]*(\d+)/); if (jazoestMatch) { jazoest = jazoestMatch[1]; break; } } } // Get __rev, __spin_r, __spin_b, __spin_t from page HTML let rev, spinR, spinB, spinT, hsi, dyn, csr; const pageHtml = document.documentElement.innerHTML; const revMatch = pageHtml.match(/"__rev":(\d+)/); if (revMatch) rev = revMatch[1]; const spinRMatch = pageHtml.match(/"__spin_r":(\d+)/); if (spinRMatch) spinR = spinRMatch[1]; const spinBMatch = pageHtml.match(/"__spin_b":"([^"]+)"/); if (spinBMatch) spinB = spinBMatch[1]; const spinTMatch = pageHtml.match(/"__spin_t":(\d+)/); if (spinTMatch) spinT = spinTMatch[1]; const hsiMatch = pageHtml.match(/"hsi":"(\d+)"/); if (hsiMatch) hsi = hsiMatch[1]; const dynMatch = pageHtml.match(/"__dyn":"([^"]+)"/); if (dynMatch) dyn = dynMatch[1]; const csrMatch = pageHtml.match(/"__csr":"([^"]+)"/); if (csrMatch) csr = csrMatch[1]; console.log('Token extraction:', { hasDtsg: !!dtsgToken, hasLsd: !!lsdToken, hasUserId: !!userId, hasJazoest: !!jazoest, hasRev: !!rev }); return { dtsg: dtsgToken, lsd: lsdToken, userId: userId, jazoest: jazoest, rev: rev, spinR: spinR, spinB: spinB, spinT: spinT, hsi: hsi, dyn: dyn, csr: csr }; } catch (error) { console.error('Error extracting tokens:', error); return null; } } // Make a GraphQL request to Facebook async function makeGraphQLRequest(docId, variables, tokens, friendlyName) { try { console.log('makeGraphQLRequest called with:', { docId, friendlyName, variables }); // Start with your working request payload as a template const basePayload = 'BASEPAYLOAD'; // sanitised - copied from a working request performed by Facebook proper // Parse the base payload const formData = new URLSearchParams(basePayload); // Override with actual user-specific values formData.set('fb_dtsg', tokens.dtsg); formData.set('lsd', tokens.lsd); formData.set('av', tokens.userId); formData.set('__user', tokens.userId); // Set the GraphQL-specific parameters formData.set('fb_api_req_friendly_name', friendlyName); formData.set('variables', JSON.stringify(variables)); formData.set('doc_id', docId); console.log('Sending fetch request...'); const response = await fetch('https://www.facebook.com/api/graphql/', { method: 'POST', body: formData, credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded', } }); console.log('Response status:', response.status); const text = await response.text(); console.log('Response text (first 500 chars):', text.substring(0, 500)); // Facebook sometimes returns text with for(;;); prefix const cleanText = text.split(/\n/)[0].replace(/^for\s*\(\s*;\s*;\s*\)\s*;\s*/, ''); const result = JSON.parse(cleanText); return result; } catch (error) { console.error('GraphQL request failed:', error); throw error; } } // Listen for messages from the popup chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'getTokens') { (async () => { await waitForPageReady(); // Give it a bit more time for React to inject tokens await new Promise(resolve => setTimeout(resolve, 500)); const tokens = getAuthTokens(); sendResponse({ success: tokens && tokens.dtsg !== undefined, tokens: tokens }); })(); return true; } if (request.action === 'deleteActivity') { (async () => { try { const tokens = getAuthTokens(); if (!tokens || !tokens.dtsg) { sendResponse({ success: false, error: 'No auth tokens found' }); return; } console.log('Deleting posts with tokens:', { hasDtsg: !!tokens.dtsg, hasLsd: !!tokens.lsd, userId: tokens.userId, storyCount: request.storyIds?.length }); // Delete activity mutation - different doc_id than loading posts const docId = 'DOCIDHERE'; // sanitised const friendlyName = 'CometActivityLogBulkActionMutation'; const variables = { input: { action: 'MOVE_TO_TRASH', category_key: request.categoryKey || 'MANAGEPOSTSPHOTOSANDVIDEOS', render_location: 'ACTIVITY_LOG', story_ids: request.storyIds || [], transaction_id: `${Date.now()}_batch_0`, actor_id: tokens.userId, client_mutation_id: '1' } }; console.log('Making delete GraphQL request with variables:', variables); const result = await makeGraphQLRequest(docId, variables, tokens, friendlyName); console.log('Delete GraphQL response:', result); sendResponse({ success: true, result: result }); } catch (error) { console.error('Error in deleteActivity:', error); sendResponse({ success: false, error: error.message }); } })(); return true; } if (request.action === 'loadPosts') { (async () => { try { const tokens = getAuthTokens(); if (!tokens || !tokens.dtsg) { sendResponse({ success: false, error: 'No auth tokens found' }); return; } console.log('Loading posts with tokens:', { hasDtsg: !!tokens.dtsg, hasLsd: !!tokens.lsd, userId: tokens.userId }); // Use the pagination doc_id and friendly name from your example const docId = 'DOCIDHERE'; // sanitised const friendlyName = 'CometActivityLogStoriesListPaginationQuery'; // Match Facebook's exact variable structure const variables = { audience: null, category: request.category || 'MANAGEPOSTSPHOTOSANDVIDEOS', category_key: request.categoryKey || 'MANAGEPOSTSPHOTOSANDVIDEOS', count: request.count || 25, cursor: request.cursor || null, feedLocation: null, media_content_filters: [], month: request.month || null, person_id: null, privacy: 'NONE', scale: 1, timeline_visibility: 'ALL', year: request.year || null, id: tokens.userId }; console.log('Making GraphQL request with variables:', variables); const result = await makeGraphQLRequest(docId, variables, tokens, friendlyName); console.log('GraphQL response:', result); sendResponse({ success: true, result: result }); } catch (error) { console.error('Error in loadPosts:', error); sendResponse({ success: false, error: error.message }); } })(); return true; } });
background.js
// background.js - Service worker for background tasks // Listen for installation chrome.runtime.onInstalled.addListener(() => { console.log('Facebook Activity Manager installed'); }); // You can add additional background functionality here // For example, scheduling tasks, managing storage, etc. // Example: Listen for messages from content script or popup chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { // Handle background tasks here if needed return true; });
Spending a little bit of time updating my portfolio (this site) and just generally enjoying the things I've worked so hard on. π
This is a common UX pattern in web development: you display a modal or overlay, and you need to stop the background content from scrolling. Most of us solve this the same way β set overflow: hidden on the html or body, either directly or via a class.
It works, but itβs not elegant. You might have to deal with layout shift from the missing scrollbar, or patch it up with a bit of padding-right. On mobile, you might hit weird edge cases with touch scrolling.
Youβd think by now the platform would offer a cleaner way to do this. <dialog> kind of does, but itβs not flexible enough for most use cases. A few libraries try to smooth over the rough edges, but theyβre still using the same core trick under the hood.
So for now, the best solution is still the one weβve been using all along: toggle overflow and move on. πͺ
The Braytech project pays my rent and evolves daily. No matter how often I try to update the project item in my portfolio, it always falls out of currency. It's difficult to even justifty spending time updating it when you could instead be using that time to grow the project that pays the bills! It's very cyclical.
Functions for 2023
Immutability
- Array.prototype.toSpliced()
- Array.prototype.toSorted()
- Array.prototype.toReversed()
- Array.prototype.with()
The above methods look like existing methods we've had for decades but with one key difference: they return new copies of arrays. This has various implications, especially if you're using a framework like React.
Previously, to avoid mutating an original, you'd first make a copy the array by using from(), slice(), or the spread operator. Now you can do it on the fly.
with() is a cool new trick that allows you to alter an item in an array at specific index while returning the entire array without mutating the original.
const fruits = ['π', 'π', 'π', 'π', 'π', 'π']; console.log( fruits.with(2, 'π') ); // ['π', 'π', 'π', 'π', 'π', 'π']
Coming Soon: Object.groupBy()
The Object.groupBy() static method groups the elements of a given iterable according to the string values returned by a provided callback function. The returned object has separate properties for each group, containing arrays with the elements in the group.
Object.groupBy() - Javascript | MDN
Whatever you say, MDN technical writers! Basically, you can group an array without using a library like Lodash and without writing a complex reduce() function.
const sports = [ { name: 'Rugby Union', contact: 'full' }, { name: 'Tennis', contact: 'noncontact' }, { name: 'American Football', contact: 'full' }, { name: 'Wrestling', contact: 'full' }, { name: 'AFL', contact: 'semi' }, { name: 'Sailing', contact: 'noncontact' }, ]; // using a reduce() function console.log( sports.reduce((groups, sport) => { if (groups[sport.contact] !== undefined) { groups[sport.contact].push(sport); } else { groups[sport.contact] = [sport]; } return groups; }, {}) ); // using the new Object.groupBy() method console.log( Object.groupBy(sports, (sport) => sport.contact) ); // both functions return... const output = { "full": [ { "name": "Rugby Union", "contact": "full" }, { "name": "American Football", "contact": "full" }, { "name": "Wrestling", "contact": "full" } ], "noncontact": [ { "name": "Tennis", "contact": "noncontact" }, { "name": "Sailing", "contact": "noncontact" } ], "semi": [ { "name": "AFL", "contact": "semi" } ] }
Today, I learnt how I could have multiple scripts messaging eachother in a Node.js environment. It's very similar in principle to recent modern additions to the web standard such as web workers.
// File: parent.js import { fork } from 'child_process'; const child = fork('child.js'); child .on('message', function (message) { console.log('Message from child:', message); }) .on('exit', function (code, signal) { console.log(`Child process exited with code ${code} and signal ${signal}`); }) .on('error', function (error) { console.log(error); }); child.send('Message from parent');
// File: child.js process.on('message', function (message) { process.send('Message from parent:', message); });
All of my projects live in DigitalOcean Droplets, somewhere in some kind of internets cloud thing. Deploying to these Droplets has meant a lot of SFTP action... Until GitHub Actions showed me a better way.
I had a less-than-positive (bad) experience with Netlify which led them to erroneously charging my money card and their support was bad. It really burnt me.
In protest, I built my own private Netlify clone, but running bash scripts from Node is just not that fun.
Enter GitHub actions: you can create highly customisable workflows with various triggers that perform various actions which you may create yourself or import from other GitHub committers.
- The following action is fired on git push to the master branch.
- It spins up Ubuntu
- Check outs the latest commit
- Installs Node.js
- Runs
npm install - Runs
npm run-script build - Transfers the built React app to the specified Droplet (or server of your nomination) to be served by NGINX
name: Deploy create-react-app to DigitalOcean Droplet with SCP on: push: branches: [ master ] jobs: deploy: name: Deploy runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: submodules: 'recursive' - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '16.x' - name: Install dependencies run: npm install - name: Build run: npm run-script build env: CI: false - name: Deploy uses: appleboy/scp-action@master with: host: ${{ secrets.DROPLET_HOST }} username: ${{ secrets.DROPLET_USERNAME }} password: ${{ secrets.DROPLET_PASSWORD }} source: 'build/' target: '${{ secrets.DEPLOY_TARGET }}' strip_components: 1
I recently added Suspense, a feature of React, to one of my projects. I learnt the following;
- It's awesome and works effortlessly
- You need error boundaries
- It works great with react-router but you do need to mind how you configure it
- Error boundaries will make the sub-routes inaccessible when an error is thrown. Instead of wrapping multiple routes, it's better to wrap components on a route-by-route basis instead
- If you do use an error boundary component as a child of a router <Switch /> component, you may find that the switch's behaviour no longer works as expected
So how do I use it all exactly? Well, that's easy, I created my own little higher-order component. Florals? For spring? Groundbreaking.
In place of a regular <Route /> component, I use this this <SuspenseRoute />. Pass all the usual props along and they get handed down with some extra magic thrown into the mix!
const SuspenseRoute = ({ component: Component, ...rest }) => ( <Route {...rest} render={route => ( <ErrorBoundary> <Suspense fallback={<SuspenseLoading />}> <Component {...route} /> </Suspense> </ErrorBoundary> )} /> );
Look, it's been a time. A lot of the things have done happened. Today, I'd like to focus on a single event.
So, I use a headless content management system called Directus which I host from a DigitalOcean Droplet.
I decided that I wanted to update it to the latest version, but I didn't realise there'd be so many breaking changes (they weren't communicated well). Needless to say, I ended up in a position where I needed to remove some directories and their contents.
Now, rm -rf is not the bad guy, but I forgot to backup all of the uploaded files associated with the hosted web sites. I deleted everything.
This isn't the first time it's happened. Last time, I had no backups to lean on as I hadn't enabled them.
To my delight, I opted to enable backups for my primary Droplet, after the last disaster, for a small fee. Backups are made weekly and retained for a few weeks before being deleted. These backups can be instantly converted to snapshots. With a snapshot, one can spin up an additional Droplet, based upon it.
Okay, cool, I have the files, but they're on the wrong server. As a programmer, I have a professional web search certification, which enabled me to divine the solution (Googled it): scp -r /path/to/my/files [email protected]:/path/on/remote/droplet.
scp is linux utility, whose name apparently stands for secure copy. It's based on SSH and was a delight to use.
So in summary, I really appreciate the Recycle Bin β it's an important aspect of my workflow.
"Post your setups" is an activity where people post an image of where they get whatever it is they're doing done. It's popular amongst gaming communities. Seems to be becoming a thing for programmers, too.
It was allegedly popular on Twitter.
This is my desk.
In 2019, JavaScript has some amazing and readily accessible built-in functions. Learning React has been one thing, but adding various built-in JavaScript functions to my repertoire has beenβarguablyβeven more rewarding.
Functions such as Array.map, Array.filter, and Array.reduce, can make code so much more straight forward. Of all the tools I've equipped, I've found Array.reduce to be the most confounding. I'd heard of accumulation before, but I'd never heard of an "accumulator" (thanks, MDN Documentation). Of the lot, it's probably the most powerful.
Below is a mostly real example of a situation where I needed to build a quick set of item filter links based on an array of objects returned from an API.
const products = props.productsFromAnAPI; // array of objects with a 'type' property const productTypes = [{ type: 'all' }] // add an artificial product item to the start of the .concat(products) // products array, as real data won't have an 'all' type .reduce((accumulator, currentValue) => { if (!accumulator.includes(currentValue.type)) { // check the previously accumlated items and only add return [ // to the array if it's not yet included ...accumulator, currentValue.type // β οΈ performance tip: this example returns a new array ]; // with every itteration - for large arrays, consider accumulator.push() } else { return accumulator; // item included already, return the array as is } }, []) // initialise with an empty array .map((type, t) => { return ( <li key={t}> <NavLink to={`/store/filter/${type}`}> {type} </NavLink> </li> ); });
One Thousand Voices is a piece of highly sought-after equipment found through successful completion of one of Destiny's co-operative and most challenging activities, the Last Wish raid.
I've been having some fun learning about and experimenting with 3DS Max, materials (sans textures), and rendering.
The final incarnation of my effort sporting a deliciously soft depth of field with all of the bokeh.
Calculating depth of field for this kind of render requires an exahusative number of CPU cycles but it's totally worth it, especailly if you aren't a surface modeller, don't have a high-poly model, don't know what you're doing, or if you aren't adding textures. ?
It's super forgiving for when you have a lot of missing details or some crazy edges.
Depth of field effects can help take a render from "eh" to "yeah!"
Bungie's Destiny API includes an endpoint for 3D model data as it's used by the official Destiny 2 Companion app. Unfortunately, it's not disseminated in an easy to consume format, such as GLTF. It's filled with proprietary shenanigans and left to the third party developer to discover how they can wield it.
A starting point, courtesy of the beautiful minds at Bungie via the third party developer API.
I haven't built many React-based projects yet, especially projects where size and performance become significant concerns... Until Braytech. It's in the Archiveβcheck it out!
Lots of components, lots of HOCs, lots of asynchronous activity.
Braytech is one of my test beds, one of the projects I lean on for learning React and new principles. So there's occasions where I haven't looked at code I wrote when I first started the project and as well all know, past self is dumb. π€ͺ
I've refined Braytech a lot over the past few months. I've re-designed and re-written a lot of code, usually for the better. As I've done so, I've found myself increasingly considering performance of these components, and what's the better way to approach solving a problem. The usual self-improvement stuff, you could say.
Passing down unnecessary props
Despite the fact I've understood the effects of spreading props for a long time, I've only just recently considered how spreading unnecessary props and state could adversely affect performance by triggering needless reconciliations.
Accordingly, the past few weeks I've been spending a chunk of time searching the whole project for instances where I have unnecessarily spread all of the Redux state.
Object literals defined in render
Usually, I stick to writing CSS the old fashioned way with a stylesheet, but there have been instances where I have passed an object literal as a prop to a component or in JSX.
The case to look out for is where by the object is defined in a component's render function. The problem being is that each time the render function is executed (often), React will create a new unique reference to that object.
When it comes time for React to perform reconciliation between the DOM and virtual DOM, it will perform a shallow comparison and interpret them as being unique (different).
// Don't do this <Notification priority='high' style={{ backgroundColor: 'red', fontWeight: '900' }} /> // Do do this <Notification priority='high' style={notificationStyle} />
Anonymous functions
Similarly to object literals defined in render, anonymous functions defined in render will also cause unnecessary reconciliations.
// Don't do this <Button text='Reload' onClick={() => { setTimeout(() => { window.location.reload(); }, 50); }} /> // Do do this <Button text='Reload' onClick={this.reloadPagePlease} />
I found an awesome little package for dealing with updating old package.json.
It checks every dependency for its latest version and tells you what it will do if you tell it to go ahead and upgrade everything.
Why is this so awesome? The alternative is to manually check each dependancy and update the package.json yourself.
PS G:\thomchap.com.au> npx npm-check-updates
Checking G:\thomchap.com.au\package.json
[====================] 10/10 100%
lodash ^4.17.11 β ^4.17.15
react ^16.8.6 β ^16.9.0
react-dom ^16.8.6 β ^16.9.0
react-markdown ^4.0.8 β ^4.1.0
react-moment ^0.8.4 β ^0.9.2
react-router-dom ^4.3.1 β ^5.0.1
react-scripts 2.0.5 β 3.1.1
three ^0.102.1 β ^0.107.0
Run npx npm-check-updates -u to upgrade package.json