~thewhodidthis/longform

d9b0d2fbfc100ac6d2fd7fd2b7d2efad7b132d5c — thewhodidthis 3 days ago main
git: MXXII remake
A  => .gitignore +4 -0
@@ 1,4 @@
longform
.env
*.gz
config*

A  => LICENSE +20 -0
@@ 1,20 @@
The MIT License (MIT)

Copyright (c) 2011 Thepersonwhodidthis <thewhodidthis@fastmail.com> (https://thewhodidthis.com)

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

A  => Makefile +24 -0
@@ 1,24 @@
include .env

go/run:
	env BEARER_TOKEN=${BEARER_TOKEN} go run main.go
go:
	env GOOS=openbsd GOARCH=amd64 go build
js/watch:
	deno bundle --import-map=imports.json client.js bundle.js --unstable --watch
js/lint:
	deno lint client.js serviceworker.js helper.js
js:
	deno bundle --import-map=imports.json client.js -r | \
		esbuild --bundle --minify --sourcefile=client.js --outfile=bundle.js
gz:
	gzip index.html bundle.js ivory.css -k9
push:
	rsync -Lav --rsync-path /usr/bin/openrsync ./longform zz:~/
fetch/live:
	curl -v -H "Accept: text/event-stream" https://thewhodidthis.net/001/io
fetch:
	curl -v -H "Accept: text/event-stream" 127.0.0.1:8001/io
fetch/rules:
	curl -v https://api.twitter.com/2/tweets/search/stream/rules -H "Authorization: Bearer ${BEARER_TOKEN}"


A  => README.md +5 -0
@@ 1,5 @@
## Long form

> Pulls content related to the arab spring from twitter and prints it out in real time in ever expanding type.

![Demo](demo.gif)

A  => app.webmanifest +21 -0
@@ 1,21 @@
{
  "background_color": "#fff",
  "display": "fullscreen",
  "icons": [
    {
      "src": "icon.png",
      "type": "image/png",
      "sizes": "192x192",
      "purpose": "maskable"
    },
    {
      "src": "poster.png",
      "type": "image/png",
      "sizes": "1200x1200"
    }
  ],
  "name": "Long form",
  "short_name": "Long form",
  "start_url": "index.html?launcher=true",
  "theme_color": "#fff"
}

A  => bundle.js +11 -0
@@ 1,11 @@
(()=>{var d=(t={},...e)=>e.reduce((i,r)=>{let s=t[r];return s===void 0?i:s},()=>{}),m=(t=document)=>d(t,"webkitFullscreenElement","mozFullScreenElement","msFullscreenElement","fullscreenElement"),f=(t=document.body)=>d(t,"webkitRequestFullScreen","mozRequestFullScreen","msRequestFullscreen","requestFullscreen").call(t),g=(t=document)=>d(t,"webkitExitFullscreen","mozCancelFullScreen","msExitFullscreen","exitFullscreen").call(t),l=t=>m()!==null?g():f(t);l.state=m;l.enter=f;l.leave=g;var c=class extends HTMLElement{constructor(){super(),this.attachShadow({mode:"open"}),this.shadowRoot.appendChild(document.createElement("slot"))}connectedCallback(){this.isConnected&&this.firstElementChild&&this.addEventListener("click",()=>{l(this.firstElementChild)})}},u=class extends HTMLElement{constructor(){super();let e=document.createRange().createContextualFragment(`
      <style>
        :host {
          display: block;
        }
        :host([hidden]) {
          display: none;
        }
      </style>
      <slot></slot>
    `);this.attachShadow({mode:"open"}),this.shadowRoot.appendChild(e)}static get observedAttributes(){return["flash","ready"]}get flash(){return this.getAttribute("flash")}set flash(e){this.setAttribute("flash",e)}get ready(){return this.getAttribute("ready")}set ready(e){this.setAttribute("ready",e)}get timeout(){let e=this.hasAttribute("timeout")?this.getAttribute("timeout"):1500;return parseInt(e,10)}set timeout(e){Number.isNaN(e)||this.setAttribute("timeout",e)}attributeChangedCallback(e,i,r){switch(e){case"ready":{if(r!=="null"){let s=new Event("ready");this.innerText="",this.dispatchEvent(s)}break}case"flash":{let s=setTimeout(()=>{clearTimeout(s),this.ready=!0},this.timeout);this.innerText=r,this.ready=null;break}}}},v=(t="",...e)=>RegExp(e.join("|")).test(t),C=(t="")=>{let{origin:e}=location;if(v(e,"local","test"))return t;let{href:i=e}=document.querySelector('link[rel="canonical"]');return e!==i?i:e};"customElements"in self&&(customElements.define("just-scream",c),customElements.define("just-flash",u));var a=document.getElementById("figure"),n=document.querySelector("just-flash"),y=()=>{if(n.flash!==""){let e=new Date().toLocaleTimeString(void 0,{hour12:!1});n.style.fontFamily="monospace",n.flash=e;return}n.removeEventListener("ready",y)};n.addEventListener("ready",y);var h=new Date(9),E=new EventSource(`${C("http://localhost:8001").replace(/\/$/,"")}/io`);E.addEventListener("message",({data:t})=>{let{clientHeight:e,firstElementChild:i,scrollHeight:r}=a,s=h.getTime(),p=Math.min(s,e);h.setTime(s+1);let o=document.createElement("span");o.setAttribute("style",`font-size: ${s}px; line-height: ${p}px`);let b=document.createTextNode(t);o.appendChild(b),i===null&&(n.flash=""),i&&r>e&&a.removeChild(i),a.appendChild(o)});E.addEventListener("error",()=>{n.style.fontFamily="inherit",n.flash="Sorry, trying to connect still"});self.addEventListener("load",()=>{"serviceWorker"in navigator&&navigator.serviceWorker.register("serviceworker.js").catch(t=>{console.log("serviceworker: register:",t.message)}),n.style.fontFamily="inherit",n.flash="Connecting..."});})();

A  => client.js +75 -0
@@ 1,75 @@
import { Flash, Scream } from "@thewhodidthis/j"
import { findHost } from "./helper.js"

if ("customElements" in self) {
  customElements.define("just-scream", Scream)
  customElements.define("just-flash", Flash)
}

const figure = document.getElementById("figure")
const flash = document.querySelector("just-flash")

const flashTheTime = () => {
  if (flash.flash !== "") {
    const date = new Date()
    const time = date.toLocaleTimeString(undefined, { hour12: false })

    flash.style.fontFamily = "monospace"
    flash.flash = time

    return
  }

  flash.removeEventListener("ready", flashTheTime)
}

// Flash the time while waiting for the tracker to respond.
flash.addEventListener("ready", flashTheTime)

// Maxes out 8,640,000,000,000,000 milliseconds from epoch (ECMA-262).
const counter = new Date(9)
const io = new EventSource(`${findHost("http://localhost:8001").replace(/\/$/, "")}/io`)

io.addEventListener("message", ({ data }) => {
  const { clientHeight, firstElementChild, scrollHeight } = figure
  const fontSize = counter.getTime()
  const lineHeight = Math.min(fontSize, clientHeight)

  // Move up in time.
  counter.setTime(fontSize + 1)

  const span = document.createElement("span")

  span.setAttribute("style", `font-size: ${fontSize}px; line-height: ${lineHeight}px`)

  const text = document.createTextNode(data)

  span.appendChild(text)

  // Hide timer.
  if (firstElementChild === null) {
    flash.flash = ""
  }

  if (firstElementChild && scrollHeight > clientHeight) {
    figure.removeChild(firstElementChild)
  }

  figure.appendChild(span)
})

io.addEventListener("error", () => {
  flash.style.fontFamily = "inherit"
  flash.flash = "Sorry, trying to connect still"
})

self.addEventListener("load", () => {
  if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("serviceworker.js").catch((e) => {
      console.log("serviceworker: register:", e.message)
    })
  }

  flash.style.fontFamily = "inherit"
  flash.flash = "Connecting..."
})

