my eye

Bootstrap

Committed 0f7bb3

index 0000000..7d382e6
--- /dev/null

+## Typeface Attribution
+
+Ubuntu (Ubuntu font licence, version 1.0)  
+Canonical, Dalton Maag
+
+Fleuron (SIL Open Font License, version 1.1)  
+Mickaël Emile, Yves-Gabriel So

index 0000000..cb8ff70
--- /dev/null

+"""A decentralized social web platform."""
+
+import web
+
+app = web.application(__name__, automount=True, autotemplate=True)
+
+
+@app.control("")
+class Home:
+    """Your homepage."""
+
+    def get(self):
+        """Render your homepage."""
+        return app.view.index()
diff --git a/canopy/static/fleuron-dingbats.woff b/canopy/static/fleuron-dingbats.woff
new file mode 100644
index 0000000..aa97add
Binary files /dev/null and b/canopy/static/fleuron-dingbats.woff differ
diff --git a/canopy/static/fleuron-dingbats.woff2 b/canopy/static/fleuron-dingbats.woff2
new file mode 100644
index 0000000..7cf4480
Binary files /dev/null and b/canopy/static/fleuron-dingbats.woff2 differ
diff --git a/canopy/static/fleuron-mixed.woff b/canopy/static/fleuron-mixed.woff
new file mode 100644
index 0000000..566e03f
Binary files /dev/null and b/canopy/static/fleuron-mixed.woff differ
diff --git a/canopy/static/fleuron-mixed.woff2 b/canopy/static/fleuron-mixed.woff2
new file mode 100644
index 0000000..fff122e
Binary files /dev/null and b/canopy/static/fleuron-mixed.woff2 differ
diff --git a/canopy/static/fleuron-regular.woff b/canopy/static/fleuron-regular.woff
new file mode 100644
index 0000000..2189a3f
Binary files /dev/null and b/canopy/static/fleuron-regular.woff differ
diff --git a/canopy/static/fleuron-regular.woff2 b/canopy/static/fleuron-regular.woff2
new file mode 100644
index 0000000..3166658
Binary files /dev/null and b/canopy/static/fleuron-regular.woff2 differ
diff --git a/canopy/static/recognizer-processor.js b/canopy/static/recognizer-processor.js
new file mode 100644
index 0000000..41be1f6
--- /dev/null
+++ b/canopy/static/recognizer-processor.js
+class RecognizerAudioProcessor extends AudioWorkletProcessor {
+    constructor(options) {
+        super(options);
+        
+        this.port.onmessage = this._processMessage.bind(this);
+    }
+    
+    _processMessage(event) {
+        // console.debug(`Received event ${JSON.stringify(event.data, null, 2)}`);
+        if (event.data.action === "init") {
+            this._recognizerId = event.data.recognizerId;
+            this._recognizerPort = event.ports[0];
+        }
+    }
+    
+    process(inputs, outputs, parameters) {
+        const data = inputs[0][0];
+        if (this._recognizerPort && data) {
+            // AudioBuffer samples are represented as floating point numbers between -1.0 and 1.0 whilst
+            // Kaldi expects them to be between -32768 and 32767 (the range of a signed int16)
+            const audioArray = data.map((value) => value * 0x8000);
+        
+            this._recognizerPort.postMessage(
+                {
+                    action: "audioChunk",
+                    data: audioArray,
+                    recognizerId: this._recognizerId,
+                    sampleRate, // Part of AudioWorkletGlobalScope
+                },
+                {
+                    transfer: [audioArray.buffer],
+                }
+            );
+        }
+        return true;
+    }
+}
+
+registerProcessor('recognizer-processor', RecognizerAudioProcessor)
\ No newline at end of file

index 0000000..6b40d2f
--- /dev/null

