gubes mirror. how does this work
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

of course you have blue hair and metadata

leah b4468e64 1e2aeb4e

+154 -13
+8
core/buffer.ts
··· 1 1 import { IrcMessage } from "./parser"; 2 2 import type { Connection } from "./connection"; 3 3 import { FetchHistoryParams } from "./history"; 4 + import Metadata from "./metadata"; 4 5 5 6 export abstract class ChatBuffer implements AsyncIterable<IrcMessage> { 6 7 constructor(public name: string, public conn: Connection) { ··· 89 90 // handling for clients. 90 91 msg.source = { nick: this.conn.nickname } 91 92 return msg; 93 + } 94 + 95 + get metadata() { 96 + if (!this.conn.metadata[this.name]) { 97 + this.conn.metadata[this.name] = new Metadata(this.name, this.conn); 98 + } 99 + return this.conn.metadata[this.name]; 92 100 } 93 101 }
+1
core/capabilities.ts
··· 12 12 // "draft/event-playback", 13 13 "draft/account-registration", 14 14 "soju.im/bouncer-networks", 15 + "draft/metadata-2", 15 16 ] as const; 16 17 17 18 export async function collect_caps(conn: Connection): Promise<IrcMessage[]> {
+11 -1
core/connection.ts
··· 12 12 import { ChatBuffer } from "./buffer"; 13 13 import FileHost from "./filehost"; 14 14 import Registration from "./registration"; 15 + import Metadata from "./metadata"; 15 16 16 17 export interface ConnectionConfig { 17 18 /** ··· 467 468 list_channels = () => list_channels(this); 468 469 469 470 filehost?: FileHost; 471 + metadata: { [target: string]: Metadata } = {}; 472 + get our_metadata() { 473 + // todo: move things around on nick changes 474 + if (!this.metadata[this.nickname]) { 475 + this.metadata[this.nickname] = new Metadata(this.nickname, this); 476 + } 477 + return this.metadata[this.nickname] 478 + } 470 479 471 480 /** 472 481 * Convenience methods to check if something is supported by the connection. ··· 476 485 sasl: () => this.capabilities.has("sasl"), 477 486 batches: () => this.capabilities.has("batch"), 478 487 setname: () => this.capabilities.has("setname"), 479 - filehost: () => Boolean(this.isupport?.["soju.im/FILEHOST"]) 488 + filehost: () => Boolean(this.isupport?.["soju.im/FILEHOST"]), 489 + metadata: () => this.capabilities.has("draft/metadata-2"), 480 490 } 481 491 482 492 __debug_explode: (() => void) | undefined;
+15
core/handler.ts
··· 6 6 import { IrcChannelState, Numeric } from "./support"; 7 7 import { DirectMessage } from "./direct"; 8 8 import FileHost from "./filehost"; 9 + import Metadata from "./metadata"; 9 10 10 11 export type MessageHandler = (message: IrcMessage, connection: Connection) => Promise<void>; 11 12 ··· 233 234 case Numeric.RPL_LOGGEDIN: { 234 235 connection.account_name = message.params?.[2]; 235 236 connection.sasl!.authed = true; 237 + break; 238 + } 239 + 240 + case "METADATA": { 241 + if (!message.params || message.params.length > 4) { 242 + break; 243 + } 244 + const [target, key, _visibility, value] = message.params; 245 + if (!connection.metadata[target]) { 246 + connection.metadata[target] = new Metadata(target, connection); 247 + } 248 + 249 + connection.metadata[target].cache.set(key, value); 250 + 236 251 break; 237 252 } 238 253 }
+57
core/metadata.ts
··· 1 + import { Connection } from "." 2 + import { ArrayMatcher, Matcher } from "./queue"; 3 + import { Numeric } from "./support"; 4 + 5 + export const supported_keys = [ 6 + "avatar", 7 + "display-name", 8 + "pronouns", 9 + ] 10 + 11 + export function setup_metadata(conn: Connection) { 12 + conn.send(`METADATA * SUB ${supported_keys.join(" ")}`) 13 + } 14 + 15 + export default class Metadata { 16 + constructor(public target: string, public conn: Connection) { 17 + } 18 + 19 + cache: Map<string, string> = new Map; 20 + 21 + async fetch(key: string): Promise<string | undefined> { 22 + const cached = this.cache.get(key); 23 + if (cached) return cached; 24 + 25 + this.conn.send(`METADATA ${this.target} GET ${key}`) 26 + const resp = await this.conn.expect(`metadata response: ${key}`, 27 + new ArrayMatcher( 28 + new Matcher(Numeric.RPL_KEYVALUE), 29 + new Matcher(Numeric.RPL_KEYNOTSET), 30 + ), 31 + new ArrayMatcher( 32 + new Matcher("FAIL", "METADATA", "KEY_INVALID"), 33 + new Matcher("FAIL", "METADATA", "KEY_NO_PERMISSION"), 34 + ) 35 + ); 36 + 37 + if (resp.command == Numeric.RPL_KEYNOTSET) { 38 + return; 39 + } 40 + 41 + const value = resp.params?.at(-1); 42 + if (value) this.cache.set(key, value); 43 + 44 + return resp.params?.at(-1); 45 + } 46 + 47 + async set(key: string, value: string) { 48 + this.conn.send(`METADATA ${this.target} SET ${key} :${value}`); 49 + const resp = await this.conn.expect(`metadata response: set ${key}`, new ArrayMatcher( 50 + new Matcher(Numeric.RPL_KEYVALUE), 51 + new Matcher(Numeric.RPL_KEYNOTSET), 52 + ), new ArrayMatcher( 53 + 54 + )); 55 + this.cache.set(key, value); 56 + } 57 + }
+3
core/support.ts
··· 147 147 ERR_SASLABORTED = "906", 148 148 ERR_SASLALREADY = "907", 149 149 RPL_SASLMECHS = "908", 150 + 151 + RPL_KEYVALUE = "761", 152 + RPL_KEYNOTSET = "766", 150 153 } 151 154 152 155 /**
+59 -12
neo/src/pages/profile-settings.tsx
··· 14 14 interface ProfileDetails { 15 15 nickname: Signal<string>, 16 16 realname: Signal<string>, 17 + pronouns: Signal<string | undefined>, 17 18 } 18 19 19 20 export default function ProfileSettingsPage({ conn }: { conn: Connection }) { 20 21 const details: ProfileDetails = useMemo(() => ({ 21 22 nickname: signal(conn.nickname), 22 23 realname: signal(conn.realname), 24 + pronouns: signal(conn.our_metadata.cache.get("pronouns")), 23 25 }), []); 24 26 const error = useSignal<string>(); 25 27 const [, setLocation] = useLocation(); 28 + const extended = conn.supports.metadata(); 26 29 27 30 return <main style="--width: 48rem"> 28 31 <NetworkHeader conn={conn}> ··· 49 52 let success = true; 50 53 e.preventDefault(); 51 54 error.value = undefined; 55 + 52 56 const form = new FormData(e.currentTarget); 53 - let { nickname, realname } = Object.fromEntries(form.entries()) as Record<string, string>; 57 + const entries = form.entries().map(([k, v]) => [k, (v as string).trim()]); 58 + let { nickname, realname, pronouns } = Object.fromEntries(entries); 59 + 54 60 if (realname?.length == 0) { 55 61 realname = nickname; 56 62 } ··· 71 77 }); 72 78 } 73 79 80 + if (pronouns) { 81 + await conn.our_metadata.set("pronouns", pronouns); 82 + } 83 + 74 84 if (success) { 75 85 conn.update_config({ 76 86 nickname, ··· 94 104 <FormField label="Real Name" 95 105 flavour_text={<> 96 106 your name, pronouns, etc. doesn't actually have to be your real name 97 - {!conn.supports.setname() 98 - && details.realname.value != conn.realname 107 + {!conn.supports.setname() 108 + && details.realname.value != conn.realname 99 109 && <><br /><span class={css` 100 110 display: block; 101 111 color: var(--colour-grey-800); ··· 103 113 font-style: italic; 104 114 margin-top: .25rem; 105 115 `}> 106 - ⚠️ This network requires you to reconnect for others to see your updated real name. 107 - </span></>} 116 + ⚠️ This network requires you to reconnect for others to see your updated real name. 117 + </span></>} 108 118 </>} 109 119 > 110 120 <input ··· 114 124 onInput={e => details.realname.value = e.currentTarget.value} 115 125 /> 116 126 </FormField> 117 - <ProfilePreview details={details} /> 127 + {extended && 128 + <> 129 + <hr /> 130 + {/* <p className="body-small">Watch out! These details will not appear for people using older software.</p> */} 131 + <FormField label="Pronouns" 132 + flavour_text={<>how people should address you (e.g., they/them)</>} 133 + > 134 + <input 135 + type="text" 136 + name="pronouns" 137 + value={details.pronouns} 138 + onInput={e => details.pronouns.value = e.currentTarget.value} 139 + /> 140 + </FormField> 141 + </>} 142 + 143 + <ProfilePreview details={details} extended={conn.supports.metadata()} /> 118 144 {error.value && <ErrorMessage>{error.value}</ErrorMessage>} 119 145 <PrimaryButton type="submit">Save</PrimaryButton> 120 146 </form> ··· 122 148 } 123 149 124 150 const ProfilePreview 125 - : FunctionalComponent<{ details: ProfileDetails }> 126 - = ({ details }) => { 151 + : FunctionalComponent<{ details: ProfileDetails, extended: boolean }> 152 + = ({ details, extended }) => { 127 153 const colour = pick_colour(details.nickname.value); 128 154 return <div class={css` 129 155 grid-column: 2; ··· 132 158 padding: .5rem; 133 159 margin-top: 2rem; 134 160 transition: border 100ms cubic-bezier(0.215, 0.610, 0.355, 1); 161 + 162 + display: grid; 163 + grid-auto-rows: max-content; 164 + grid-template-columns: 1fr auto; 165 + align-items: baseline; 135 166 `}> 136 167 <p class={css` 137 168 color: var(--colour-${colour}-600); 138 169 font-variation-settings: 'GRAD' 150; 139 170 font-size: .95rem; 140 171 font-weight: 500; 141 - margin-top: 0; 142 - margin-bottom: .25rem; 172 + margin: 0; 143 173 transition: color 100ms cubic-bezier(0.215, 0.610, 0.355, 1); 144 174 `}> 145 175 {details.nickname || "..."} ··· 148 178 color: var(--colour-grey-600); 149 179 font-variation-settings: 'GRAD' 150; 150 180 font-size: .8rem; 151 - margin: .25rem 0; 152 - margin-bottom: .5rem; 181 + margin: 0; 182 + margin-top: .25rem; 183 + 184 + grid-row: 2; 185 + grid-column: 1 / span 2; 153 186 `}> 154 187 {details.realname} 155 188 </p> 189 + {extended && 190 + <p class={css` 191 + color: var(--colour-grey-400); 192 + font-variation-settings: 'GRAD' 150; 193 + font-weight: 500; 194 + font-size: .7rem; 195 + margin: 0; 196 + 197 + grid-row: 1; 198 + grid-column: 2; 199 + `}> 200 + {details.pronouns} 201 + </p>} 202 + 156 203 </div>; 157 204 }