A  => demo.gif +0 -0
A  => favicon.ico +0 -0
A  => go.mod +3 -0
@@ 1,3 @@
module git.sr.ht/~thewhodidthis/longform

go 1.16

A  => helper.js +16 -0
@@ 1,16 @@
export const stringContains = (str = "", ...terms) => RegExp(terms.join("|")).test(str)
export const findHost = (url = "") => {
  const { origin } = location

  if (stringContains(origin, "local", "test")) {
    return url
  }

  const { href: actualHost = origin } = document.querySelector(`link[rel="canonical"]`)

  if (origin !== actualHost) {
    return actualHost
  }

  return origin
}

A  => icon.png +0 -0
A  => imports.json +6 -0
@@ 1,6 @@
{
  "imports": {
    "@thewhodidthis/j": "https://thewhodidthis.com/modules/j/main.js",
    "fullscream": "https://thewhodidthis.com/modules/fullscream/main.js"
  }
}

A  => index.html +100 -0
@@ 1,100 @@
<!DOCTYPE html>
<html lang="en" id="root">
  <head>
    <meta charset="utf-8">
    <meta name="description" content="Arab spring related tweets in ever expanding type.">
    <meta name="theme-color" content="#000000">
    <meta name="viewport" content="width=device-width">
    <title>Long form</title>
    <link rel="apple-touch-icon" href="icon.png">
    <link rel="canonical" href="https://thewhodidthis.net/001/">
    <link rel="icon" href="icon.png" sizes="any">
    <link rel="manifest" href="app.webmanifest">
    <link rel="preload" href="ivory.css" as="style">
    <link rel="stylesheet" href="ivory.css">
    <script src="bundle.js" defer></script>
    <style>
      blockquote img {
        height: auto;
        object-fit: contain;
        max-width: 100%;
      }
      figure figure {
        background: #fff;
        font-size: 0;
        line-height: 1;
      }
      just-scream {
        cursor: crosshair;
      }
      just-flash {
        bottom: 0;
        display: initial;
        font-size: 0.75rem;
        height: 0;
        left: 0;
        margin: auto;
        position: absolute;
        right: 0;
        top: 0;
        text-align: center;
      }
      just-flash:not(:defined) {
        display: none;
      }
    </style>
    <noscript>
      <style>
        figure {
          position: relative;
        }
        figure figure {
          background: center / contain no-repeat url(screenshot.png);
        }
        just-scream {
          cursor: unset;
        }
      </style>
    </noscript>
  </head>
  <body>
    <figure>
      <just-scream>
        <figure id="figure"></figure>
        <just-flash timeout="400" hidden>Please wait...</just-flash>
      </just-scream>
      <figcaption>
        <h1>Long form</h1>
        <p>Arab spring related tweets in ever expanding type. <a href="#more" title="Read more" hidden>More &rsaquo;</a></p>
      </figcaption>
    </figure>
    <aside id="more">
      <div>
        <a href="#root" title="Close" hidden>&times;</a>
        <blockquote>
          <picture>
            <source srcset="preview-2.webp" type="image/webp">
            <source srcset="preview-2.png" type="image/png">
            <img loading="lazy" src="preview-2.png" alt="Preview: Just type" width="250" height="150">
          </picture>
          <picture>
            <source srcset="preview-3.webp" type="image/webp">
            <source srcset="preview-3.png" type="image/png">
            <img loading="lazy" src="preview-3.png" alt="Preview: Bigger, huge type" width="250" height="150">
          </picture>
          <p>Fluctuates based on current events, but the growth pattern is always the same, which in turn loses importance after a short while.</p>
        </blockquote>
        <ul>
          <li><a href="https://git.sr.ht/~thewhodidthis/longform" title="Browse source code">Git log</a></li>
          <li><a href="https://thewhodidthis.com" title="Visit author homepage">More &rarr;</a></li>
        </ul>
        <footer>
          <p>
            <small>&copy; 2011 &middot; 22</small>
          </p>
        </footer>
      </div>
    </aside>
    <noscript>please enable javascript</noscript>
  </body>