+:root {
+    font-family: Ubuntu, system-ui;
+    font-size: 16px;
+    line-height: 1.5; }
+
+body {
+    background-color: #002b36;
+    color: #839496;
+    display: grid;
+    grid-column-gap: 1.666%;
+    /* grid-template-columns: 5em 40em 13em; */
+    grid-template-columns: 8.333% 66.666% 21.666%;
+    grid-template-rows: min-content min-content min-content;
+    margin: 0 auto;
+    max-width: 60em;
+    padding: 2em 0; }
+body.widescreen {
+    margin: 0 2em;
+    max-width: 100%; }
+/* body.widescreen > nav {
+    display: none; } */
+
+body > header {
+    display: grid;
+    grid-column-start: 1;
+    grid-column-end: 4;
+    grid-row: 1;
+    grid-template-columns: auto auto; }
+body > header div:last-child {
+    text-align: right; }
+
+article#content {
+    grid-column: 2;
+    grid-row: 2; }
+body > nav {
+    grid-column: 1;
+    grid-row: 2; }
+body > aside {
+    grid-column: 3;
+    grid-row-start: 2;
+    grid-row-end: 3; }
+body > aside #livestream {
+    font-size: 8px;
+    height: 7em;
+    margin-bottom: 2em; }
+body > aside #mediasoup-demo-app-container {
+    margin-bottom: 1em; }
+
+a {
+  color: #268bd2; }
+a:visited {
+  color: #6c71c4; }
+
+.h-card img {
+    border: .0625em solid #ccc;
+    border-radius: 50%;
+    display: block;
+    margin: 0 auto 1em auto;
+    width: 60%; }
+/* .h-card > div {
+    display: inline-block; } */
+.h-card > p {
+    margin: 0; }
+a.p-name {
+    font-size: 2.5em;
+    font-weight: 500;
+    line-height: 1.25;
+    text-decoration: none; }
+p.p-note {
+    font-size:.75em;
+    margin-top: .5em; }
+#search {
+    margin: 0 0 .5em 0; }
+
+.breadcrumbs {
+    font-size: .8em; }
+.crumb-sep {
+    color: #777;
+    font-size: .8em;
+    position: relative;
+    top: -.1em; }
+code.fancy {
+    background-color: #aaa;
+    border-radius: .25em;
+    color: #333;
+    padding: .0625em .25em; }
+h1 {
+    font-size: 4em;
+    line-height: 1.35;
+    margin: 0; }
+h1 span {
+    font-family: cursive; }
+body > nav .h-card p {
+    font-family: sans-serif;
+    font-size: .9em;
+    margin: 0; }
+body > nav .h-card .p-note {
+    font-size: 1.1em; }
+nav ul {
+    list-style: none;
+    padding-left: 0; }
+body > footer {
+    grid-column-start: 1;
+    grid-column-end: 4;
+    grid-row: 3;
+    text-align: center; }
+
+pre {
+    overflow-x: auto;
+    white-space: pre-wrap;
+    white-space: -moz-pre-wrap;
+    white-space: -pre-wrap;
+    white-space: -o-pre-wrap;
+    word-wrap: break-word; }
+
+.rotate {
+  animation: rotation 60s infinite linear;
+}
+@keyframes rotation {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(359deg);
+  }
+}
+
+#connection {
+  color: #c36;
+  font-weight: bold;
+  letter-spacing: .025em;
+  text-transform: uppercase; }
+#version {
+  color: #555;
+  font-size: .66em; }
+
+.code > div > pre > span {
+  display: block; }
+
+input {
+  background-color: #eee;
+  border: 0; }
+button {
+  background-color: #eee;
+  border: 0;
+  border-radius: .25em; }
+input[type=text] {
+  width: 10em; }
+
+summary {
+  cursor: pointer; }
+
+hr {
+  border: 0;
+  border-top: .1em dotted #586e75;
+  border-bottom: .1em dotted #586e75;
+  height: .0625em;
+  margin: 2em;
+  opacity: 50%;
+  overflow: visible; }
+hr:before {
+  background-color: #002b36;
+  color: #586e75;
+  content: "f";
+  display: block;
+  font-family: 'fleurondingbats';
+  font-size: 5em;
+  line-height: .8;
+  margin: auto;
+  padding: 0 .25em;
+  position: relative;
+  text-align: center;
+  top: -.2em;
+  width: .725em;
+  writing-mode: vertical-rl; }
+
+.h-entry {
+  background-color: #073642;
+  display: table;
+  margin: 0 0 1em 0;
+  padding: 0 1em;
+  width: 100%; }
+.h-entry.private {
+  border: .1em solid #b58900; }
+
+.apps {
+  margin: .5em 0; }
+.apps li > * {
+  display: block;
+  padding: .5em 0; }
+
+.admin {
+  font-size: .75em; }
+
+body > footer {
+  font-size: .8em; }
+
+.system {
+  font-family: fleurondingbats;
+  font-size: 4em;
+  line-height: .5;
+  margin: .25em 0;
+  text-align: center;
+  width: 1em; }
+.systemtext:after {
+  content: "y"; }
+.system a {
+  color: #586e75;
+  display: block;
+  opacity: 75%;
+  text-decoration: none; }
+.system a span {
+  display: none; }
+
+pre.asciinema-terminal {
+  font-family: UbuntuMonoPowerline; }
+
+
+@font-face {
+  font-family: 'Ubuntu';
+  src: url('/static/ubuntu-light.ttf') format('ttf');
+  font-weight: 300;
+  font-style: normal; }
+@font-face {
+  font-family: 'Ubuntu';
+  src: url('/static/ubuntu-light-italic.ttf') format('ttf');
+  font-weight: 300;
+  font-style: italic; }
+@font-face {
+  font-family: 'Ubuntu';
+  src: url('/static/ubuntu-regular.ttf') format('ttf');
+  font-weight: 400;
+  font-style: normal; }
+@font-face {
+  font-family: 'Ubuntu';
+  src: url('/static/ubuntu-regular-italic.ttf') format('ttf');
+  font-weight: 400;
+  font-style: italic; }
+@font-face {
+  font-family: 'Ubuntu';
+  src: url('/static/ubuntu-medium.ttf') format('ttf');
+  font-weight: 500;
+  font-style: normal; }
+@font-face {
+  font-family: 'Ubuntu';
+  src: url('/static/ubuntu-medium-italic.ttf') format('ttf');
+  font-weight: 500;
+  font-style: italic; }
+@font-face {
+  font-family: 'Ubuntu';
+  src: url('/static/ubuntu-bold.ttf') format('ttf');
+  font-weight: 700;
+  font-style: normal; }
+@font-face {
+  font-family: 'Ubuntu';
+  src: url('/static/ubuntu-bold-italic.ttf') format('ttf');
+  font-weight: 700;
+  font-style: italic; }
+
+@font-face {
+  font-family: 'UbuntuMonoPowerline';
+  src: url('/static/ubuntu-mono-powerline.woff2') format('woff2');
+  font-weight: normal;
+  font-style: normal; }
+
+@font-face {
+  font-family: 'fleurondingbats';
+  src: url('/static/fleurondingbats.woff2') format('woff2'),
+       url('/static/fleurondingbats.woff') format('woff');
+  font-weight: normal;
+  font-style: normal; }
+@font-face {
+  font-family: 'fleuronmixed';
+  src: url('/static/fleuronmixed.woff2') format('woff2'),
+       url('/static/fleuronmixed.woff') format('woff');
+  font-weight: normal;
+  font-style: normal; }
diff --git a/canopy/static/ubuntu-bold-italic.ttf b/canopy/static/ubuntu-bold-italic.ttf
new file mode 100644
index 0000000..ce6e784
Binary files /dev/null and b/canopy/static/ubuntu-bold-italic.ttf differ
diff --git a/canopy/static/ubuntu-bold.ttf b/canopy/static/ubuntu-bold.ttf
new file mode 100644
index 0000000..c2293d5
Binary files /dev/null and b/canopy/static/ubuntu-bold.ttf differ
diff --git a/canopy/static/ubuntu-light-italic.ttf b/canopy/static/ubuntu-light-italic.ttf
new file mode 100644
index 0000000..ad0741b
Binary files /dev/null and b/canopy/static/ubuntu-light-italic.ttf differ
diff --git a/canopy/static/ubuntu-light.ttf b/canopy/static/ubuntu-light.ttf
new file mode 100644
index 0000000..b310d15
Binary files /dev/null and b/canopy/static/ubuntu-light.ttf differ
diff --git a/canopy/static/ubuntu-medium-italic.ttf b/canopy/static/ubuntu-medium-italic.ttf
new file mode 100644
index 0000000..36ac1ae
Binary files /dev/null and b/canopy/static/ubuntu-medium-italic.ttf differ
diff --git a/canopy/static/ubuntu-medium.ttf b/canopy/static/ubuntu-medium.ttf
new file mode 100644
index 0000000..7340a40
Binary files /dev/null and b/canopy/static/ubuntu-medium.ttf differ
diff --git a/canopy/static/ubuntu-regular-italic.ttf b/canopy/static/ubuntu-regular-italic.ttf
new file mode 100644
index 0000000..a599244
Binary files /dev/null and b/canopy/static/ubuntu-regular-italic.ttf differ
diff --git a/canopy/static/ubuntu-regular.ttf b/canopy/static/ubuntu-regular.ttf
new file mode 100644
index 0000000..f98a2da
Binary files /dev/null and b/canopy/static/ubuntu-regular.ttf differ

index 0000000..69fe154
--- /dev/null

+import collections
+import pprint
+
+import pendulum
+# TODO from micropub.readability import Readability
+import web
+import webint_live
+import webint_posts
+from mf import discover_post_type
+from webagt import Document
+
+__all__ = [
+    "discover_post_type",
+    "pformat",
+    "pendulum",
+    "tx",
+    "post_mkdn",
+    # TODO "Readability",
+    "get_first",
+    "get_months",
+    "get_posts",
+    "get_categories",
+    "Document",
+    "livestream",
+]
+
+tx = web.tx
+livestream = webint_live.app.view.stream
+
+
+def pformat(obj):
+    return f"<pre>{pprint.pformat(obj)}</pre>"
+
+
+def post_mkdn(content):
+    return web.mkdn(content)  # XXX , globals=micropub.markdown_globals)
+
+
+def get_first(obj, p):
+    return obj.get(p, [""])[0]
+
+
+def get_months():
+    months = collections.defaultdict(collections.Counter)
+    for post in webint_posts.app.model.get_posts():
+        published = post["published"][0]
+        months[published.year][published.month] += 1
+    return months
+
+
+def get_posts():
+    return webint_posts.app.model.get_posts()
+
+
+def get_categories():
+    return webint_posts.app.model.get_categories()

index 0000000..649c44f
--- /dev/null

+$def with ()
+$# var classes = ["h-feed"]
+
+<style>
+#content img {
+  width: 100%; }
+</style>
+
+$for item in get_posts():
+    $ item_type = item["type"][0]
+    <section class="h-$item_type $item['visibility'][0]">
+    $if item_type == "entry":
+        $ entry = item
+        $ post_type = discover_post_type(entry)
+        $if post_type == "bookmark":
+            $# <p class=p-content>$entry["content"]</p>
+            <p>Bookmarked <a href=$entry["bookmark-of"][0]
+            class=u-bookmark-of>$entry["bookmark-of"][0]</a></p>
+        $elif "rsvp" in entry:
+            $ in_reply_to = entry["in-reply-to"][0]
+            $ colors = {
+            $   "yes": "2aa198",
+            $   "maybe": "6c71c4",
+            $   "no": "cb4b16",
+            $ }
+            <p>RSVP'd <strong class=p-rsvp
+                style="color:$colors[entry['rsvp'][0]];">$entry["rsvp"][0]</strong> to
+            <a class=u-in-reply-to href=$in_reply_to>$in_reply_to</a></p>
+        $elif item_type == "audio/clip":
+            <div><audio class=u-audio src=$entry["audio"][0]></audio></div>
+        $elif "audio" in entry:
+            $entry
+            $# <div><img class=u-photo src=$entry["photo"][0]></div>
+            $# <div class=p-summary>$entry["summary"][0]</div>
+        $elif "photo" in entry:
+            <div><img
+            $if summary := entry.get("summary"):
+                alt="$summary[0]"
+            class="u-photo p-summary" src=$entry["photo"][0]></div>
+        $elif "content" in entry and "name" in entry:
+            <h3>$:entry["name"][0]</h3>
+            $# <div class=p-content>$:entry["content"][0]</div>
+        $elif content := entry.get("content"):
+            $ c = content
+            <div class=e-content>
+            $if isinstance(c, dict):
+                $:c["html"]
+            $else:
+                $if isinstance(c[0], dict):
+                    $:c[0]["html"]
+                $else:
+                    $:c[0]
+            </div>
+        $elif "listen-of" in entry:
+            $ listen_of = entry["listen-of"][0]
+            $if isinstance(listen_of, str):
+                <p class=p-name>Listened to <em class=p-listen-of>$listen_of</em></p>
+            $else:
+                $ author = listen_of["author"][0]
+                $ youtube_id = listen_of["url"][0].partition("?")[2]
+                <details>
+                <summary>
+                Listened to <em><a href=$author["url"]>$author["name"]</a> &ndash;
+                <a href=$listen_of["url"][0]>$listen_of["name"][0]</a></em>
+                </summary>
+                </details>
+                $# <script>
+                $# document.querySelector('summary').addEventListener('click', ev => {
+                $#   const embed = document.createElement('div');
+                $#   embed.innerHTML =
+                $#     `<iframe width=560 height=315
+                $#     src="https://www.youtube.com/embed/videoseries?$youtube_id"
+                $#     title="YouTube video player" frameborder=0
+                $#     allow="accelerometer; autoplay; clipboard-write; encrypted-media; \
+                $#     gyroscope; picture-in-picture" allowfullscreen></iframe>`
+                $#   ev.originalTarget.after(embed)
+                $# })
+                $# </script>
+        $elif "like-of" in entry:
+            $ like_of = entry["like-of"][0]
+            <div class=p-name>Liked <a class=u-like-of href=$like_of>$like_of</a></div>
+        $elif "bookmark-of" in entry:
+            $ bookmark_of = entry["bookmark-of"][0]
+            <div class=p-name>Bookmarked <a class=u-bookmark-of
+            href=$bookmark_of>$bookmark_of</a></div>
+        $else:
+            <div>$entry</div>
+    $elif item_type == "canopy-project":
+        $ project = item
+        <div>Created project <code class=p-name>$project["name"][0]</code></div>
+    $ published = item['published'][0]
+    <p style=text-align:right><small><a class=u-url href=$item["url"][0]><time class=dt-published
+    title="$published" datetime="$published">$published.diff_for_humans()</time>\
+    </a></small></p>
+    </section>

