misc-etc/dec18-flutter.html

1224 lines
179 KiB
HTML
Raw Permalink Normal View History

2025-12-19 02:31:04 +00:00
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="darkreader-lock">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src https: data:; manifest-src data:;"/>
<meta name="referrer" content="no-referrer"/>
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content"/>
<meta property="og:title" content="Utter"/>
<meta property="og:description" name="description" content="Have offline single-keyboard conversations with your f/os, designed for sharing"/>
<meta property="og:type" content="website"/>
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0iIzU0NTA5ZSIgZD0iTTEyIDNDNi41IDMgMiA2LjU4IDIgMTFhNy4yMTggNy4yMTggMCAwIDAgMi43NSA1LjVjMCAuNi0uNDIgMi4xNy0yLjc1IDQuNSAyLjM3LS4xMSA0LjY0LTEgNi40Ny0yLjUgMS4xNC4zMyAyLjM0LjUgMy41My41IDUuNSAwIDEwLTMuNTggMTAtOHMtNC41LTgtMTAtOCIvPjxwYXRoIGZpbGw9IiNlY2YzZjYiIGQ9Ik0xMiAxN2MtNC40MiAwLTgtMi42OS04LTZzMy41OC02IDgtNiA4IDIuNjkgOCA2LTMuNTggNi04IDYiLz48cGF0aCBmaWxsPSIjMDBkY2FmIiBkPSJNNy41IDEydi0yaC0ydjJ6Ii8+PHBhdGggZmlsbD0iI2ZjMCIgZD0iTTExLjE2NyAxMnYtMmgtMnYyaDIiLz48cGF0aCBmaWxsPSIjYjhjNmY5IiBkPSJNMTQuODMzIDEydi0yaC0ydjJoMiIvPjxwYXRoIGZpbGw9IiNmZjI0NTIiIGQ9Ik0xOC41IDEydi0yaC0ydjJoMiIvPjwvc3ZnPg==">
<link rel="manifest" href="data:application/json;base64,ewoJIm5hbWUiOiAiVXR0ZXIiLAoJImRlc2NyaXB0aW9uIjogIkhhdmUgb2ZmbGluZSBzaW5nbGUta2V5Ym9hcmQgY29udmVyc2F0aW9ucyBkZXNpZ25lZCBmb3Igc2hhcmluZyIsCgkiaWNvbnMiOiBbCgkJewoJCQkic3JjIjogImRhdGE6aW1hZ2Uvc3ZnK3htbDtiYXNlNjQsUEhOMlp5QjRiV3h1Y3owaWFIUjBjRG92TDNkM2R5NTNNeTV2Y21jdk1qQXdNQzl6ZG1jaUlIWnBaWGRDYjNnOUlqQWdNQ0F5TkNBeU5DSStQSEJoZEdnZ1ptbHNiRDBpSXpVME5UQTVaU0lnWkQwaVRURXlJRE5ETmk0MUlETWdNaUEyTGpVNElESWdNVEZoTnk0eU1UZ2dOeTR5TVRnZ01DQXdJREFnTWk0M05TQTFMalZqTUNBdU5pMHVORElnTWk0eE55MHlMamMxSURRdU5TQXlMak0zTFM0eE1TQTBMalkwTFRFZ05pNDBOeTB5TGpVZ01TNHhOQzR6TXlBeUxqTTBMalVnTXk0MU15NDFJRFV1TlNBd0lERXdMVE11TlRnZ01UQXRPSE10TkM0MUxUZ3RNVEF0T0NJdlBqeHdZWFJvSUdacGJHdzlJaU5sWTJZelpqWWlJR1E5SWsweE1pQXhOMk10TkM0ME1pQXdMVGd0TWk0Mk9TMDRMVFp6TXk0MU9DMDJJRGd0TmlBNElESXVOamtnT0NBMkxUTXVOVGdnTmkwNElEWWlMejQ4Y0dGMGFDQm1hV3hzUFNJak1EQmtZMkZtSWlCa1BTSk5OeTQxSURFeWRpMHlhQzB5ZGpKNklpOCtQSEJoZEdnZ1ptbHNiRDBpSTJaak1DSWdaRDBpVFRFeExqRTJOeUF4TW5ZdE1tZ3RNbll5YURJaUx6NDhjR0YwYUNCbWFXeHNQU0lqWWpoak5tWTVJaUJrUFNKTk1UUXVPRE16SURFeWRpMHlhQzB5ZGpKb01pSXZQanh3WVhSb0lHWnBiR3c5SWlObVpqSTBOVElpSUdROUlrMHhPQzQxSURFeWRpMHlhQzB5ZGpKb01pSXZQand2YzNablBnPT0iLAoJCQkidHlwZSI6ICJpbWFnZS9zdmcreG1sIiwKCQkJInNpemVzIjogImFueSIKCQl9LAoJCXsKCQkJInNyYyI6ICJkYXRhOmltYWdlL3BuZztiYXNlNjQsaVZCT1J3MEtHZ29BQUFBTlNVaEVVZ0FBQU1BQUFBREFDQU1BQUFCbEFwdzFBQUFBd0ZCTVZFVkhjRXhVVUo1VVVaNWFXcGRVVUo1VVVKNVRVSjVVVUo1VVVKNVVUNTVVVUo1VVVKNVVVSjVUVDU1VVVKNVRVSjVXVDU1VVVKNVVVSjVVVHFGVFVKNVRVSjVWVDUxVVVKNVVVSjVVVUo1VVVKNUtTcjlVVUo1VVVKN3M4L1lBM0svL0pGTC96QUM0eHZubTdmSlhVNkNQa01HVWxNUGo2ZkdqcGN4dGE2M0F4TjNlNU82RmhicHhjSy9aM3V0aFhxWHA4UFhhNVBmeTVxUDUyVkxLMWZqSzBPTjlmTFpkV2FQUDFPYkZ5ZCtabXNhcHJORFQyT2kzdWRkb1pxcXh0TlJQMVJoWkFBQUFIWFJTVGxNQS9CRUY3TEhlYkpVdHllWEJSRktsR29MMEM1dzVJM2ZXWDlBQmlYU2ZpVEVBQUFXNlNVUkJWSGphN05WSG1xcEFGQVhnSWloQkZOdWM3cDBvSWxFbW9xSyszdit1M2x4Q0U2cEtCL1d2NEZ6T3FROGlDSUlnQ0lJZ0NJSWdDSUlnQ0hUSTg1RzJzM3ZtYXFDcUVvQ2txb09WMmJOMzJtZ3VmM3Ywb1RZelZhaWdtak50K0pWbmJBM043a05OZlZzenR1U0xqTmUyQ2cycDlucE12c0pDczZDbDFZL3g4VytmUzkvNGhnWDVHR1ZrU2RDWlpJMlV6M3o4bndGUU12Z1o4MSsrUGdHS0pOc2dQQms5b0s1bjhJdHZBeE9jV3BCMUNSaVJiUFp2UVZsT2dLSEpVaUZNelRmQTJHcEkySm5xRWpBbjZWTm1uNzhQWFBUWmxLRHNKS2puSElYSDArdCtlOGFCaStnRzhmTjJmNTJPWVhTdVc4Sk9JZFNOclZyWnc1TWZZQ24zOG5BOHFNRmNFTXFHS3Z6Rnkzd1hhM0Q5N084alZNb3owaVNvZEExL1kyd2cvZzJ2VUVsYTBweS9EbFVPNld1UGplMzk4QUJWZElWYS9oNVVpQjRCdGhROElxalFteElxWkF2S3BUNTJjcW1xd1pTcDVOK1ViOGQ1WW1meHNmeUVqVXdodjFrZVAwQXFBdWRRZmdHNy9Pa05xWG1tckZha1dGRE04NUVxMzROaWxrSTYyTm9sNjhrU3BDdzVsZXpJM3BMMmRsREl1eUFEdHdnSzdVaHJJeWprN0pHSmZWWmN3b2kwWkV5Z3dOVkhadndyRkpnWXBKWHBDZ3I4ZXlKRHNRY0ZWbFBTeGd3S3BDNHk1YVpRUUtmMkFJNEpNcFk0bEo2QlBJQThCems0UXQ1QXBqSWdCN2s0MGhqUnZEby8vd3ZtcEpHdENlOE9JWElUUW82NUpVMnNJU2ZhSXpmN0NITFdwQUdsRCsvT01YSVVuK0ZkWCtsVXdPR09YRjBPK1FvNkZYQkN6azVkS3NnWEVDWElXUkoxK0p1WjhPNkNPZnhIWkpHYURIaVg0UWRrOEwrOWUxRnFFNGpDQUh5NEp3RWlBUUpwOEtlWGFEUjFPcjJxY1J6dCs3OVZaNXgyZEhjaUJBU1cwOW52Q1Z4aDJjdTVSRGFsNDB3Zytib3BGZGg4YmJrY1d6WWs5NlVTOTVEWUZoMWpYdkVBRkQrQ3NOMDI3ckpVNUJLU2xJNVErQkRkWHBTS1hOeEM1QmR0dmtIYlVwbHRtKzlRRHNtK1ZPWWFrcHpxT1JBOWxnbzlRdVJRUGI5aUVWTyttUGxVSzRMa3JsVG9EcEtJNm9RUW5XMUtoVFpualZjQ0Q2SXY1YlAza3ZLbG54OUZQOG9Ydm44Uy9TcGYraUFwbjMyQnlHdThFZHFxSGNCNTQrMlFBOUc5MmdIY1ErUTBQZ3Q4Vmp1QXp4RE5xSTRMMFY3dEFQWVF1VlRIaHVoSzdRQ3VJTEtwVGd6UlJ1MEFMaUNLcVk0QjBUZTFBL2dHa2ZIL0Q4RG0vZ3E1M0NmeG12dG4xT0cra0UyNGJ5VnlpTTY1YmVibUVKeXgyMDVuM0E4MFZzejhTRW5CbUE3MTIrYUhlanJsZnEweWgrU2EyY1dXYVl6bmF2RzhWWWhqeHZ4eWx4Yk1yOWNwNHg3Z29OVklRa3lYTFVOTWxETVA4bEZrakNMTXVtMGRaaVdIZWFDN0NDSGJEWjlxc0h0RHFrR3hIbWV5QjZkMG03dUtkQnUyQ1UrY1VzNTJxSGdBTFdjQldDWDlVVGl5dEV0alNRMmRNRTk4cFNnZVZlcHhRbzE1T09DY1QvSTNGUTRPdUxsaWszNVBrYzI4QUlKQ2cxc0ppbXd4ZEJFUTBNMEVrS2VCN1BHNjdNSDFEZ2N0cUwxa3hyd1FqaUlYaCswZXlrN2RkVldLS010OHhjV2dDYjFSNWc5UWp0dHJTWFRtY2l1SWxrV3JYa3ZTOGJyQXBFNGt6SnNDVUZGTVVPbm1kNXUyREE4MWJSbFNpenFUQUlNM3h2Q29ReG1Pc05zZTI1cGtPM2hya2hDQy9wdkRCQkYxS2tjVHQ3dWJwL1k4KzMvdGVmWlA3WGx1ZHJjNGpyR3dxRnNPQk93YUpDVXhCT3hhVk0weG5QV1NCTXplSU51enFIdG1QTmpiWTFJZmNnekNPTW1vRjVhUENnejZYWHE4LzN3cVhGUmcwRERWUTc4TVoxNVFqMHdiZlZybEVmVnJndDRZc3dIYU5rOE4xSEdYVTgreDBaQS9UT1BzSWtBZHgvemJ1dHh4Y2FSVittNUtBblV6Mk
<noscript><style>#prepare{display:none}</style></noscript>
<title>Flutter</title>
<style>
body {
background: #2C3433;
color: #FFF;
font-family: sans-serif;
display: flex;
flex-direction: column;
min-height: 100vh;
margin: 0;
color-scheme: dark;
}
code {
font-family: monospace;
padding: 4px 2px;
background: #F001;
font-size: 14px;
}
#prepare {
text-align: center;
line-height: 1.4;
padding: 12px;
}
#prepare, #app {
flex-grow: 1;
}
#app {
flex-direction: column;
}
#chatbox, #send {
background: #FFF2;
border: 2px #FFF4 solid;
opacity: 0.5;
color: #FFF;
border-radius: 4px;
transition: opacity 0.6s;
&:hover, &:focus {
opacity: 0.9;
}
&:active {
opacity: 1;
}
}
#chatbox {
font-family: inherit;
font-size: inherit;
padding: 8px;
flex-grow: 1;
resize: vertical;
min-height: 42px;
box-sizing: border-box;
}
@media print {
.noprint {
display: none;
}
}
#chatwrapper {
display: flex;
flex-direction: row;
padding: 8px;
gap: 8px;
position: sticky;
bottom: 0;
left: 0;
right: 0;
z-index: 30;
background: #2C3433CC;
}
#proxyindicator {
aspect-ratio: 1/1;
height: 42px;
body.AvatarShape-SQUARE & {
border-radius: 0px;
}
body.AvatarShape-ROUNDED & {
border-radius: 4px;
}
body.AvatarShape-CIRCLE & {
border-radius: 24px;
}
}
#send {
width: 42px;
height: 42px;
svg {
width: 24px;
height: 24px;
}
svg path {
fill: currentColor;
}
}
#title {
padding: 8px;
}
a:link, a:visited {
color: inherit;
}
#messages {
flex-grow: 1;
padding-left: 12px;
display: flex;
flex-direction: column;
justify-content: end;
& > div {
display: flex;
flex-direction: row;
.bubble {
padding: 8px 12px;
margin-left: 8px;
margin-bottom: 4px;
border-radius: 8px;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
body.BubbleBackground-COLORED & {
background: rgba(from var(--color) r g b / 0.2);
}
body.BubbleBackground-LIGHT & {
background: #CCC;
color: #000;
}
body.BubbleBackground-DARK & {
background: #222;
}
}
&.initial .bubble {
border-top-left-radius: 8px;
}
&.final .bubble {
border-bottom-left-radius: 8px;
}
.text {
white-space: pre-wrap;
p {
margin: 0;
margin-top: 12px;
}
p:first-child {
margin-top: 0;
}
p:first-child:last-child {
display: contents;
}
ul, ol {
margin-top: 0;
margin-bottom: 0;
}
}
.name {
display: none;
color: oklch(from var(--color) 0.9 c h);
body.BubbleBackground-LIGHT & {
color: oklch(from var(--color) 0.4 c h);
}
}
img {
width: 48px;
height: 4px;
object-fit: contain;
visibility: hidden;
flex-shrink: 0;
}
&.initial {
padding-left: 0;
margin-top: 12px;
img {
visibility: visible;
height: 48px;
body.AvatarShape-SQUARE & {
border-radius: 0px;
}
body.AvatarShape-ROUNDED & {
border-radius: 4px;
}
body.AvatarShape-CIRCLE & {
border-radius: 24px;
}
}
.name {
display: block;
font-weight: bold;
}
}
&:after {
content: "";
clear: both;
display: table;
}
}
}
footer {
opacity: 0.5;
text-align: center;
padding-bottom: 12px;
}
h6 {
margin: 0;
font-weight: normal;
font-size: 14px;
opacity: 0.8;
}
.options {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
div {
width: 260px;
height: 200px;
background-size: cover;
background-position: center;
background-blend-mode: overlay;
border-radius: 16px;
display: flex;
justify-content: flex-end;
flex-direction: column;
align-items: center;
text-align: center;
font-weight: bold;
box-sizing: border-box;
border: 8px #000A solid;
border-bottom-width: 4px;
border-left-color: #0006;
border-top: 2px #FFFA solid;
overflow: hidden;
cursor: pointer;
transition: filter 0.4s, opacity 0.4s;
opacity: 0.8;
&:hover, &:focus {
opacity: 1;
filter: brightness(110%);
}
&:active {
opacity: 1;
filter: brightness(125%);
}
span {
background: #000A;
padding: 6px;
width: 100%;
box-sizing: border-box;
}
}
}
#noscript {
text-align: center;
padding: 16px;
font-size: 24px;
.fakeout {
font-family: serif;
font-size: 16px;
animation-name: goaway;
animation-duration: .5s;
animation-delay: .5s;
animation-fill-mode: forwards;
}
.realmessage {
opacity: 0;
animation-name: goaway;
animation-duration: .5s;
animation-delay: .5s;
animation-direction: reverse;
animation-fill-mode: forwards;
}
}
.dialog-outer {
position: fixed;
top: 0; left: 0;
width: 100%;
height: 100%;
background: #0028;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
.dialog {
position: fixed;
background: #2C3433;
z-index: 1000;
border-radius: 16px;
box-shadow: 0px 2px 8px 4px #000, inset 0px -4px 0px 2px #54509e88;
overflow: hidden;
max-height: 90vh;
display: flex;
flex-direction: column;
.them {
display: flex;
flex-direction: column;
max-width: 480px;
width: 100vw;
padding: 32px;
gap: 16px;
box-sizing: border-box;
overflow-y: auto;
flex-shrink: 1;
&.scroll {
overflow-y: scroll;
}
&:has(hr:first-child) {
padding-top: 0;
overflow-y: hidden;
flex-shrink: 0;
}
.input-field {
display: flex;
flex-direction: column;
gap: 8px;
input, button, select {
font-size: inherit;
font-family: inherit;
padding: 4px;
}
.beside {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
input {
flex-grow: 1;
}
}
img {
width: 96px;
height: 96px;
object-fit: contain;
}
}
.proxy {
.beside {
gap: 0px;
input {
width: 4px;
}
.prefix {
text-align: right;
padding-right: 0;
}
.suffix {
text-align: left;
padding-left: 0;
}
}
}
.member {
border-left: 4px var(--color) solid;
display: flex;
flex-direction: row;
gap: 8px;
padding: 8px;
align-items: center;
background-color: transparent;
transition: background-color 0.4s;
cursor: pointer;
img {
width: 48px;
height: 48px;
}
span {
font-size: 18px;
}
&:hover, &:focus {
background-color: rgba(from var(--color) r g b / 0.1);
}
&:active {
background-color: rgba(from var(--color) r g b / 0.2);
}
}
}
hr {
width: 100%;
height: 1px;
min-height: 1px;
max-height: 1px;
flex-shrink: 0;
border: 0;
background: currentColor;
}
b {
font-size: 24px;
display: block;
padding: 12px 32px;
background: #54509e;
}
}
}
.warning {
b {
color: #F06;
}
border: 2px #F03 solid;
background-image: repeating-linear-gradient(60deg,
#F03F 5px,
#F032 5px,
#F032 9px,
#F03F 10px,
#F03F 14px,
#F032 15px,
#F032 20px
), linear-gradient(#F03, #F03);
background-size: 12px 100%, 2px 100%;
background-repeat: no-repeat;
background-position: 0px 0px, 12px 0px;
background-color: #F002;
padding: 12px;
padding-left: 14px;
max-width: 720px;
border-radius: 8px;
margin: auto;
text-wrap: balance;
}
input[type=file] {
display: none;
}
@keyframes goaway {
from { opacity: 1 }
to { opacity: 0 }
}
.inappfooter {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
padding: 0 8px;
span {
padding: 8px 0;
white-space: pre-wrap;
}
}
.gap {
flex-grow: 1;
}
.plural-journey {
image-rendering: pixelated;
aspect-ratio: 3/2;
height: 20px;
vertical-align: middle;
border: 2px black solid;
}
</style>
</head>
<body>
<noscript>
<div id="noscript">
<div class="fakeout" aria-hidden>You need to enable JavaScript to run this app.</div>
<div class="realmessage">
<b>Flutter requires JavaScript to operate</b>, as it runs fully inside your browser.<br/>
Please enable JavaScript and reload the page. <a href="https://git.sleeping.town/krynesthesia/Utter">Read the source?</a>
<p>
<small>And no, it's not a React app.</small>
</div>
</div>
</noscript>
<div id="prepare">
<h1>Flutter, a playground fork of Utter v1.7.1</h1>
A tool for ephemeral single-keyboard conversations, this instance geared toward self-shippers, ready to share with alt text.<br/>
<p>
<div class="warning">
<b>Warning</b>: <i>Flutter does not save or upload message data anywhere</i>. Reloading the page, closing your browser, apps getting background killed, rebooting your phone, etc will all <b>delete every message</b>. It is <i>ephemeral</i>.
</div>
<p>
<h2>Get Started</h2>
<div class="options">
<div id="scratch" style="background-color: #2C5; background-image: url('data:image/webp;base64,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
<span>Start from Scratch</span>
</div>
</div>
<input type="file" id="pluralkit-upload" accept=".json,application/json">
<input type="file" id="tupperbox-upload" accept=".json,application/json">
<p>
Data is stored solely in your browser for later use.<br>
<b>It will not be uploaded anywhere</b>, this tool runs entirely in your browser.<br/>
Flutter can be installed as a PWA if your browser supports that, or you can just save this page to an HTML file and open it.
<p>
<small>* PluralKit's format has become something of a de-facto standard, so other JSONs also work here — such as /plu/ral, and many tools such as Simply Plural can export in PluralKit's format.</small>
<footer>
Copyright © 2025 <a href="https://exa.y2k.diy">Exa</a> and contributors
— no frameworks, no AI
<a href="https://donate.y2k.diy">help us buy food</a>
<a href="https://git.sleeping.town/krynesthesia/Utter">read the source / report bugs</a><br/>
<br/>
PluralKit logo by @tedkalashnikov on Discord, original Myriad design by @layl on Discord.
</footer>
</div>
<div id="app" style="display:none">
<div id="messages">
<i id="credit">generated by utter.y2k.diy</i>
</div>
<div id="chatwrapper" class="noprint">
<img src="" id="proxyindicator">
<textarea id="chatbox" rows="1"></textarea>
<button id="send">
<svg viewBox="0 0 26 26"><path d="M24.906 0a.99.99 0 0 0-.375.125l-24 13a.985.985 0 0 0-.504.969.99.99 0 0 0 .692.844l6.375 1.906c.148 1.18.812 6.285.937 7.281.125.992.797 1.164 1.469.25.453-.617 3.125-4.375 3.125-4.375l5.688 5.688a.99.99 0 0 0 1.656-.438l6-24A.99.99 0 0 0 24.906 0ZM23.47 2.938l-5.032 20.125-5.656-5.657L21 6 8.219 15.125 3.563 13.75Z"/></svg>
</button>
</div>
<div class="noprint inappfooter">
<span><a href="#" id="copyalt">copy alt text</a></span>
<span><a href="https://donate.y2k.diy">help us buy food</a></span>
<span><a href="#" data-open="options">options</a></span>
<span> • utter v1.7.1 by <a href="https://exa.y2k.diy">exa</a> and contributors ♥</span>
<span class="gap"></span>
<img class="plural-journey" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAECAIAAADAusJtAAAAGElEQVR42mPQPLGAQbf+AUO29X4GQ0NDAC18BRYQeR0nAAAAAElFTkSuQmCC">
</div>
</div>
<div id="options" class="dialog-outer" style="display:none">
<div class="dialog">
<b>options</b>
<div class="them">
<a data-open="settings" data-close="options" href="#">general settings</a>
<a data-open="system" data-close="options" href="#">edit system information</a>
<a data-open="members" data-close="options" href="#">edit members</a>
<a id="export-system" href="#">export system information in PluralKit format</a>
<a id="reset" href="#" style="color:#F00">reset all data</a>
<hr>
<a href="https://git.sleeping.town/krynesthesia/Utter">view the source code</a>
<a href="https://git.sleeping.town/krynesthesia/Utter/issues">report a bug or request a feature</a>
<hr>
<a data-close="options" href="#">close this dialog</a>
</div>
</div>
</div>
<div id="settings" class="dialog-outer" style="display:none">
<div class="dialog">
<b>general settings</b>
<div class="them">
<div class="input-field">
<label for="autoproxy-mode">autoproxy mode</label>
<select id="autoproxy-mode">
<option value="DEFAULT" selected>none</option>
<option value="LATCH">latch</option>
</select>
</div>
<div class="input-field">
<label for="avatar-shape">avatar shape</label>
<select id="avatar-shape">
<option value="SQUARE">square</option>
<option value="ROUNDED" selected>rounded</option>
<option value="CIRCLE">circle</option>
</select>
</div>
<div class="input-field">
<label for="bubble-background">bubble background</label>
<select id="bubble-background">
<option value="COLORED" selected>colored</option>
<option value="LIGHT">light</option>
<option value="DARK">dark</option>
</select>
</div>
<hr>
<a data-open="options" data-close="settings" href="#">return</a>
</div>
</div>
</div>
<div id="system" class="dialog-outer" style="display:none">
<div class="dialog">
<b>system</b>
<div class="them">
<div class="input-field">
<label for="system-name">name</label>
<input type="text" id="system-name" placeholder="<none>">
</div>
<div class="input-field">
<label for="system-color">color</label>
<div class="beside">
<input type="color" id="system-color">
<input type="text" id="system-color-text" pattern="^#[0-9A-Fa-f]{6}$">
</div>
</div>
<div class="input-field">
<label for="system-avatar">avatar</label>
<img id="system-avatar-preview">
<div class="beside">
<input type="text" id="system-avatar" placeholder="<none>">
<button id="system-avatar-upload">load from file…</button>
</div>
</div>
<hr>
<a data-open="members" data-close="system" href="#">edit members</a>
<a data-open="options" data-close="system" href="#">return</a>
</div>
</div>
</div>
<div id="members" class="dialog-outer" style="display:none">
<div class="dialog">
<b>members</b>
<div class="them scroll" id="members-target">
<div class="member" style="--color: #FFF" id="newmember">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE5LDEzSDEzVjE5SDExVjEzSDVWMTFIMTFWNUgxM1YxMUgxOVYxM1oiIGZpbGw9IiNGRkYiLz48L3N2Zz4K">
<span>New Member</span>
</div>
</div>
<div class="them">
<hr>
<a data-open="options" data-close="members" href="#">return</a>
</div>
</div>
</div>
<div id="editmember" class="dialog-outer" style="display:none">
<div class="dialog">
<b>edit member</b>
<div class="them scroll">
<div class="input-field">
<label for="member-name">name</label>
<input type="text" id="member-name" placeholder="<none>">
</div>
<div class="input-field">
<label for="member-color">color</label>
<div class="beside">
<input type="color" id="member-color">
<input type="text" id="member-color-text" pattern="^#[0-9A-Fa-f]{6}$">
</div>
</div>
<div class="input-field">
<label for="member-avatar">avatar</label>
<img id="member-avatar-preview">
<div class="beside">
<input type="text" id="member-avatar" placeholder="<none>">
<button id="member-avatar-upload">load from file…</button>
</div>
</div>
<label id="proxies-target">proxies</label>
<div class="input-field proxy">
<div class="beside">
<input type="text" class="prefix">
<span>text</span>
<input type="text" class="suffix">
</div>
</div>
<div class="input-field" id="newproxy">
<button>add new</button>
</div>
<small>(blank out the prefix and suffix for a proxy to remove it)</small>
</div>
<div class="them">
<hr>
<div style="display:flex;flex-direction:row">
<a id="finisheditingmember" data-open="members" data-close="editmember" href="#">return</a>
<div style="flex-grow:1"></div>
<a id="deletemember" data-open="members" data-close="editmember" href="#" style="color:#F00">delete</a>
</div>
</div>
</div>
</div>
<input type="file" id="system-avatar-upload-field" accept="image/*">
<input type="file" id="member-avatar-upload-field" accept="image/*">
<script>
/*! markdown-it 14.1.0 https://github.com/markdown-it/markdown-it @license MIT */
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).markdownit=e()}(this,(function(){"use strict";const t={};function e(r,n){"string"!=typeof n&&(n=e.defaultChars);const s=function(e){let r=t[e];if(r)return r;r=t[e]=[];for(let t=0;t<128;t++){const e=String.fromCharCode(t);r.push(e)}for(let t=0;t<e.length;t++){const n=e.charCodeAt(t);r[n]="%"+("0"+n.toString(16).toUpperCase()).slice(-2)}return r}(n);return r.replace(/(%[a-f0-9]{2})+/gi,(function(t){let e="";for(let r=0,n=t.length;r<n;r+=3){const i=parseInt(t.slice(r+1,r+3),16);if(i<128)e+=s[i];else{if(192==(224&i)&&r+3<n){const n=parseInt(t.slice(r+4,r+6),16);if(128==(192&n)){const t=i<<6&1984|63&n;e+=t<128?"\ufffd\ufffd":String.fromCharCode(t),r+=3;continue}}if(224==(240&i)&&r+6<n){const n=parseInt(t.slice(r+4,r+6),16),s=parseInt(t.slice(r+7,r+9),16);if(128==(192&n)&&128==(192&s)){const t=i<<12&61440|n<<6&4032|63&s;e+=t<2048||t>=55296&&t<=57343?"\ufffd\ufffd\ufffd":String.fromCharCode(t),r+=6;continue}}if(240==(248&i)&&r+9<n){const n=parseInt(t.slice(r+4,r+6),16),s=parseInt(t.slice(r+7,r+9),16),o=parseInt(t.slice(r+10,r+12),16);if(128==(192&n)&&128==(192&s)&&128==(192&o)){let t=i<<18&1835008|n<<12&258048|s<<6&4032|63&o;t<65536||t>1114111?e+="\ufffd\ufffd\ufffd\ufffd":(t-=65536,e+=String.fromCharCode(55296+(t>>10),56320+(1023&t))),r+=9;continue}}e+="\ufffd"}}return e}))}e.defaultChars=";/?:@&=+$,#",e.componentChars="";const r={};function n(t,e,s){"string"!=typeof e&&(s=e,e=n.defaultChars),void 0===s&&(s=!0);const i=function(t){let e=r[t];if(e)return e;e=r[t]=[];for(let t=0;t<128;t++){const r=String.fromCharCode(t);/^[0-9a-z]$/i.test(r)?e.push(r):e.push("%"+("0"+t.toString(16).toUpperCase()).slice(-2))}for(let r=0;r<t.length;r++)e[t.charCodeAt(r)]=t[r];return e}(e);let o="";for(let e=0,r=t.length;e<r;e++){const n=t.charCodeAt(e);if(s&&37===n&&e+2<r&&/^[0-9a-f]{2}$/i.test(t.slice(e+1,e+3)))o+=t.slice(e,e+3),e+=2;else if(n<128)o+=i[n];else if(n>=55296&&n<=57343){if(n>=55296&&n<=56319&&e+1<r){const r=t.charCodeAt(e+1);if(r>=56320&&r<=57343){o+=encodeURIComponent(t[e]+t[e+1]),e++;continue}}o+="%EF%BF%BD"}else o+=encodeURIComponent(t[e])}return o}function s(t){let e="";return e+=t.protocol||"",e+=t.slashes?"//":"",e+=t.auth?t.auth+"@":"",t.hostname&&-1!==t.hostname.indexOf(":")?e+="["+t.hostname+"]":e+=t.hostname||"",e+=t.port?":"+t.port:"",e+=t.pathname||"",e+=t.search||"",e+=t.hash||"",e}function i(){this.protocol=null,this.slashes=null,this.auth=null,this.port=null,this.hostname=null,this.hash=null,this.search=null,this.pathname=null}n.defaultChars=";/?:@&=+$,-_.!~*'()#",n.componentChars="-_.!~*'()";const o=/^([a-z0-9.+-]+:)/i,u=/:[0-9]*$/,c=/^(\/\/?(?!\/)[^\?\s]*)(\?[^\s]*)?$/,a=["{","}","|","\\","^","`"].concat(["<",">",'"',"`"," ","\r","\n","\t"]),l=["'"].concat(a),h=["%","/","?",";","#"].concat(l),p=["/","?","#"],f=/^[+a-z0-9A-Z_-]{0,63}$/,d=/^([+a-z0-9A-Z_-]{0,63})(.*)$/,_={javascript:!0,"javascript:":!0},m={http:!0,https:!0,ftp:!0,gopher:!0,file:!0,"http:":!0,"https:":!0,"ftp:":!0,"gopher:":!0,"file:":!0};function g(t,e){if(t&&t instanceof i)return t;const r=new i;return r.parse(t,e),r}i.prototype.parse=function(t,e){let r,n,s,i=t;if(i=i.trim(),!e&&1===t.split("#").length){const t=c.exec(i);if(t)return this.pathname=t[1],t[2]&&(this.search=t[2]),this}let u=o.exec(i);if(u&&(u=u[0],r=u.toLowerCase(),this.protocol=u,i=i.substr(u.length)),(e||u||i.match(/^\/\/[^@\/]+@[^@\/]+/))&&(s="//"===i.substr(0,2),!s||u&&_[u]||(i=i.substr(2),this.slashes=!0)),!_[u]&&(s||u&&!m[u])){let t,e,r=-1;for(let t=0;t<p.length;t++)n=i.indexOf(p[t]),-1!==n&&(-1===r||n<r)&&(r=n);e=-1===r?i.lastIndexOf("@"):i.lastIndexOf("@",r),-1!==e&&(t=i.slice(0,e),i=i.slice(e+1),this.auth=t),r=-1;for(let t=0;t<h.length;t++)n=i.indexOf(h[t]),-1!==n&&(-1===r||n<r)&&(r=n);-1===r&&(r=i.length),":"===i[r-1]&&r--;const s=i.slice(0,r);i=i.slice(r),this.parseHost(s),this.hostname=this.hostname||"";const o="["===this.hostname[0]&&"]"===this.hostname[this.hostname.length-1];if(!o){cons
</script>
<script>
// bootleg jquery
const $ = document.querySelector.bind(document);
const $$ = (s) => Array.prototype.slice.apply(document.querySelectorAll(s));
EventTarget.prototype.on = EventTarget.prototype.addEventListener;
const ProxyMode = {
DEFAULT: "DEFAULT",
LATCH: "LATCH",
};
const AvatarShape = {
SQUARE: "SQUARE",
ROUNDED: "ROUNDED",
CIRCLE: "CIRCLE",
};
const BubbleBackground = {
COLORED: "COLORED",
LIGHT: "LIGHT",
DARK: "DARK",
};
const md = markdownit();
const defaultProxyImg = $("link[rel=icon]").href;
const chatbox = $("#chatbox");
const proxyindicator = $("#proxyindicator");
const messages = $("#messages");
let system = null;
proxyindicator.src = defaultProxyImg;
let selectedMember = {name:"This should not appear",color:"FF00FF",avatar_url:"data:image/webp;base64,UklGRioAAABXRUJQVlA4TB0AAAAvf8AfABAIJPurB/D8z3//J6D54PffgPOb338DDgA=",proxy_tags:[]};
const initializers = [];
let lastSender = null;
let lastMsg = null;
let altText = [];
let latchedMember = null;
function getProxyMode() {
return system.config?.utter?.autoproxy_mode ?? ProxyMode.DEFAULT;
}
function getAvatarShape() {
return system.config?.utter?.avatar_shape ?? AvatarShape.ROUNDED;
}
function getBubbleBackground() {
return system.config?.utter?.bubble_background ?? BubbleBackground.COLORED;
}
function utterCfg() {
if (!system.config) system.config = {};
if (!system.config.utter) system.config.utter = {};
return system.config.utter;
}
Object.entries({
"system-name": {
get: () => system.name,
set: it => { system.name = it },
},
"system-color": {
get: () => system.color ? '#'+system.color : '#AAAAAA',
set: it => { system.color = it.substring(1) },
},
"system-avatar": {
get: () => system.avatar_url,
set: it => { system.avatar_url = it },
},
"member-name": {
get: () => selectedMember.name,
set: it => { selectedMember.name = it },
},
"member-color": {
get: () => selectedMember.color ? '#'+selectedMember.color : '#AAAAAA',
set: it => { selectedMember.color = it.substring(1) },
},
"member-avatar": {
get: () => selectedMember.avatar_url,
set: it => { selectedMember.avatar_url = it },
},
"autoproxy-mode": {
get: () => getProxyMode(),
set: it => { utterCfg().autoproxy_mode = it },
},
"avatar-shape": {
get: () => getAvatarShape(),
set: it => { utterCfg().avatar_shape = it },
},
"bubble-background": {
get: () => getBubbleBackground(),
set: it => { utterCfg().bubble_background = it },
},
}).forEach(([id, {get, set}]) => {
const e = $("#"+id);
e.on('input', () => {
set(e.value);
save();
});
initializers.push(() => {
e.value = get();
e.dispatchEvent(new CustomEvent("input"));
});
});
function populateProxies() {
const t = $("#proxies-target");
const rm = []
for (const e of t.parentElement.children) {
if (e.classList.contains("proxy")) {
rm.push(e);
}
}
rm.forEach(e => e.remove());
selectedMember.proxy_tags.forEach(tag => {
const field = document.createElement("div");
field.className = "input-field proxy";
const beside = document.createElement("div");
beside.className = "beside";
field.appendChild(beside);
const prefix = document.createElement("input");
prefix.type = "text";
prefix.className = "prefix";
prefix.value = tag.prefix ?? "";
beside.appendChild(prefix);
prefix.on('input', () => {
tag.prefix = prefix.value;
save();
});
const text = document.createElement("span");
text.textContent = "text";
beside.appendChild(text);
const suffix = document.createElement("input");
suffix.type = "text";
suffix.className = "suffix";
suffix.value = tag.suffix ?? "";
beside.appendChild(suffix);
suffix.on('input', () => {
tag.suffix = suffix.value;
save();
});
t.after(field);
});
}
initializers.push(populateProxies);
function save() {
for (let v of Object.values(AvatarShape)) {
document.body.classList.remove("AvatarShape-"+v);
}
for (let v of Object.values(BubbleBackground)) {
document.body.classList.remove("BubbleBackground-"+v);
}
document.body.classList.add("AvatarShape-"+getAvatarShape());
document.body.classList.add("BubbleBackground-"+getBubbleBackground());
system.members.forEach(m => delete m.matched_proxy_tag);
localStorage.setItem('system', JSON.stringify(system));
}
function assert(b, msg) {
if (!b) throw new Error("Assertion failed: "+msg);
}
function populateMembers() {
const t = $("#members-target");
const rm = []
for (const e of t.children) {
if (e.id === "newmember") continue;
rm.push(e);
}
rm.forEach(e => e.remove());
system.members.forEach(m => {
const e = document.createElement('div');
e.className = "member";
e.style.setProperty("--color", '#'+(m.color || 'AAA'));
const img = document.createElement('img');
setAvatar(m, img);
e.appendChild(img);
const name = document.createElement('span');
name.textContent = m.name;
e.appendChild(name);
t.prepend(e);
e.on('click', () => {
selectedMember = m;
initializers.forEach(f => f());
$("#members").style.display = 'none';
$("#editmember").style.display = 'flex';
});
});
}
$("#newmember").on('click', () => {
selectedMember = {
id: randid(),
name: "New Member",
color: 'AAA',
avatar_url: null,
proxy_tags: []
};
system.members.push(selectedMember);
initializers.forEach(f => f());
$("#members").style.display = 'none';
$("#editmember").style.display = 'flex';
});
$("#finisheditingmember").on('click', () => {
selectedMember.proxy_tags = selectedMember.proxy_tags.filter(t => t.prefix?.length || t.suffix?.length);
populateMembers();
});
$("#deletemember").on('click', (e) => {
e.preventDefault();
system.members = system.members.filter(m => m.id !== selectedMember.id);
populateMembers();
save();
});
$("#newproxy").on('click', () => {
selectedMember.proxy_tags.push({
prefix: "example: ",
suffix: ""
});
populateProxies();
save();
});
function start() {
$("#prepare").style.display = 'none';
$("#app").style.display = 'flex';
system = JSON.parse(localStorage.getItem('system'));
proxyindicator.src = system.avatar_url || defaultProxyImg;
populateMembers();
initializers.forEach(f => f());
}
if (localStorage.getItem('system')) {
start();
}
function randid() {
return "utr"+Math.floor(Math.random()*65535).toString(16);
}
function entwine(a, b) {
a.on('click', b.click.bind(b));
}
function entangle(a, b) {
let reentering = false;
function _(a, b) {
a.on('input', () => {
if (reentering) return;
reentering = true;
b.value = a.value
b.dispatchEvent(new CustomEvent("input"));
reentering = false;
});
}
_(a, b);
_(b, a);
}
entwine($("#pluralkit"), $("#pluralkit-upload"));
entwine($("#tupperbox"), $("#tupperbox-upload"));
entangle($("#system-color"), $("#system-color-text"));
entangle($("#member-color"), $("#member-color-text"));
$("#scratch").on('click', () => {
localStorage.setItem('system', JSON.stringify({
id: randid(),
name: "New System",
color: null,
avatar_url: null,
members: []
}));
start();
$("#system").style.display = "flex";
});
$$("[data-open]").forEach((e) => {
e.on('click', (v) => {
v.preventDefault();
$('#'+e.dataset.open).style.display = 'flex';
});
});
$$("[data-close]").forEach((e) => {
e.on('click', (v) => {
v.preventDefault();
$('#'+e.dataset.close).style.display = 'none';
});
});
const pkupload = $("#pluralkit-upload");
pkupload.value = null;
pkupload.on('input', () => {
const fr = new FileReader();
fr.onload = () => {
try {
const j = JSON.parse(fr.result);
assert(j, "JSON is null");
assert(j.members, "Members array is missing");
assert(j.members.length > 0, "No members are defined");
j.members.forEach((m) => {
if (typeof m.color === "number") {
// /plu/ral puts the naked int into the JSON
m.color = (m.color+0xF000000).toString(16).substring(1);
}
});
localStorage.setItem('system', fr.result);
start();
} catch (e) {
alert("Error parsing JSON: "+e);
}
};
fr.onerror = () => {
alert("Error reading file");
};
fr.readAsText(pkupload.files[0]);
});
const tbupload = $("#tupperbox-upload");
tbupload.value = null;
tbupload.on('input', () => {
const fr = new FileReader();
fr.onload = () => {
try {
const j = JSON.parse(fr.result);
assert(j, "JSON is null");
assert(j.tuppers, "Tuppers array is missing");
assert(j.tuppers.length > 0, "No tuppers are defined");
localStorage.setItem('system', JSON.stringify({
id: "imported-tupperbox-system-"+randid(),
members: j.tuppers.map(t => ({
id: t.id,
name: t.name,
avatar_url: t.avatar_url,
keep_proxy: t.show_brackets,
proxy_tags: t.brackets ? [
{
prefix: t.brackets[0],
suffix: t.brackets[1]
}
] : []
}))
}));
start();
} catch (e) {
alert("Error parsing JSON: "+e);
}
};
fr.onerror = () => {
alert("Error reading file");
};
fr.readAsText(tbupload.files[0]);
});
function uploadfield(idprefix) {
const target = $("#"+idprefix);
const field = $("#"+idprefix+"-upload-field");
const preview = $("#"+idprefix+"-preview");
entwine($("#"+idprefix+"-upload"), field);
field.on('input', () => {
const fr = new FileReader();
fr.onload = () => {
target.value = fr.result;
target.dispatchEvent(new CustomEvent('input'));
};
fr.onerror = () => {
alert("Error reading file");
};
fr.readAsDataURL(field.files[0]);
});
target.on('input', () => {
preview.src = target.value;
});
preview.src = target.value;
}
uploadfield("system-avatar");
uploadfield("member-avatar");
function hasProxyTag(text, tag) {
return ((!tag.prefix || text.startsWith(tag.prefix)) && (!tag.suffix || text.endsWith(tag.suffix)));
}
function getMember(text) {
const searchedMember = system.members.find(m => {
return m.proxy_tags.find(t => {
if (hasProxyTag(text, t)) {
m.matched_proxy_tag = t;
return true;
}
return false;
});
});
if (searchedMember) {
return searchedMember;
} else if (getProxyMode() === ProxyMode.LATCH) {
if (text.startsWith("\\")) return;
if (latchedMember) {
delete latchedMember.matched_proxy_tag;
return latchedMember;
}
}
return;
}
function colorblock(color) {
return 'data:image/svg+xml;base64,'+btoa('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1" fill="#'+color+'"/></svg>');
}
function setAvatar(member, ele) {
if (!ele) ele = proxyindicator;
if (ele.src && ele.src === member.avatar_url) return;
const cb = colorblock(member.color || 'AAA');
ele.src = cb;
if (!member.avatar_url) return;
const img = new Image();
img.onload = () => {
ele.src = img.src;
};
img.onerror = () => {
console.warn("Failed to load "+member.avatar_url+", falling back on color block");
};
img.src = member.avatar_url;
}
function send() {
let text = chatbox.value;
chatbox.value = "";
chatbox.rows = 1;
const member = getMember(text) ?? system;
if (!member.keep_proxy && member.matched_proxy_tag) {
const t = member.matched_proxy_tag;
text = text.substring(t.prefix?.length ?? 0, text.length - (t.suffix?.length ?? 0));
}
if (text.startsWith("\\") && getProxyMode() === ProxyMode.LATCH && latchedMember) {
latchedMember = null;
text = text.substring(1); // not what pk does due to inability to modify unproxied messages, but it's what it *should* do
}
text = text.trim();
if (text.length === 0) return;
const msg = document.createElement('div');
if (lastSender !== member.id) {
lastSender = member.id;
msg.classList.add('initial');
} else if (lastMsg) {
lastMsg.classList.remove('final');
}
msg.classList.add('final');
msg.style.setProperty('--color', '#'+(member.color ?? 'AAA'));
const avatar = document.createElement('img');
setAvatar(member, avatar);
msg.appendChild(avatar);
const bubble = document.createElement('div');
bubble.className = 'bubble';
const name = document.createElement('div');
name.className = 'name';
name.textContent = member.name;
bubble.appendChild(name);
const textEle = document.createElement('div');
textEle.className = 'text';
// very bad but fuck if i can be bothered to implement a proper markdown-it extension
textEle.innerHTML = md.render(text.replace(/^-# /g, "###### ")).trim();
bubble.appendChild(textEle);
msg.appendChild(bubble);
lastMsg = msg;
messages.appendChild(msg);
window.scroll(0, 99999999);
altText.push(member.name+": "+text.trim());
if (getProxyMode() !== ProxyMode.LATCH) {
setAvatar(system);
}
}
chatbox.on('input', () => {
const currentMember = getMember(chatbox.value);
if (currentMember) {
latchedMember = currentMember;
}
setAvatar(currentMember ?? system);
});
$("#send").on('click', send);
window.on('keydown', (e) => {
if (e.keyCode === 13 && document.hasFocus() && document.activeElement === chatbox) {
if (!e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
e.preventDefault();
send();
} else {
chatbox.rows++;
}
}
});
$("#copyalt").on('click', (e) => {
e.preventDefault();
window.navigator.clipboard.writeText('A chat log '+$("#credit").textContent.trim()+'.\r\n\r\n'+altText.join('\r\n'));
});
$("#reset").on('click', (e) => {
e.preventDefault();
localStorage.clear();
location.reload();
});
$("#export-system").on('click', (e) => {
e.preventDefault();
const a = document.createElement('a');
save();
a.href = 'data:application/json;utf8,'+encodeURIComponent(JSON.stringify(system));
a.download = 'utter-system.json';
a.click();
});
</script>
</body>
</html>