</html>

A  => ivory.css +1 -0
@@ 1,1 @@
html{font:16px/1.3125 "Times New Roman",serif;height:100%;min-height:100%;text-size-adjust:100%}body{height:100%;margin:0}small{font-size:.625rem}img{font-size:.8125rem}h1{font-size:inherit;margin:0}p{margin:0}figure{color:inherit;height:100%;margin:0;width:100%}figure figure{overflow:hidden;position:relative}figcaption{padding:1rem}figcaption p{font-size:.875rem;margin:.25rem 0 0}blockquote{margin:0}blockquote p{font-size:1.25rem;line-height:1.25;margin-top:1rem;margin-left:0;max-width:45ch}footer{color:#999;font-family:sans-serif;line-height:1;margin:1rem -1rem -1rem 0;text-align:right}aside{display:flex;justify-content:center;left:0;min-height:100%;position:absolute;right:0;top:0;visibility:hidden}aside:target{visibility:visible}aside div{background:rgba(255,255,255,.9);box-sizing:border-box;display:flex;flex-direction:column;min-height:100%;padding:2rem;position:absolute;transform:translateY(-100%);width:100%}aside:target div{position:relative;transform:none}aside p{margin:1rem 0 0}aside ul{font-size:.875rem;line-height:1.25;margin:1rem 0 auto 1rem;padding:0}noscript{color:#999;font:.8125rem/2 sans-serif;left:0;padding:0 .75rem;position:absolute;text-transform:lowercase;top:0;z-index:1}a{text-decoration:underline}footer a{color:inherit}aside a{text-decoration:none}a:hover{text-decoration:none}aside a:hover{text-decoration:underline}a[href="#more"]{display:initial;white-space:nowrap}a[href="#root"]{color:#666;display:initial;font-size:2rem;line-height:1;padding:.75rem 1rem;position:absolute;right:1px;top:1px;z-index:101}a[href="#root"]:hover{color:#000;text-decoration:none}@media screen and (min-height: 500px) and (min-width: 768px){aside{align-items:center;background:rgba(255,255,255,.9)}aside div{background:#fff;border:1px solid #eee;max-width:40rem;min-height:0;min-width:55ch}}@media screen and (min-height: 500px) and (min-width: 1024px){body>figure{height:300px;left:50%;position:absolute;transform:translate(-50%,-50%);top:50%;width:500px}figcaption{bottom:0;max-width:12rem;position:absolute;right:0;transform:translate(100%);width:100%}blockquote p{font-size:1.5rem;line-height:1.125}noscript{left:auto;right:0;transform:rotate(90deg) translate(100%);transform-origin:100% 0}}

A  => main.go +307 -0
@@ 1,307 @@
// Long form tracks arab spring related tweets.
package main

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

// ENDPOINT is the streaming resource to be fetching data from.
const ENDPOINT = "https://api.twitter.com/2/tweets/search/stream"

// PORT is the default port number. Use the `-p` flag to override.
const PORT = "8001"

// KEYWORDS is the list of search terms to be filtering against.
var KEYWORDS = []string{
	"beirut",
	"palestine",
	"tunisia",
	"egypt",
	"yemen",
	"libya",
	"syria",
}

// Data contains fields found across tweets and rules responses.
type Data struct {
	ID   string `json:"id"`
	Tag  string `json:"tag"`
	Text string `json:"text"`
}

// Error allows for parsing errors embedded in an otherwise successful response.
type Error struct {
	Message string `json:"message"`
}

// Meta contains extra fields for parsing rules responses.
type Meta struct {
	Count int `json:"result_count"`
}

// Tracker implements the http Handler interface and keeps
// track of active connections.
type Tracker struct {
	Clients map[string]chan string
	Delay   time.Duration
	Idle    bool
	Suspend chan bool
	Resume  chan bool
}

func (t *Tracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if !strings.HasSuffix(r.URL.Path, "/io") {
		http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
		log.Printf("ServeHTTP: odd path: %s", r.URL.Path)

		return
	}

	// Need to manually flush the response writer for SSE to work.
	f, ok := w.(http.Flusher)

	if !ok {
		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

		return
	}

	if a := r.Header.Get("Accept"); a != "text/event-stream" {
		http.Error(w, http.StatusText(http.StatusNotAcceptable), http.StatusNotAcceptable)

		return
	}

	if o := r.Header.Get("Origin"); strings.Contains(o, "localhost") {
		w.Header().Set("Access-Control-Allow-Origin", o)
	}

	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	w.Header().Set("Content-Type", "text/event-stream")

	c := make(chan string)

	defer func() {
		delete(t.Clients, r.RemoteAddr)
		t.Suspend <- true
	}()

	t.Clients[r.RemoteAddr] = c
	t.Resume <- true

L:
	for {
		select {
		case d := <-c:
			fmt.Fprintf(w, "retry: 10000\ndata: %s\n\n", d)
			f.Flush()
		case <-r.Context().Done():
			break L
		}
	}
}

// Stream tells the tracker to call ENDPOINT for collecting data.
func (t *Tracker) Stream() {
	defer func() {
		// Free up for calling again.
		t.Idle = true
	}()

	// Set busy state.
	t.Idle = false

	// Attempt to contact the streaming resource.
	res, err := fetch(http.MethodGet, ENDPOINT, nil)

	if err != nil {
		log.Printf("tracker: failed to connect / %v", err)

		// Calculate back off delay, wait as much and try again.
		if t.Delay == 0 {
			t.Delay = 1 * time.Minute
		} else {
			t.Delay = 2 * t.Delay
		}

		time.Sleep(t.Delay)
		t.Resume <- true

		return
	}

	// Safe to reset since the request seems to have went through OK.
	t.Delay = 0

	defer res.Body.Close()
	dec := json.NewDecoder(res.Body)

M:
	for {
		var msg struct {
			Data  Data
			Error Error `json:"error"`
		}

		err := dec.Decode(&msg)
		if err != nil {
			// Done streaming.
			if err == io.EOF {
				break
			}

			log.Fatalf("tracker: failed to parse / %v", err)
		}

		if msg.Error.Message != "" {
			log.Printf("tracker: API error: %v", msg.Error.Message)

			break
		}

		select {
		case <-t.Suspend:
			if len(t.Clients) == 0 {
				break M
			}
		default:
			for _, c := range t.Clients {
				c <- msg.Data.Text
			}
		}
	}
}

// Check password and filtering rules ahead of launching the tracker.
func init() {
	// Only proceed if a password is available.
	if _, ok := os.LookupEnv("BEARER_TOKEN"); !ok {
		log.Fatal("init: the BEARER_TOKEN environment variable is required")
	}

	tap := ENDPOINT + "/rules"
	tag := "long form"

	add := func() error {
		val := strings.Join(KEYWORDS, " OR ")
		add := fmt.Sprintf(`{ "add": [{ "value": "%v", "tag": "%v" }] }`, val, tag)

		_, err := fetch(http.MethodPost, tap, []byte(add))

		return err
	}

	// A failed GET request could mean there are no rules available.
	res, err := fetch(http.MethodGet, tap, nil)

	if err != nil {
		log.Printf("init: failed reading rules, %v", err)

		// Try posting search rules.
		if err := add(); err != nil {
			log.Printf("init: failed adding rules, %v", err)
		}

		return
	}

	// To be parsing the response onto.
	var msg struct {
		Data []Data
		Meta Meta `json:"meta"`
	}

	defer res.Body.Close()

	if err := json.NewDecoder(res.Body).Decode(&msg); err != nil {
		log.Printf("init: failed parsing rules, %v", err)

		return
	}

	if msg.Meta.Count == 0 {
		if err := add(); err != nil {
			log.Printf("init: failed adding rules, %v", err)
		}
	}

	tagMissing := func() bool {
		for i := range msg.Data {
			if msg.Data[i].Tag == tag {
				return false
			}
		}
		return true
	}()

	if tagMissing {
		log.Print("init: failed finding rules")
	}
}

func main() {
	var p string

	flag.StringVar(&p, "p", PORT, "Choose a port number")
	flag.Parse()

	t := &Tracker{
		Clients: make(map[string]chan string),
		Delay:   0,
		Idle:    true,
		Suspend: make(chan bool),
		Resume:  make(chan bool),
	}

	// Listen for calls to start streaming.
	go func() {
		for <-t.Resume {
			if t.Idle {
				go t.Stream()
			}
		}
	}()

	s := &http.Server{
		Addr:    "localhost:" + p,
		Handler: t,
	}

	log.Fatal(s.ListenAndServe())
}

// fetch helps make BEARER_TOKEN authorized ENDPOINT json queries.
func fetch(verb string, hook string, b []byte) (*http.Response, error) {
	req, err := http.NewRequest(verb, hook, bytes.NewReader(b))

	if err != nil {
		return nil, err
	}

	req.Header.Set("Authorization", os.ExpandEnv("Bearer ${BEARER_TOKEN}"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)

	if err != nil {
		return nil, err
	}

	// If not 200/1, it's code 400 and above.
	if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusCreated {
		// Reading from the response is up to the caller.
		return res, nil
	}

	return nil, fmt.Errorf("fetch: request failed / %v", res.StatusCode)
}

A  => poster.png +0 -0
A  => preview-1.png +0 -0
A  => preview-1.webp +0 -0
A  => preview-2.png +0 -0
A  => preview-2.webp +0 -0
A  => preview-3.png +0 -0
A  => preview-3.webp +0 -0
A  => robots.txt +2 -0
@@ 1,2 @@
User-agent: *
Disallow: /io/*
\ No newline at end of file

A  => screenshot.png +0 -0
A  => serviceworker.js +48 -0
@@ 1,48 @@
const activeCacheName = "longform-v2"

self.addEventListener("install", (e) => {
  const assets = [
    "/",
    "app.webmanifest",
    "bundle.js",
    "index.html",
    "ivory.css",
    "preview-2.png",
    "preview-2.webp",
    "preview-3.png",
    "preview-3.webp",
    "screenshot.png",
  ]

  const action = caches.open(activeCacheName).then(cache => cache.addAll(assets)).catch((e) => {
    console.log("serviceworker: install:", e.message)
  })

  e.waitUntil(action)
})

self.addEventListener("activate", (e) => {
  const others = name => activeCacheName !== name
  const remove = name => caches.delete(name)

  const action = caches.keys().then(entries => Promise.all(entries.filter(others).map(remove)))

  e.waitUntil(action)
})

self.addEventListener("fetch", (e) => {
  // Skip SSE endpoint.
  if (e.request.url.includes("/io")) {
    return
  }

  const action = caches.match(e.request).then((response) => {
    if (response) {
      return response
    }

    return fetch(e.request)
  })

  e.respondWith(action)
})