index 0000000..e67ae5e
--- /dev/null

+$def with (resource)
+$ path = tx.request.uri.path
+$ owner = tx.host.owner
+
+<html lang=en-us class=dark>
+<head>
+<meta charset=utf-8>
+<meta name=viewport
+    content=initial-scale=1.0,user-scalable=no,maximum-scale=1,width=device-width>
+<meta name=apple-mobile-web-app-capable content=yes>
+<meta name=apple-mobile-web-app-status-bar-style content=black-translucent>
+<!--link rel=icon href=/static/photo.png-->
+$if not tx.request.uri.path:
+    <link rel=manifest href=/manifest.json>
+<link rel=stylesheet href=/chats/mediasoup-demo-app.css>
+<link rel=stylesheet href=/static/screen.css media=screen>
+<title>\
+$if "title" in resource:
+    $:Document(resource.title).doc.text_content()&thinsp;&mdash;&thinsp;\
+$owner["name"][0]</title>
+
+$if not isinstance(resource, str) and "head" in resource:
+    $:resource.head()
+
+<link rel=stylesheet href=/static/asciinema-player.css>
+<script type=module>
+import { _, cookies, go, upgradeLink } from '/static/web.js'
+$if "session" in tx.user and tx.user.session.get("uid", None):
+    let userName = '$tx.user.session["name"][0]'
+$else:
+    let userName = 'Guest'
+// TODO $$.load(...)
+document.addEventListener('DOMContentLoaded', ev => {
+  if (cookies.get('rhythm') == 'on') {
+    document.querySelector('body').style.backgroundImage = 'url(/static/measure.png)'
+  }
+  document.addEventListener('keydown', (e) => {
+    if (e.ctrlKey && e.key == '.') {
+      if (cookies.get('rhythm') == 'on') {
+        document.querySelector('body').style.backgroundImage = 'none'
+        cookies.set('rhythm', 'off')
+      } else {
+        document.querySelector('body').style.backgroundImage = 'url(/static/measure.png)'
+        cookies.set('rhythm', 'on')
+      }
+    }
+  })
+  cookies.set('mediasoup-demo.user', `{"displayName": "$${userName}"}`)
+  // TODO const owner = "$owner['name'][0]"
+  _('a:not(.breakout)').each(upgradeLink)
+  history.pushState({scroll: 0}, 'title', window.location)
+  window.go = go
+})
+</script>
+
+$if tx.user.is_owner:
+    <style>
+    .h-card img {
+        border: .15em solid #007ba7; }
+    </style>
+
+</head>
+<body
+$if "body_classes" in resource:
+     class="$' '.join(resource.body_classes)"\
+>
+
+<header>
+<div class=h-card>
+    <!--img class=u-photo src=/static/photo.png-->
+    <p><a href=/ rel=me class="p-name u-url u-uid">
+        <strong>$owner["name"][0]</strong></a></p>
+    $if "note" in owner:
+        <p class=p-note>$owner["note"][0]</p>
+    $if "email" in owner:
+        $ email = get_first(owner, "email")
+        <p><small>
+            <a rel=me class=u-email href=mailto:$email>$email</a>
+            </small></p>
+    $if "onion" in owner:
+        <p><small>
+            $ onion = get_first(owner, "onion")
+            $if onion:
+                <a rel=me href=http://$onion><code>$onion</code></a>
+            </small></p>
+    $if "key" in owner:
+        <p><small>
+            $ key = get_first(owner, "key")
+            $if key:
+                <a href=/owner/key
+                    rel=me><code>$:"&ndash;".join(key.split()[:2])</code></a>
+            </small></p>
+</div>
+<div>
+<form id=search action=/search>
+    <input name=q type=text> <button>Search</button>
+</form>
+</div>
+</header>
+
+$def render_breadcrumbs(breadcrumbs, separator="&#x25b8;", padding="&ensp;"):
+    $ """
+    $ Render a `separator` delimited list of linkified `breadcrumbs`.
+    $
+    $ `breadcrumbs` should be a single tuple that will be read two items at a time:
+    $
+    $     ("path", "Name", "subpath", "Subname", ...)
+    $
+    $ """
+    $ remaining = int(len(breadcrumbs) / 2)
+    $ path = ""
+    $for crumb_path, crumb_title in zip(*(breadcrumbs[i::2] for i in (0, 1))):
+        $ crumb_path = str(crumb_path)
+        $ crumb_icon, crumb_classes = None, None
+        $if isinstance(crumb_title, tuple):
+            $ crumb_icon, crumb_classes, crumb_title = crumb_title
+        $ path = path + "/" + crumb_path
+        $ ups = " ".join(["up"] * remaining)
+        $ remaining = remaining - 1
+        <span>\
+        $if crumb_icon:
+            <span class="crumb-icon-$crumb_icon"></span>&thinsp;\
+        <a href=$path \
+        $if crumb_classes:
+            class="$' '.join(crumb_classes)"
+        rel="$ups">$:crumb_title</a>\
+        $:padding<span class=crumb-sep>$:separator</span></span>\
+
+<article id=content\
+$if "classes" in resource:
+     class="$' '.join(resource.classes)"\
+>
+$if path:
+    $ breadcrumbs = []
+    $ parts = path.split("/")
+    $ app = parts[0]
+    $if len(parts) > 1:
+        $ breadcrumbs += [app, app.capitalize()]
+    $if "breadcrumbs" in resource:
+        $ breadcrumbs += list(resource["breadcrumbs"])
+    $if breadcrumbs:
+        <nav class=breadcrumbs>$:render_breadcrumbs(breadcrumbs)</nav>
+$if "title" in resource:
+    $if getattr(resource, "show_title", True):
+        <header>
+        <h1
+        $if "title_classes" in resource:
+             class="$' '.join(resource.title_classes)"\
+        >$:resource.title</h1>
+        </header>
+$:resource
+$# $if "hide_footer" not in resource:
+$#     <footer>
+$#         <p><small>Content licensed <a
+$#         href=//creativecommons.org/licenses/by-nc-sa/4.0/ rel=license><abbr
+$#         title="Creative Commons Attribution-NonCommercial-ShareAlike">CC
+$#         BY-NC-SA</abbr></a> unless otherwise noted.</small></p>
+$#     <!--p><a href=//indieweb.rocks/$tx.host.name rel=me><img alt="IndieMark score"
+$#         src=//indieweb.rocks/sites/$tx.host.name/scoreboard.svg style=height:4em></a></p-->
+$#     </footer>
+</article>
+
+<nav>
+$# <ul style=font-size:.8em>
+$# <li><a href=/about>About</a></li>
+$# </ul>
+$ public_apps = {"code": "Code"}
+$# , "media": "Media"}
+<ul class=apps>
+$for app_prefix, app_name in public_apps.items():
+    <li>
+    $# XXX $if path.startswith(app_prefix):
+    $# XXX     <strong>$app_name</strong>
+    $# XXX $else:
+    <a href=/$app_prefix>$app_name</a>
+    </li>
+</ul>
+<ul class=dates>
+$for year, months in get_months().items():
+    <li><small><a href=/$year>$year</a></small>\
+    $# <div style=columns:6>
+    $# $for month in range(1, 13):
+    $#     $ MM = f"{month:02}"
+    $#     $if months[month]:
+    $#         <a href=/$year/$MM>\
+    $#     $MM\
+    $#     $if months[month]:
+    $#         </a>\
+    $#     &thinsp;\
+    $# </div>
+    </li>
+</ul>
+<p>
+$for category in get_categories():
+    $category
+</p>
+$if tx.user.is_owner:
+    <ul class=admin>
+    $for mount_name, mount_app in sorted(tx.app.mounts):
+        $if mount_name in public_apps:
+            $continue
+        <li><a href=/$mount_app.prefix>$mount_name.capitalize()</a></li>
+    </ul>
+<p class=system><a class=systemtext href=/system
+    title=system><span>System</span></a></p>
+</nav>
+
+<aside>
+$:livestream()
+
+<div id=bot>
+    <button>Start Bot</button>
+    <p class=partial></p>
+</div>
+
+<div id=chat>
+    <button>Join Chat</button>
+    <div id=mediasoup-demo-app-media-query-detector></div>
+    <div id=mediasoup-demo-app-container></div>
+</div>
+
+$if tx.user.is_owner:
+    <form action=/owner/sign-out method=post>
+    <button>Sign Out</button>
+    </form>
+$elif "session" in tx.user and tx.user.session.get("uid", None):
+    <p>Signed in as <a
+    href=$tx.user.session["uid"][0]>$tx.user.session["name"][0]</a>.</p>
+    <form action=/guests/sign-out method=post>
+    <button>Sign Out</button>
+    </form>
+$else:
+    <form action=/guests/sign-in style="margin:1em 0">
+    <input name=me type=text> <button>Sign In</button>
+    </form>
+
+$if "aside" in resource:
+    $:resource.aside()
+</aside>
+
+<footer style=display:none id=loading>loading</footer>
+
+<script src=https://cdn.jsdelivr.net/npm/vosk-browser@0.0.5/dist/vosk.js></script>
+<script>
+async function init_bot() {
+  const partialContainer = document.querySelector('#bot .partial')
+
+  partialContainer.textContent = 'Loading...'
+
+  const channel = new MessageChannel()
+  const model = await Vosk.createModel('/static/vosk-model-small-en-us-0.15.tar.gz')
+  model.registerPort(channel.port1)
+
+  const sampleRate = 48000
+
+  const recognizer = new model.KaldiRecognizer(sampleRate)
+  recognizer.setWords(true)
+ 
+  let ownerGivenName = '$owner["name"][0].split()[0]'
+  let wakeWord = `$${ownerGivenName.toLowerCase()} bought`
+  let readyWord = `Say "$${ownerGivenName}Bot help".`
+  let state = 'asleep'
+  partialContainer.textContent = readyWord
+
+  recognizer.on('result', message => {
+    let input = message.result.text
+    if (input.slice(0, wakeWord.length) != wakeWord) {
+      partialContainer.textContent = readyWord
+      return
+    }
+    input = input.slice(wakeWord.length + 1)
+    if (input == 'help') {
+      go('/help')
+      return
+    }
+    const response = fetch('/ai/bot', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify({request: input}),
+    })
+    .then(response => response.blob())
+    .then(blob => {
+      const audio = new Audio(URL.createObjectURL(blob));
+      audio.addEventListener('canplaythrough', () => {
+        audio.play()
+        state = 'asleep'
+        partialContainer.textContent = readyWord
+      })
+    })
+    partialContainer.textContent = `$${input}`
+  })
+  recognizer.on('partialresult', message => {
+    const input = message.result.partial
+    if (input.slice(0, wakeWord.length) != wakeWord)
+      return
+    state = 'awake'
+    partialContainer.textContent = input.slice(wakeWord.length)
+  })
+
+  const mediaStream = await navigator.mediaDevices.getUserMedia({
+    video: false,
+    audio: {
+      echoCancellation: true,
+      noiseSuppression: true,
+      channelCount: 1,
+      sampleRate
+    },
+  })
+
+  const audioContext = new AudioContext()
+  await audioContext.audioWorklet.addModule('/static/recognizer-processor.js')
+  const recognizerProcessor = new AudioWorkletNode(
+    audioContext,
+    'recognizer-processor',
+    { channelCount: 1, numberOfInputs: 1, numberOfOutputs: 1 }
+  )
+  recognizerProcessor.port.postMessage(
+    { action: 'init', recognizerId: recognizer.id },
+    [ channel.port2 ]
+  )
+  recognizerProcessor.connect(audioContext.destination)
+
+  const source = audioContext.createMediaStreamSource(mediaStream)
+  source.connect(recognizerProcessor)
+}
+</script>
+
+<script src=/chats/resources/js/antiglobal.js></script>
+<script>
+window.localStorage.setItem('debug', '* -engine* -socket* -RIE* *WARN* *ERROR*')
+if (window.antiglobal) {
+  window.antiglobal('___browserSync___oldSocketIo', 'io', '___browserSync___', '__core-js_shared__')
+  setInterval(window.antiglobal, 180000)
+}
+
+function init_chat() {
+  var tag = document.createElement('script')
+  tag.src = '/chats/mediasoup-demo-app.js'
+  document.getElementsByTagName('head')[0].appendChild(tag)
+
+  const autoMuter = setInterval(autoMute, 100)
+  function autoMute() {
+    if (typeof window.CLIENT !== 'undefined' && window.CLIENT._micProducer) {
+      window.CLIENT.muteMic()
+      clearInterval(autoMuter)
+    }
+  }
+}
+</script>
+
+<script>
+window.onload = () => {
+  const botTrigger = document.querySelector('#bot button')
+  botTrigger.onmouseup = () => {
+      botTrigger.disabled = true
+      botTrigger.style.display = 'none'
+      init_bot()
+  }
+  const chatTrigger = document.querySelector('#chat button')
+  chatTrigger.onmouseup = () => {
+      chatTrigger.disabled = true
+      chatTrigger.style.display = 'none'
+      init_chat()
+  }
+}
+</script>
+
+<script src=https://cdn.jsdelivr.net/npm/webtorrent@latest/webtorrent.min.js></script>
+<script src=https://cdn.jsdelivr.net/npm/drag-drop@latest/dragdrop.min.js></script>
+<script type=module>
+const client = new WebTorrent()
+DragDrop('body', (files, pos, fileList, directories) => {
+  console.log(files[0])
+  // client.seed(files, torrent => {
+  //   console.log('Client is seeding ' + torrent.magnetURI)
+  // })
+})
+</script>
+
+</body>
+</html>

index 0000000..1cf9849
--- /dev/null

+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
+
+[tool.poetry]
+name = "canopy-platform"
+version = "0.0.63"
+description = "a decentralized social web platform"
+keywords = ["IndieWeb", "ActivityPub"]
+homepage = "https://ragt.ag/code/projects/canopy"
+repository = "https://ragt.ag/code/projects/canopy.git"
+documentation = "https://ragt.ag/code/projects/canopy/api"
+authors = ["Angelo Gladding <angelo@ragt.ag>"]
+license = "BSD-2-Clause"
+packages = [{include="canopy"}]
+
+[tool.pyright]
+reportGeneralTypeIssues = false
+reportOptionalMemberAccess = false
+
+[tool.poetry.plugins."websites"]
+canopy = "canopy:app"
+
+[[tool.poetry.source]]
+name = "main"
+url = "https://ragt.ag/code/pypi"
+
+[tool.poetry.dependencies]
+python = ">=3.10,<3.11"
+webint = "^0.1"
+webint-ai = "^0.0"
+webint-auth = "^0.0"
+webint-code = "^0.0"
+webint-data = "^0.0"
+webint-editor = "^0.0"
+webint-guests = "^0.0"
+webint-live = "^0.0"
+webint-media = "^0.0"
+webint-mentions = "^0.0"
+webint-owner = "^0.0"
+webint-player = "^0.0"
+webint-posts = "^0.0"
+webint-search = "^0.0"
+webint-sites = "^0.0"
+webint-system = "^0.0"
+webint-tracker = "^0.0"
+
+[tool.poetry.group.dev.dependencies]
+gmpg = {path="../gmpg", develop=true}
+bgq = {path="../bgq", develop=true}
+easyuri = {path="../easyuri", develop=true}
+newmath = {path="../newmath", develop=true}
+sqlyte = {path="../sqlyte", develop=true}
+txtint = {path="../txtint", develop=true}
+webint = {path="../webint", develop=true}
+webagt = {path="../webagt", develop=true}
+microformats = {path="../python-microformats", develop=true}
+indieauth = {path="../python-indieauth", develop=true}
+micropub = {path="../python-micropub", develop=true}
+webint-ai = {path="../webint-ai", develop=true}
+webint-auth = {path="../webint-auth", develop=true}
+webint-code = {path="../webint-code", develop=true}
+webint-data = {path="../webint-data", develop=true}
+webint-editor = {path="../webint-editor", develop=true}
+webint-guests = {path="../webint-guests", develop=true}
+webint-live = {path="../webint-live", develop=true}
+webint-media = {path="../webint-media", develop=true}
+webint-mentions = {path="../webint-mentions", develop=true}
+webint-owner = {path="../webint-owner", develop=true}
+webint-player = {path="../webint-player", develop=true}
+webint-posts = {path="../webint-posts", develop=true}
+webint-search = {path="../webint-search", develop=true}
+webint-sites = {path="../webint-sites", develop=true}
+webint-system = {path="../webint-system", develop=true}
+webint-tracker = {path="../webint-tracker", develop=true}