Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Merge main into project-descriptions

G1t-Session: ses_01m4ybyg80e6qrrzww8qzs19yw # Conflicts: # services/repos/src/network.rs

syntaqxcommitted Parentsbf3a4f2f574400Browse files
106 files+1409−22274 files not counted0/106 viewed
+1−1
13301330
13311331 [[package]]
13321332 name = "g1t-desktop"
1333−version = "0.3.7"
1333+version = "0.3.8"
13341334 dependencies = [
13351335 "base64 0.22.1",
13361336 "ed25519-dalek",
+1−1
11 [package]
22 name = "g1t-desktop"
3−version = "0.3.7"
3+version = "0.3.8"
44 edition = "2024"
55 license = "MIT"
66 repository = "https://g1t.sh/flagon-io/g1t"
+4−1
6363 /// system can close it (not on Windows, whose WebView2 has no way to).
6464 pub fn setting_changed(app: &AppHandle) {
6565 let on = on(app);
66− for item in ITEMS.lock().unwrap().iter() {
66+ // Out of the lock first, as in tray::runner_changed: off the main thread
67+ // set_enabled waits for the main thread.
68+ let items = ITEMS.lock().unwrap().clone();
69+ for item in &items {
6770 let _ = item.set_enabled(usable(app));
6871 }
6972 allow(app, on);
+1−1
152152 tray::install(&handle)?;
153153 links::install(&handle);
154154 updates::schedule(handle.clone());
155− // The runner starts on a thread of its own, a few seconds after the window.
155+ // The runner starts on a thread of its own, once the site's page is up, apart from the app (runner::supervise).
156156 runner::install(&handle);
157157 Ok(())
158158 })
+134−47
66 //!
77 //! - **When.** On by default; settings turn it off ("Run jobs on this
88 //! computer while g1t is open"), and Stop in settings or the tray stops
9−//! it until Start, or until g1t starts again. It starts a few seconds
10−//! after the window, never before.
9+//! it until Start, or until g1t starts again. It starts once the site's
10+//! page has loaded (or 20 s have passed) and 5 s more, never before, on a
11+//! thread of its own that a panic restarts rather than ending the app.
1112 //! - **How.** The backend settings choose (backend.rs): Sandboxed, the
1213 //! default, where nothing a job does reaches this computer; Docker; or
1314 //! directly on this computer. The two that are not sandboxes are only
6566 use std::io::{BufRead, BufReader, Read, Write};
6667 use std::path::PathBuf;
6768 use std::process::{Child, Stdio};
68−use std::sync::Mutex;
69+use std::panic::{AssertUnwindSafe, catch_unwind};
6970 use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
71+use std::sync::{Mutex, MutexGuard, PoisonError};
7072 use std::time::{Duration, Instant, SystemTime};
7173
7274 use serde::{Deserialize, Serialize};
8688 /// What the site's page listens for: the app needs a registration token for a workspace.
8789 pub const WANTED_EVENT: &str = "g1t-runner-wanted";
8890
89−/// How long after the window the runner starts.
90−const AFTER_START: Duration = Duration::from_secs(4);
91+/// How long after the site's page has loaded the runner starts: the
92+/// window, the page and its first calls to the app come first.
93+const AFTER_START: Duration = Duration::from_secs(5);
94+/// The longest the runner waits for the site's page before starting
95+/// anyway (offline, the server picker, a site too old to say it loaded).
96+const SITE_WAIT: Duration = Duration::from_secs(20);
97+/// How long after the runner's thread failed it starts again.
98+const AFTER_PANIC: Duration = Duration::from_secs(15);
99+
100+/// A lock that a failed runner thread can't take the app down with: a
101+/// panic while holding one leaves it poisoned, and `unwrap` on the main
102+/// thread would then end the app. The data is plain state, never half
103+/// written in a way that matters, so it is used as it is.
104+trait Held<T> {
105+ fn held(&self) -> MutexGuard<'_, T>;
106+}
107+
108+impl<T> Held<T> for Mutex<T> {
109+ fn held(&self) -> MutexGuard<'_, T> {
110+ self.lock().unwrap_or_else(PoisonError::into_inner)
111+ }
112+}
91113 /// How often the thread looks at the runner when nothing has happened.
92114 const TICK: Duration = Duration::from_secs(5);
93115 /// How long after starting the first look for an update is, then how often.
331353 if let Some(registration) = Registration::load(app).filter(|r| r.server == server) {
332354 return Some(registration.workspace);
333355 }
334− let site = app.state::<Shared>().site.lock().unwrap().clone();
356+ let site = app.state::<Shared>().site.held().clone();
335357 site.current.filter(|slug| site.owned.iter().any(|o| &o.slug == slug)).or_else(|| site.owned.first().map(|o| o.slug.clone()))
336358 }
337359
359381 live: now.live,
360382 starting: now.starting.clone(),
361383 download: now.download.clone(),
362− held: !settings.runner || *app.state::<Shared>().paused.lock().unwrap(),
384+ held: !settings.runner || *app.state::<Shared>().paused.held(),
363385 update: update_facts(app, now, backend),
364386 }
365387 }
369391 /// its program while they run; a runner with no kinds of job takes it
370392 /// itself.
371393 fn update_facts(app: &AppHandle, now: &Now, backend: Backend) -> diagnosis::Update {
372− let updates = app.state::<Shared>().updates.lock().unwrap().clone();
394+ let updates = app.state::<Shared>().updates.held().clone();
373395 let sandbox = now.sandbox.as_ref().filter(|_| backend == Backend::Sandboxed);
374396 let mut waiting = Vec::new();
375397 if updates.image {
417439
418440 pub fn view(app: &AppHandle) -> View {
419441 let shared = app.state::<Shared>();
420− let now = shared.now.lock().unwrap().clone();
442+ let now = shared.now.held().clone();
421443 let settings = config::settings(app);
422444 let facts = facts(app, &now);
423445 let server = config::server(app);
431453 backend: settings.runner_backend,
432454 link: facts.workspace.as_ref().and_then(|w| server.join(&format!("{w}/-/runners")).ok()).map(|u| u.to_string()),
433455 workspace: facts.workspace,
434− workspaces: shared.site.lock().unwrap().owned.clone(),
456+ workspaces: shared.site.held().owned.clone(),
435457 enabled: settings.runner,
436− paused: *shared.paused.lock().unwrap(),
458+ paused: *shared.paused.held(),
437459 machine: facts.machine,
438460 platform: facts.platform,
439− log: shared.tail.lock().unwrap().iter().cloned().collect(),
461+ log: shared.tail.held().iter().cloned().collect(),
440462 }
441463 }
442464
443465 /// Whether the runner is running a job: what restarts g1t waits for it,
444466 /// since restarting stops the runner and would cut the job short.
445467 pub fn busy(app: &AppHandle) -> bool {
446− let now = app.state::<Shared>().now.lock().unwrap().clone();
468+ let now = app.state::<Shared>().now.held().clone();
447469 now.code == Code::Busy || now.sandbox.as_ref().is_some_and(|status| status.job().is_some())
448470 }
449471
464486 }
465487
466488 fn ask(app: &AppHandle, ask: Ask) {
467− if let Some(tx) = app.state::<Shared>().tx.lock().unwrap().as_ref() {
489+ if let Some(tx) = app.state::<Shared>().tx.held().as_ref() {
468490 let _ = tx.send(ask);
469491 }
470492 }
472494 /// Starts the thread that keeps the runner, as the app starts.
473495 pub fn install(app: &AppHandle) {
474496 let (tx, rx) = mpsc::channel();
475− *app.state::<Shared>().tx.lock().unwrap() = Some(tx.clone());
497+ *app.state::<Shared>().tx.held() = Some(tx.clone());
476498 let handle = app.clone();
477− let spawned = std::thread::Builder::new().name("g1t-runner".into()).spawn(move || Keeper::new(handle, tx).keep(rx));
499+ let spawned = std::thread::Builder::new().name("g1t-runner".into()).spawn(move || supervise(handle, tx, rx));
478500 if let Err(error) = spawned {
479501 eprintln!("g1t: the runner's thread could not start: {error}");
480502 }
481503 }
482504
505+/// The runner's thread, kept apart from the app: it starts only once the
506+/// window and the site's page are up, and a panic in it ends neither the
507+/// app nor the runner for good: the keeper starts again a little later,
508+/// reaping what the last one left, as it does after a crash of the app.
509+fn supervise(app: AppHandle, tx: Sender<Ask>, rx: Receiver<Ask>) {
510+ let mut held = match settle(&app, &rx) {
511+ Ok(held) => held,
512+ Err(done) => {
513+ let _ = done.send(());
514+ return;
515+ }
516+ };
517+ loop {
518+ let keeper = Keeper::new(app.clone(), tx.clone());
519+ let asks = std::mem::take(&mut held);
520+ match catch_unwind(AssertUnwindSafe(|| keeper.keep(&rx, asks))) {
521+ Ok(()) => return,
522+ Err(panic) => {
523+ let why = panic.downcast_ref::<&str>().map(|s| (*s).to_owned()).or_else(|| panic.downcast_ref::<String>().cloned()).unwrap_or_default();
524+ eprintln!("g1t: the runner's thread failed ({why}); starting it again in {}s", AFTER_PANIC.as_secs());
525+ // Asks that come meanwhile wait for the next keeper; Quit ends it all.
526+ let until = Instant::now() + AFTER_PANIC;
527+ while let Some(left) = until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()) {
528+ match rx.recv_timeout(left) {
529+ Ok(Ask::Quit(done)) => {
530+ let _ = done.send(());
531+ return;
532+ }
533+ Ok(ask) => held.push(ask),
534+ Err(RecvTimeoutError::Timeout) => break,
535+ Err(RecvTimeoutError::Disconnected) => return,
536+ }
537+ }
538+ }
539+ }
540+ }
541+}
542+
543+/// Waits, doing nothing the window could notice, until the site's page has
544+/// loaded (or SITE_WAIT has passed) and AFTER_START more: the runner starts
545+/// with the app, but never in its way. What is asked meanwhile is kept for
546+/// after; Quit returns its sender at once.
547+fn settle(app: &AppHandle, rx: &Receiver<Ask>) -> Result<Vec<Ask>, Sender<()>> {
548+ let began = Instant::now();
549+ let mut loaded: Option<Instant> = None;
550+ let mut held = Vec::new();
551+ loop {
552+ if loaded.is_none() && (config::site_ready(app) || began.elapsed() >= SITE_WAIT) {
553+ loaded = Some(Instant::now());
554+ }
555+ if loaded.is_some_and(|at| at.elapsed() >= AFTER_START) {
556+ return Ok(held);
557+ }
558+ match rx.recv_timeout(Duration::from_millis(250)) {
559+ Ok(Ask::Quit(done)) => return Err(done),
560+ Ok(ask) => held.push(ask),
561+ Err(RecvTimeoutError::Timeout) => {}
562+ Err(RecvTimeoutError::Disconnected) => return Ok(held),
563+ }
564+ }
565+}
566+
483567 /// Settings changed: the thread looks again.
484568 pub fn settings_changed(app: &AppHandle) {
485569 ask(app, Ask::Look);
488572
489573 /// Start or Stop, from settings or the tray.
490574 pub fn set_paused(app: &AppHandle, paused: bool) {
491− *app.state::<Shared>().paused.lock().unwrap() = paused;
575+ *app.state::<Shared>().paused.held() = paused;
492576 // Start with the setting off turns the setting on: that is what was asked.
493577 if !paused && !config::settings(app).runner {
494578 let mut settings = config::settings(app);
508592 }
509593 if on {
510594 settings.runner = true;
511− *app.state::<Shared>().paused.lock().unwrap() = false;
595+ *app.state::<Shared>().paused.held() = false;
512596 }
513597 settings::apply(app, settings)?;
514598 if on {
526610
527611 /// Retry and Restart: now, rather than when the wait is over.
528612 pub fn retry(app: &AppHandle) {
529− *app.state::<Shared>().paused.lock().unwrap() = false;
613+ *app.state::<Shared>().paused.held() = false;
530614 ask(app, Ask::Retry);
531615 }
532616
536620 let next = Site { signed_in: Some(signed_in), owned, current: current.map(|c| c.to_ascii_lowercase()) };
537621 let changed = {
538622 let shared = app.state::<Shared>();
539− let mut site = shared.site.lock().unwrap();
623+ let mut site = shared.site.held();
540624 let changed = *site != next;
541625 *site = next;
542626 changed
572656 "stop" => set_paused(app, true),
573657 "retry" | "restart" => retry(app),
574658 "remove_sandbox" => {
575− *app.state::<Shared>().paused.lock().unwrap() = false;
659+ *app.state::<Shared>().paused.held() = false;
576660 ask(app, Ask::RemoveSandbox);
577661 }
578662 "check_updates" => ask(app, Ask::Check),
657741
658742 /// The workspace a token is wanted for now, if one is: the site asks as it loads.
659743 pub fn wanted(app: &AppHandle) -> Option<String> {
660− app.state::<Shared>().wanted.lock().unwrap().clone()
744+ app.state::<Shared>().wanted.held().clone()
661745 }
662746
663747 /// The runner's log, for the settings window to open.
667751
668752 /// Stops the runner as g1t quits or restarts, and waits for it.
669753 pub fn shutdown(app: &AppHandle) {
670− let tx = app.state::<Shared>().tx.lock().unwrap().take();
754+ let tx = app.state::<Shared>().tx.held().take();
671755 if let Some(tx) = tx {
672756 let (done, wait) = mpsc::channel();
673757 if tx.send(Ask::Quit(done)).is_ok() {
825909 }
826910 }
827911
828− fn keep(mut self, rx: Receiver<Ask>) {
912+ fn keep(mut self, rx: &Receiver<Ask>, asks: Vec<Ask>) {
829913 self.reap();
830914 self.set(Code::Starting, detail("Starting after g1t's window."));
915+ for ask in asks {
916+ self.handle(ask);
917+ }
831918 loop {
832919 match rx.recv_timeout(TICK) {
833920 Ok(Ask::Quit(done)) => {
860947 }
861948
862949 fn with_updates<R>(&self, change: impl FnOnce(&mut Updates) -> R) -> R {
863− change(&mut self.app.state::<Shared>().updates.lock().unwrap())
950+ change(&mut self.app.state::<Shared>().updates.held())
864951 }
865952
866953 /// What an idle runner shows: why its last update failed, until one works.
874961 fn set(&mut self, code: Code, extra: Extra) {
875962 let changed = {
876963 let shared = self.app.state::<Shared>();
877− let mut now = shared.now.lock().unwrap();
964+ let mut now = shared.now.held();
878965 let job = if code == Code::Busy { now.job.clone() } else { None };
879966 let next = Now {
880967 code,
896983 }
897984
898985 fn code(&self) -> Code {
899− self.app.state::<Shared>().now.lock().unwrap().code
986+ self.app.state::<Shared>().now.held().code
900987 }
901988
902989 fn note(&mut self, job: Option<String>, version: Option<String>) {
903990 let shared = self.app.state::<Shared>();
904− let mut now = shared.now.lock().unwrap();
991+ let mut now = shared.now.held();
905992 if job.is_some() {
906993 now.job = job;
907994 }
9141001 fn write_log(&mut self, line: &str) {
9151002 {
9161003 let shared = self.app.state::<Shared>();
917− let mut tail = shared.tail.lock().unwrap();
1004+ let mut tail = shared.tail.held();
9181005 if tail.len() == TAIL {
9191006 tail.pop_front();
9201007 }
9691056 Ask::Checked(found) => self.checked(found),
9701057 Ask::Token { workspace, token } => {
9711058 let shared = self.app.state::<Shared>();
972− let mut wanted = shared.wanted.lock().unwrap();
1059+ let mut wanted = shared.wanted.held();
9731060 if wanted.as_deref() == Some(workspace.as_str()) {
9741061 *wanted = None;
9751062 self.token = Some((workspace, token));
9791066 Ask::Refused { workspace, allowed, why } => {
9801067 let matched = {
9811068 let shared = self.app.state::<Shared>();
982− let mut wanted = shared.wanted.lock().unwrap();
1069+ let mut wanted = shared.wanted.held();
9831070 let matched = wanted.as_deref() == Some(workspace.as_str());
9841071 if matched {
9851072 *wanted = None;
10071094 if let Some(download) = launcher::download(line) {
10081095 let changed = {
10091096 let shared = self.app.state::<Shared>();
1010− let mut now = shared.now.lock().unwrap();
1097+ let mut now = shared.now.held();
10111098 let changed = now.download.as_ref() != Some(&download);
10121099 now.download = Some(download);
10131100 changed
11171204 }
11181205 };
11191206 let shared = self.app.state::<Shared>();
1120− let mut now = shared.now.lock().unwrap();
1207+ let mut now = shared.now.held();
11211208 now.sandbox = stopped.filter(|_| how != Down::Delete);
11221209 now.starting.clear();
11231210 now.download = None;
11921279
11931280 /// Asks the site for a registration token for `workspace`.
11941281 fn want(&mut self, workspace: &str) {
1195− *self.app.state::<Shared>().wanted.lock().unwrap() = Some(workspace.to_owned());
1282+ *self.app.state::<Shared>().wanted.held() = Some(workspace.to_owned());
11961283 let _ = self.app.emit_to(EventTarget::webview(frame::SITE), WANTED_EVENT, workspace.to_owned());
11971284 self.not_before = Instant::now() + ASK_AGAIN;
1198− let signed_out = self.app.state::<Shared>().site.lock().unwrap().signed_in == Some(false);
1285+ let signed_out = self.app.state::<Shared>().site.held().signed_in == Some(false);
11991286 self.set(if signed_out { Code::SignedOut } else { Code::Registering }, Extra::default());
12001287 }
12011288
12021289 /// The thread's look round: what should run, and whether it does.
12031290 fn look(&mut self) {
12041291 let settings = config::settings(&self.app);
1205− let paused = *self.app.state::<Shared>().paused.lock().unwrap();
1292+ let paused = *self.app.state::<Shared>().paused.held();
12061293 if !settings.runner || paused {
12071294 if self.run.is_some() {
12081295 self.halt();
12091296 }
1210− *self.app.state::<Shared>().wanted.lock().unwrap() = None;
1297+ *self.app.state::<Shared>().wanted.held() = None;
12111298 self.set(if paused { Code::Stopped } else { Code::Off }, Extra::default());
12121299 return;
12131300 }
12641351 };
12651352 let Some(workspace) = workspace else {
12661353 self.not_before = Instant::now() + LOOK_AGAIN;
1267− let code = match app.state::<Shared>().site.lock().unwrap().signed_in {
1354+ let code = match app.state::<Shared>().site.held().signed_in {
12681355 Some(false) => Code::SignedOut,
12691356 Some(true) => Code::NoWorkspace,
12701357 None => Code::Starting,
14491536 self.program = None;
14501537 }
14511538 // The sandbox's image is the release's version; one older takes the newest.
1452− let sandbox = self.app.state::<Shared>().now.lock().unwrap().sandbox.clone();
1539+ let sandbox = self.app.state::<Shared>().now.held().sandbox.clone();
14531540 let launcher = self.run.as_ref().is_some_and(|run| run.engine == Engine::Launcher);
14541541 let image = launcher
14551542 && found.images
15101597 self.not_before = Instant::now();
15111598 return true;
15121599 }
1513− let Some(status) = self.app.state::<Shared>().now.lock().unwrap().sandbox.clone() else { return false };
1600+ let Some(status) = self.app.state::<Shared>().now.held().sandbox.clone() else { return false };
15141601 if self.program.is_some() {
15151602 let running = native_kinds(&status);
15161603 if running.is_empty() {
16941781 fn now_starting(&self, kinds: Vec<String>) {
16951782 let changed = {
16961783 let shared = self.app.state::<Shared>();
1697− let mut now = shared.now.lock().unwrap();
1784+ let mut now = shared.now.held();
16981785 let changed = now.starting != kinds;
16991786 now.starting = kinds;
17001787 changed
17131800 if !self.run.as_ref().is_some_and(|run| run.engine == Engine::Launcher) {
17141801 return;
17151802 }
1716− let Some(status) = self.app.state::<Shared>().now.lock().unwrap().sandbox.clone() else { return };
1803+ let Some(status) = self.app.state::<Shared>().now.held().sandbox.clone() else { return };
17171804 if !status.per_kind() {
17181805 return;
17191806 }
17671854 other => {
17681855 self.token = other;
17691856 if wanted(&self.app).is_none() {
1770− *self.app.state::<Shared>().wanted.lock().unwrap() = Some(workspace.to_owned());
1857+ *self.app.state::<Shared>().wanted.held() = Some(workspace.to_owned());
17711858 let _ = self.app.emit_to(EventTarget::webview(frame::SITE), WANTED_EVENT, workspace.to_owned());
17721859 }
17731860 return;
18841971 /// Keeps what a status says for the tray, settings and the site.
18851972 fn seen(&mut self, status: &Status) {
18861973 let shared = self.app.state::<Shared>();
1887− let mut now = shared.now.lock().unwrap();
1974+ let mut now = shared.now.held();
18881975 now.sandbox = Some(status.clone());
18891976 if let Some(version) = status.version() {
18901977 now.version = Some(version);
18941981
18951982 /// The sandbox as it is now, while it is the run.
18961983 fn sandbox_now(&mut self, status: Status) {
1897− let had = self.app.state::<Shared>().now.lock().unwrap().job.clone();
1984+ let had = self.app.state::<Shared>().now.held().job.clone();
18981985 let Some(run) = self.run.as_mut() else { return };
18991986 let step = run.step();
19001987 let job = status.job().map(launcher::Job::said);
19902077 let status = launcher::parse(&printed);
19912078 {
19922079 let shared = self.app.state::<Shared>();
1993− let mut now = shared.now.lock().unwrap();
2080+ let mut now = shared.now.held();
19942081 now.starting.clear();
19952082 now.download = None;
19962083 }
20592146 self.not_before = Instant::now() + wait;
20602147 // The cards say why from what `up` said, not from the stopped sandbox.
20612148 if let Some(failed) = status {
2062− self.app.state::<Shared>().now.lock().unwrap().sandbox = Some(failed);
2149+ self.app.state::<Shared>().now.held().sandbox = Some(failed);
20632150 }
20642151 return self.set(code, Extra { detail: Some(cause), fix, wait_seconds: Some(wait.as_secs()), ..Extra::default() });
20652152 }
+22−8
7979 }
8080
8181 /// The runner changed: its rows, and the icon's tooltip, say what it is doing now.
82+///
83+/// The change is handed to the main thread and not waited for: off the
84+/// main thread `set_text` waits for the main thread to make it, so the
85+/// runner's thread would stall on a busy window, and holding `RUNNER`
86+/// across it while the main thread came here too (the site's
87+/// `runner_offer`) froze the app, the window marked Not Responding.
8288 pub fn runner_changed(app: &AppHandle, view: &crate::runner::View) {
83− if let Some((status, toggle)) = RUNNER.lock().unwrap().as_ref() {
84− let _ = status.set_text(crate::runner::headline(view));
85− let _ = toggle.set_text(toggle_text(view));
86− }
87− if let Some(tray) = app.tray_by_id(TRAY) {
88− let _ = tray.set_tooltip(Some(format!("g1t · {}", crate::runner::headline(view))));
89− }
89+ let headline = crate::runner::headline(view);
90+ let toggle_line = toggle_text(view);
91+ let handle = app.clone();
92+ let _ = app.run_on_main_thread(move || {
93+ let rows = RUNNER.lock().unwrap().clone();
94+ if let Some((status, toggle)) = rows {
95+ let _ = status.set_text(&headline);
96+ let _ = toggle.set_text(toggle_line);
97+ }
98+ if let Some(tray) = handle.tray_by_id(TRAY) {
99+ let _ = tray.set_tooltip(Some(format!("g1t · {headline}")));
100+ }
101+ });
90102 }
91103
92104 /// The update is downloaded: the menus say so, and offer the restart.
93105 pub fn update_ready(version: &str) {
94− for item in RESTART.lock().unwrap().iter() {
106+ // Out of the lock first, as in runner_changed: this runs off the main thread.
107+ let items = RESTART.lock().unwrap().clone();
108+ for item in &items {
95109 let _ = item.set_text(format!("Restart to update to {version}"));
96110 let _ = item.set_enabled(true);
97111 }
+92−21
1717 answer arrives in the place you asked.
1818
1919 Chat is open to every member of a workspace, on every plan, the free one
20−too. Chat is never charged: no seats, no per-message price and no history
21−cutoff.
20+too. People outside it can talk in its [public channels](#public-channels)
21+and in the channels they are [asked into as guests](#guests). Chat is never
22+charged: no seats, no per-message price and no history cutoff.
2223
2324 Open **Chat** in the rail on the left, or go to
2425 `g1t.sh/<workspace>/-/chat`.
6566 A channel is a named conversation, such as `#web`, `#releases` or
6667 `#support`. Every workspace starts with `#general`, and everyone new is put
6768 in it the first time they open Chat (owners can choose other
68−[default channels](#workspace-chat-settings)). A private channel shows a
69−lock in place of `#`, in the sidebar, in Browse channels and at the top of
70−the channel.
69+[default channels](#workspace-chat-settings)).
7170
72−| | Public channel | Private channel |
73−| --- | --- | --- |
74−| Who can find it | Every member, under **Browse channels** | Only its members |
75−| Who can join | Any member, by opening it and choosing **Join** | Only people a member adds |
76−| Who can read it | Every member | Only its members |
71+Each channel is one of three kinds, and its mark says which, in the
72+sidebar, in Browse channels and at the top of the channel:
73+
74+| | Workspace `#` | Private 🔒 | Public 🌐 |
75+| --- | --- | --- | --- |
76+| Who can find it | Every member, under **Browse channels** | Only the people in it | Every member, and anyone on g1t with its link |
77+| Who can join | Any member | Only people someone in it adds, or lets in on a [request](#ask-to-join) | Anyone signed in to g1t |
78+| Who can read it | Every member | Only the people in it | Anyone signed in to g1t |
79+| Who can make one | Members, unless owners keep it to themselves | Members, unless owners keep it to themselves | Workspace owners |
7780
81+Anyone in a channel who is not a member of the workspace shows as a
82+**Guest**, beside their name on every message, in the channel's people and
83+in its details, so who is in the workspace is never in doubt.
84+
7885 ### Create a channel
7986
8087 <Steps>
8491 lowercase letters, digits, dashes and underscores. `Web Team` becomes
8592 `web-team`.
8693 3. Optionally, say what the channel is for. That line shows under its name.
87−4. Turn on **Private** if only the people you invite should find and read
88− it.
94+4. Under **Who can open it**, choose **Workspace**, **Private**, or (for
95+ workspace owners) **Public**.
8996 5. Choose **Create channel**. You are its owner and first member. Invite
9097 people once it is made.
9198
9299 </Steps>
93100
94−A workspace's owners can keep creating public channels, private channels or
95−both to themselves ([chat settings](#workspace-chat-settings)). When they
101+A workspace's owners can keep creating workspace channels, private channels
102+or both to themselves ([chat settings](#workspace-chat-settings)). When they
96103 have, **Create a channel** is turned off for everyone else and says why, and
97−someone who may create only one kind gets that kind.
104+a kind someone may not create is shown turned off, with why. Only owners
105+make a channel public.
106+
107+### Public channels
108+
109+A public channel is open to anyone signed in to g1t with a confirmed email
110+address: a community, a support desk, an open project's chat. Share its
111+link, `g1t.sh/<workspace>/-/chat/<channel>`; anyone who opens it reads it,
112+and **Join** (or simply writing in it) puts it in their sidebar.
98113
114+- People outside the workspace talk there as **guests**. They see that
115+ channel and the workspace's other public ones, and nothing else of the
116+ workspace: no other channels, no direct messages, no people directory.
117+- **@g1t works for the workspace's members only.** Guests never start a
118+ conversation with g1t, in a public channel or anywhere else, so nobody
119+ outside the workspace spends its AI.
120+- Only a workspace owner makes a channel public, when creating it or later
121+ from its details (**Open to anyone on g1t**). A private channel stays
122+ private. Turning it off keeps the channel to the workspace again: guests
123+ who joined on their own leave it, and guests someone asked in stay.
124+- A channel's owners and the workspace's owners can take anyone out of it
125+ from its details. Someone outside the workspace taken out of a public
126+ channel cannot join it again on their own; asking them in again lifts
127+ that.
128+
129+### Guests
130+
131+A guest is someone outside the workspace in one of its channels. Besides
132+joining a public channel, someone can be asked into any channel, private
133+ones too, without joining the workspace: a contractor, a customer, a
134+friend from another team.
135+
136+<Steps>
137+
138+1. Open the channel's details and choose **Add**.
139+2. Type their g1t username and choose **Invite @username as a guest**.
140+3. They see the invitation in their own Chat sidebar, under
141+ **Invitations**, and choose **Join** or **Decline**.
142+
143+</Steps>
144+
145+A guest's channels show in their own sidebar under **Other workspaces**,
146+with their unread counts. Opening one shows that workspace's chat as a
147+guest sees it, with a **Guest** mark at the top and a way back to their
148+own.
149+
150+Owners can always ask guests in. Members can too, unless owners turned off
151+**Members can invite outside collaborators** in the workspace's
152+[permissions](/guides/workspaces/#member-privileges), the same setting as
153+for repositories. Guests themselves add no one. Pending invitations show in
154+the channel's details, where whoever may take people out can take one
155+back.
156+
157+### Ask to join
158+
159+Opening the link of a channel you cannot open (a private one, or a
160+workspace one when you are not in the workspace) shows **You can't open
161+#channel** and a way to **Ask to join**, with an optional note. The page is
162+the same whether or not the channel exists, so asking never tells anyone
163+about a channel they may not see.
164+
165+The people who could add you see your request in the channel's details,
166+with a count on its members button, and choose **Let in** or **Decline**.
167+Anyone in the channel can let in a member of the workspace; someone outside
168+it is let in, as a guest, by those who may invite guests.
169+
99170 ### Join, leave and browse
100171
101−- **Browse channels** lists every public channel, and the private ones
102− you're in, at `g1t.sh/<workspace>/-/chat/browse`, and at the end of the
103− sidebar's **Channels**. Choose a channel anywhere on its row to read it,
172+- **Browse channels** lists every workspace and public channel, and the
173+ private ones you're in, at `g1t.sh/<workspace>/-/chat/browse`, and at the
174+ end of the sidebar's **Channels**. A guest sees the public ones there. Choose a channel anywhere on its row to read it,
104175 and choose **Join** to post in it and see it in your sidebar. A private channel
105176 you're not in never appears.
106177 - **Archived**, on the same page, lists archived channels.
107−- You can leave a channel, and join a public one again at any time.
178+- You can leave a channel, and join a workspace or public one again at any time.
108179 - When you join, everything already said counts as read, so a busy channel
109180 doesn't greet you with its whole history as unread.
110181
394465
395466 | Setting | Choices |
396467 | --- | --- |
397−| **Creating channels** | Who can create public and private channels (**Any member**, the default, or **Owners only**) is set with the workspace's other permissions, under [Permissions → Chat](/guides/permissions/#chat); this page says what is allowed today and leads there. |
468+| **Creating channels** | Who can create workspace and private channels (**Any member**, the default, or **Owners only**) is set with the workspace's other permissions, under [Permissions → Chat](/guides/permissions/#chat); this page says what is allowed today and leads there. |
398469 | **Renaming and archiving** | **Channel owners and workspace owners** (the default) or **Workspace owners only**. Any member of a channel can still change its topic. |
399470 | **Custom emoji** | Who can add them: **Any member** (the default) or **Owners only**. |
400−| **Default channels** | The public channels someone new is put in the first time they open Chat. `#general` unless owners choose others. People already in the workspace are not moved, and anyone can leave a default channel. |
471+| **Default channels** | The workspace and public channels someone new is put in the first time they open Chat. `#general` unless owners choose others. People already in the workspace are not moved, and anyone can leave a default channel. |
401472
402473 Changing a setting doesn't change what already exists: channels already
403474 made stay, whoever made them.
+6−4
208208 The app runs a [self-hosted runner](/guides/self-hosted-runners/) for
209209 you while it is open: your workspace's jobs that ask for `self-hosted`
210210 run on your computer, at no cost, and nothing has to be downloaded,
211−registered or started by hand. It starts a few seconds after the window
212−opens, and stops when g1t quits. Time on it is free, as on any of your
211+registered or started by hand. It starts a few seconds after g1t's page has loaded
212+(or after 20 seconds without it), and stops when g1t quits. It runs apart
213+from the window: a runner that fails starts again on its own, and never takes
214+the app down with it. Time on it is free, as on any of your
213215 runners.
214216
215217 To stop it, press **Stop** in [the runner menu](#the-runner-menu), or
261263
262264 | Setting | Default | What it does |
263265 | --- | --- | --- |
264−| **Run jobs on this computer while g1t is open** | On | Starts the runner a few seconds after the window. Off, nothing runs. |
266+| **Run jobs on this computer while g1t is open** | On | Starts the runner a few seconds after g1t's page loads. Off, nothing runs. |
265267 | **How jobs run** | Sandboxed | Sandboxed, in Docker, or directly on this computer. See [how jobs run](#how-jobs-run). |
266268 | **Linux jobs**, **Windows jobs** (sandboxed) | Both on | Each kind of job's card has its own **Stop** and **Start**, which leave the other kind as it is. |
267269 | **Workspace** | The one you were in when it first registered | The workspace whose jobs the runner runs. Lists the workspaces you own. |
320322
321323 | When | The app runs |
322324 | --- | --- |
323−| A few seconds after g1t opens | `g1t-runner sandbox up --json --state-dir <data folder>/sandbox --url <server> --name <computer> --labels desktop`, with the registration token the first time only, in its environment as `G1T_RUNNER_TOKEN`, never on its command line, where anyone on the computer could read it. The first `up` downloads and checks the sandbox's image and makes the VM, which takes a few minutes. |
325+| A few seconds after g1t's page loads | `g1t-runner sandbox up --json --state-dir <data folder>/sandbox --url <server> --name <computer> --labels desktop`, with the registration token the first time only, in its environment as `G1T_RUNNER_TOKEN`, never on its command line, where anyone on the computer could read it. The first `up` downloads and checks the sandbox's image and makes the VM, which takes a few minutes. |
324326 | Every few seconds while it runs | `g1t-runner sandbox status --json`, for the tray, the settings and the Runners page. |
325327 | When g1t quits, or you press **Stop running jobs** | `g1t-runner sandbox down`: a job that is running is cut short. |
326328 | When you press **Stop** on one kind of job's card | `g1t-runner sandbox down --only windows` (or `linux`): that kind stops, a job of its own cut short, and stays off; the other runs on. Every `up` names the kinds that are on, `--only linux,windows`, so one you stopped never starts by itself. |
+1−1
3636
3737 | Setting | What it decides | Today |
3838 | --- | --- | --- |
39−| **Public channels** | Who can create them: **Any member** or **Owners only**. | Any member |
39+| **Workspace channels** | Who can create channels anyone in the workspace can join: **Any member** or **Owners only**. Public channels, open to anyone on g1t, are always made by owners. | Any member |
4040 | **Private channels** | Who can create them: **Any member** or **Owners only**. | Any member |
4141
4242 Renaming and archiving channels, custom emoji and default channels are on
+58−11
99 deployed apps, and, on the plan, storage and git traffic past the free
1010 amounts. Each is priced from what it costs g1t, plus 20%: runner minutes
1111 at a fixed rate for each machine, everything else at its cost plus 20%.
12−It is charged to the workspace that owns the repository. There is no seat price, and on the plan there
13−are no quotas: only your spend limit stops anything.
12+It is charged to the workspace that owns the repository. There is no seat price.
13+Your spend limit bounds paid usage; public storage and service size limits still apply.
1414
1515 Wherever this page says an owner can do something to a workspace's
1616 billing, so can its [billing managers](/guides/workspaces/#roles-that-add-to-a-member):
4141 them are refused at the start of the push, with what to do: make the
4242 repository public, delete what you do not need, or start the plan, where
4343 storage past 1 GB is usage and pushes never stop. Public repositories are
44−never charged for storage, on any plan; a free workspace's public
45−repositories hold up to 5 GB together, and past it pushes to them are
46−refused the same way. On the plan, pushes to public repositories never
47−stop.
44+never charged for storage. Every workspace, including paid workspaces,
45+has a **5 GB public repository storage cap**. Pushes, imports, forks and
46+changes that would add public storage need room within that cap.
47+Recently deleted repositories still count until permanently deleted.
48+In-flight writes reserve capacity; an interrupted write may retain its
49+reservation until it is reconciled.
4850
4951 Compute is not free. Workflows, the merge queue,
5052 deployments and semantic search need [the g1t plan](#the-g1t-plan), or a
112114 - Usage past $10 is charged the same way, **up to your
113115 [spend limit](#limits)**.
114116
115−There are **no quotas** on the plan: no count of projects, previews,
116−build minutes, requests, custom domains, git operations or gigabytes
117−stops a workspace. Everything that costs g1t money is metered from the
117+The plan has no count limit on projects or previews. Public repository
118+storage has the 5 GB cap above, and repository and request size limits
119+still apply. Paid usage is metered from the
118120 first unit (past the free 1 GB of private storage and 50,000 git
119−operations that every workspace has), and only your spend limit stops
120−work.
121+operations that every workspace has). Your spend limit also stops new
122+paid work.
121123
122124 **Included usage** pays for the month's usage first, at the same prices as
123125 everything else: runner minutes, builds, app traffic, custom domains,
845847 have a 100% discount. Their usage is shown at price and kept apart from
846848 what customers pay.
847849
850+### Complimentary terms
851+
852+g1t can enroll a workspace in **complimentary** terms, such as for a
853+design partner. A new workspace is never enrolled on its own: g1t staff
854+choose each one. An enrollment gives:
855+
856+- **The plan's features**, without the plan's $21 a month.
857+- **A monthly usage allowance**, such as $100. Usage counts against it at
858+ its ordinary price, the same prices everyone pays: it is an amount of
859+ usage g1t pays for, not a discount. It starts again on the 1st of each
860+ month (UTC), and what is unused does not carry over.
861+- **A monthly fee of its own**, often none. When there is one, Stripe
862+ invoices it each month, apart from the allowance.
863+
864+**Billing** shows the allowance, what is used, what is metered but not
865+billed yet, what is left, and when it starts again. Usage that is measured
866+at the end of the month, such as storage and git operations, and work
867+running now count against what is left.
868+
869+What happens at the allowance is part of the terms, and **Billing** says
870+which:
871+
872+- **New work that costs money stops** until the month turns: new runner
873+ jobs, builds, deployments and other compute, and storage and caches past
874+ the free amounts. Nothing is ever charged for usage past it, whether or
875+ not a card is saved. Work already running when the allowance runs out
876+ finishes, and g1t covers what it cost past the allowance.
877+- **Or usage past it is charged to your card**, such as when g1t covers a
878+ set amount of an employee's or a friend's usage. Past the allowance,
879+ usage is charged at the same prices as on the plan, up to your
880+ [spend limit](#limits) and the [ceiling](#the-ceiling), and invoiced
881+ when the month closes. Add a card on **Billing** first: without one,
882+ work stops at the allowance until you do.
883+
884+If g1t changes the amount, what the month already used still counts:
885+raising it adds only the difference, and lowering it below what was used
886+leaves nothing more this month, never a bill.
887+
888+An owner or billing manager can **start the g1t plan instead** from the
889+allowance card on **Billing**. The plan uses the same Stripe customer and
890+subscription, and nothing changes until its first payment goes through:
891+if the card is declined, the bank asks to confirm the payment, or the page
892+is closed, the complimentary terms stay as they were. Usage the allowance
893+paid for before the plan started is never charged afterwards.
894+
848895 Each change is made by g1t staff and recorded with who made it and why. To
849896 ask for one, write to [support@g1t.sh](mailto:support@g1t.sh).
850897
+2−2
245245 Custom
246246 </Segment>
247247 </div>
248− <p className="mt-1.5 text-xs text-faint">Off every usage charge. 100%: nothing is charged, and the statement shows usage at price with the discount beside it.</p>
248+ <p className="mt-1.5 text-xs text-faint">Off usage charges. This does not change an existing Stripe subscription's monthly fee. A full discount requires ending that subscription first.</p>
249249 </fieldset>
250250 <div className="when-custom">
251251 <Field label="Custom discount %" hint="A whole percent, 1 to 100.">
253253 </Field>
254254 </div>
255255 <div className="grid gap-4 sm:grid-cols-2">
256− <Field label="Limit $" hint="Unpaid usage allowed; blank: trust decides. At 100%: g1t's monthly budget for it, at cost; blank: the default ($150).">
256+ <Field label="Limit $" hint="Unpaid usage allowed; blank: trust decides. At 100%: g1t's monthly budget at cost; blank: $150; zero: unlimited. This is not a monthly usage credit.">
257257 <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} />
258258 </Field>
259259 <Field label="Until" hint="Blank: no end. UTC.">
+213−0
1+/**
2+ * A workspace's complimentary enrollment: the monthly usage allowance (at
3+ * ordinary prices), the recurring monthly fee apart from it, this month's
4+ * standing, the Stripe subscription that stands for it, and its terms over
5+ * time. Staff enroll each workspace by hand; nothing enrolls one on its own.
6+ */
7+import type { ComplimentaryStanding } from "@g1t/contracts";
8+
9+import { Badge, Button, Field, Input, Notice, Section, Textarea, When } from "~/components/ui";
10+import { dollarsField, usd } from "~/lib/money";
11+import type { SectionError } from "~/lib/review";
12+
13+const STATUS: Record<ComplimentaryStanding["status"], { label: string; tone: "mint" | "warn" | "plain" }> = {
14+ active: { label: "Active", tone: "mint" },
15+ pending: { label: "Waiting for Stripe", tone: "warn" },
16+ ended: { label: "Ended", tone: "plain" },
17+};
18+
19+function allowanceText(micros: number, unlimited?: boolean) {
20+ return unlimited ? "Unlimited" : `${usd(micros, { cents: true })} a month`;
21+}
22+
23+function feeText(cents?: number) {
24+ return cents ? `${usd(cents * 10_000, { cents: true })} a month` : "Waived";
25+}
26+
27+export function ComplimentarySection({
28+ workspace,
29+ standing,
30+ unavailable,
31+ pathname,
32+ error,
33+}: {
34+ workspace: string;
35+ standing: ComplimentaryStanding | null;
36+ unavailable: string | null;
37+ pathname: string;
38+ error: SectionError;
39+}) {
40+ const values = error?.values;
41+ const open = standing && standing.status !== "ended" ? standing : null;
42+ const month = open?.month ?? null;
43+ return (
44+ <Section
45+ id="complimentary"
46+ title="Complimentary"
47+ description="The plan's features, a monthly usage allowance at ordinary prices, and a monthly fee of its own. Past the allowance new work that costs money stops; nothing is ever charged for it. No rollover."
48+ >
49+ {unavailable && <Notice tone="error">Billing could not say: {unavailable}</Notice>}
50+ {open ? (
51+ <div className="mb-4 space-y-3">
52+ <div className="flex flex-wrap items-center gap-2 text-sm">
53+ <Badge tone={STATUS[open.status].tone}>{STATUS[open.status].label}</Badge>
54+ <span className="text-fg-soft">{allowanceText(open.allowanceMicros, open.unlimited)} allowance</span>
55+ <span className="text-faint">·</span>
56+ <span className="text-fg-soft">Fee {feeText(open.feeCents)}</span>
57+ <span className="text-faint">·</span>
58+ <span className="text-fg-soft">{open.chargePastAllowance ? "Past it: their card is charged" : "Past it: work stops"}</span>
59+ </div>
60+ {month && (
61+ <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-md border border-line bg-bg px-3.5 py-2.5 text-sm sm:grid-cols-3">
62+ <Stat label="Used this month" value={usd(month.usedMicros, { cents: true })} />
63+ <Stat label="Metered, not billed yet" value={usd(month.pendingMicros ?? 0, { cents: true })} />
64+ <Stat label="Held by running work" value={usd(month.heldMicros ?? 0, { cents: true })} />
65+ <Stat label="Remaining" value={month.unlimited ? "Unlimited" : usd(month.remainingMicros, { cents: true })} />
66+ <Stat label="Covered past it by g1t" value={usd(month.overrunMicros ?? 0, { cents: true })} />
67+ <Stat label="Resets" value={<When at={month.resetsAt} />} />
68+ </dl>
69+ )}
70+ <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3">
71+ <Stat label="Money received, ever" value={usd(open.moneyReceivedMicros ?? 0, { cents: true })} />
72+ <Stat label="Promotional credit, ever" value={usd(open.promotionalCreditMicros ?? 0, { cents: true })} />
73+ <Stat label="Gifted usage, ever" value={usd(open.giftedMicros ?? 0, { cents: true })} />
74+ </dl>
75+ <p className="text-xs text-muted">
76+ Stripe {open.live ? "live" : "test"}: {open.customerId ?? "no customer yet"}, {open.subscriptionId ?? "no subscription yet"}
77+ {open.stripeStatus ? ` (${open.stripeStatus})` : ""}. Grant {open.grantId}.
78+ </p>
79+ {open.pendingOp && (
80+ <Notice tone="info">
81+ Still owed at Stripe: {open.pendingOp}. Tried again every fifteen minutes{open.lastError ? `; last error: ${open.lastError}` : "."}
82+ </Notice>
83+ )}
84+ </div>
85+ ) : (
86+ <p className="mb-4 text-sm text-muted">
87+ {standing?.status === "ended"
88+ ? `Not enrolled. The last enrollment ended ${standing.endedReason === "converted" ? "when the owner started the plan" : "by staff"}.`
89+ : "Not enrolled: the workspace is on its own plan."}
90+ </p>
91+ )}
92+
93+ <form method="post" action={`${pathname}#complimentary`} className="space-y-4">
94+ <input type="hidden" name="intent" value="complimentary" />
95+ {error && <Notice tone="error">{error.error}</Notice>}
96+ <div className="grid gap-4 sm:grid-cols-2">
97+ <Field label="Monthly allowance $" hint="Usage at ordinary prices, each calendar month (UTC). Changing it keeps what this month used.">
98+ <Input
99+ name="allowance"
100+ inputMode="decimal"
101+ placeholder="100.00"
102+ defaultValue={values?.allowance ?? (open ? dollarsField(open.allowanceMicros) : "100.00")}
103+ />
104+ </Field>
105+ <Field label="Monthly fee $" hint="Recurring, invoiced by Stripe, apart from the allowance. Blank or 0: waived.">
106+ <Input name="fee" inputMode="decimal" placeholder="Waived" defaultValue={values?.fee ?? (open?.feeCents ? dollarsField(open.feeCents * 10_000) : "")} />
107+ </Field>
108+ </div>
109+ <fieldset className="space-y-2 text-sm">
110+ <legend className="mb-1 text-xs text-faint">Past the allowance</legend>
111+ <label className="flex cursor-pointer items-start gap-2.5">
112+ <input
113+ type="radio"
114+ name="past"
115+ value="stop"
116+ defaultChecked={values ? values.past !== "charge" : !open?.chargePastAllowance}
117+ className="mt-0.5"
118+ />
119+ <span>
120+ <span className="block font-medium">New paid work stops</span>
121+ <span className="block text-xs text-muted">Nothing is ever charged. For partners g1t carries entirely.</span>
122+ </span>
123+ </label>
124+ <label className="flex cursor-pointer items-start gap-2.5">
125+ <input
126+ type="radio"
127+ name="past"
128+ value="charge"
129+ defaultChecked={values ? values.past === "charge" : Boolean(open?.chargePastAllowance)}
130+ className="mt-0.5"
131+ />
132+ <span>
133+ <span className="block font-medium">Their card is charged</span>
134+ <span className="block text-xs text-muted">
135+ g1t covers the allowance; past it, usage is charged to the workspace's own card at the usual prices, up to its spend
136+ limit. For employees and friends. Without a card, work stops until they add one.
137+ </span>
138+ </span>
139+ </label>
140+ </fieldset>
141+ <label className="flex cursor-pointer items-start gap-2.5 text-sm">
142+ <input type="checkbox" name="unlimited" defaultChecked={values ? values.unlimited === "on" : Boolean(open?.unlimited)} className="mt-0.5" />
143+ <span>
144+ <span className="block font-medium">Unlimited allowance</span>
145+ <span className="block text-xs text-muted">For g1t's own workspaces. Infrastructure limits still apply.</span>
146+ </span>
147+ </label>
148+ <Field label="Note" hint="Required. Why, for whoever looks next.">
149+ <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner, $500 a month through Q1" />
150+ </Field>
151+ <Field label={`Type ${workspace} to confirm`}>
152+ <Input name="confirmation" autoComplete="off" defaultValue="" />
153+ </Field>
154+ <div className="flex justify-end">
155+ <Button type="submit">{open ? "Change terms" : "Enroll"}</Button>
156+ </div>
157+ </form>
158+
159+ {open && (
160+ <form method="post" action={`${pathname}#complimentary`} className="mt-6 space-y-3 border-t border-line pt-4">
161+ <input type="hidden" name="intent" value="end-complimentary" />
162+ <p className="text-sm text-muted">
163+ Ending it puts the workspace on Free now and cancels its Stripe subscription. Usage the allowance paid for stays paid for.
164+ </p>
165+ <div className="grid gap-3 sm:grid-cols-2">
166+ <Field label="Note">
167+ <Input name="note" required maxLength={500} placeholder="Why" />
168+ </Field>
169+ <Field label={`Type ${workspace} to end`}>
170+ <Input name="confirmation" autoComplete="off" />
171+ </Field>
172+ </div>
173+ <div className="flex justify-end">
174+ <Button type="submit" variant="danger">
175+ End enrollment
176+ </Button>
177+ </div>
178+ </form>
179+ )}
180+
181+ {standing?.periods && standing.periods.length > 0 && (
182+ <div className="mt-6 border-t border-line pt-4">
183+ <p className="text-xs text-faint">Terms over time</p>
184+ <ul className="mt-2 space-y-1 text-xs">
185+ {standing.periods.map((period) => (
186+ <li key={period.id} className="flex flex-wrap gap-x-2 text-fg-soft">
187+ <When at={period.startsAt} />
188+ <span>→</span>
189+ {period.endsAt ? <When at={period.endsAt} /> : <span>now</span>}
190+ <span>
191+ {allowanceText(period.allowanceMicros, period.unlimited)}, fee {feeText(period.feeCents)},{" "}
192+ {period.chargePastAllowance ? "then their card" : "then stops"}
193+ </span>
194+ <span className="text-muted">
195+ {period.createdBy}: {period.note}
196+ </span>
197+ </li>
198+ ))}
199+ </ul>
200+ </div>
201+ )}
202+ </Section>
203+ );
204+}
205+
206+function Stat({ label, value }: { label: string; value: React.ReactNode }) {
207+ return (
208+ <div>
209+ <dt className="text-xs text-faint">{label}</dt>
210+ <dd className="tabular-nums text-fg-soft">{value}</dd>
211+ </div>
212+ );
213+}
+27−1
99 import { data, redirect } from "react-router";
1010
1111 import { parseCreditForm } from "./credits";
12−import { fields, parseAllowances, parseEmail, parseGoodwill, parseNote, parsePayment, parseSlug, parseTerms, text } from "./forms";
12+import { fields, parseAllowances, parseComplimentary, parseEmail, parseGoodwill, parseNote, parsePayment, parseSlug, parseTerms, text } from "./forms";
1313 import { FORGIVE_COST_MICROS, goodwillWarning } from "./pricing";
1414 import type { ActionData } from "./review";
1515 import { admin, identity, priceBook } from "./services.server";
6161 return back("allowances");
6262 }
6363
64+ if (intent === "complimentary" || intent === "end-complimentary") {
65+ // Enrollment is per workspace, never an enterprise's.
66+ const values = fields(form, "allowance", "unlimited", "fee", "past", "note", "confirmation");
67+ if (subject.kind !== "workspace") return failed("complimentary", "Enroll each workspace on its own page.", values);
68+ if (subject.billedTo) return failed("complimentary", `${subject.billedTo.name} pays for this workspace. Detach it first.`, values);
69+ if (intent === "end-complimentary") {
70+ const note = parseNote(values.note);
71+ if (!note.ok) return failed("complimentary", note.error, values);
72+ if (values.confirmation !== subject.slug) {
73+ return failed("complimentary", `Type the workspace's slug, ${subject.slug}, to end its enrollment. It goes back to Free now.`, values);
74+ }
75+ const ended = await admin.endComplimentary(subject.slug, note.value, staff.email);
76+ if (!ended.ok) return failed("complimentary", ended.error.message, values);
77+ return back("complimentary-ended");
78+ }
79+ const terms = parseComplimentary(form);
80+ if (!terms.ok) return failed("complimentary", terms.error, values);
81+ if (values.confirmation !== subject.slug) {
82+ return failed("complimentary", `Type the workspace's slug, ${subject.slug}, to confirm these terms.`, values);
83+ }
84+ const { note, ...chosen } = terms.value;
85+ const saved = await admin.setComplimentary(subject.slug, chosen, note, staff.email);
86+ if (!saved.ok) return failed("complimentary", saved.error.message, values);
87+ return back("complimentary");
88+ }
89+
6490 if (intent === "attach") {
6591 const values = fields(form, "workspace", "target");
6692 const section = isEnterprise ? "members" : "billed-to";
+33−0
235235 };
236236 }
237237
238+/** The most a complimentary allowance or fee can be: billing's caps. */
239+export const MAX_COMPLIMENTARY_MICROS = 100_000 * MICROS_PER_DOLLAR;
240+
241+export type ComplimentaryInput = { allowanceMicros: number; unlimited: boolean; feeCents: number; chargePastAllowance: boolean; note: string };
242+
243+/**
244+ * A complimentary enrollment's terms, from its form: the monthly usage
245+ * allowance (at ordinary prices) or unlimited, and the monthly fee (blank or
246+ * 0: waived). The two are separate amounts. A note is required.
247+ */
248+export function parseComplimentary(form: FormData): Parsed<ComplimentaryInput> {
249+ const unlimited = text(form, "unlimited") === "on";
250+ const rawAllowance = text(form, "allowance");
251+ const allowanceMicros = unlimited && !rawAllowance ? 0 : parseDollars(rawAllowance);
252+ if (allowanceMicros == null || allowanceMicros < 0) {
253+ return { ok: false, error: "The monthly allowance is dollars and cents, such as 100 or 500, valued at ordinary usage prices." };
254+ }
255+ if (allowanceMicros > MAX_COMPLIMENTARY_MICROS) return { ok: false, error: "The allowance is at most $100,000 a month; for more, make it unlimited." };
256+ const rawFee = text(form, "fee");
257+ const feeMicros = rawFee ? parseDollars(rawFee) : 0;
258+ if (feeMicros == null || feeMicros < 0) return { ok: false, error: "The monthly fee is dollars and cents, or blank to waive it." };
259+ if (feeMicros > MAX_COMPLIMENTARY_MICROS) return { ok: false, error: "The monthly fee is at most $100,000." };
260+ if (feeMicros % 10_000 !== 0) return { ok: false, error: "The monthly fee is whole cents." };
261+ if (!unlimited && allowanceMicros === 0 && feeMicros === 0) {
262+ return { ok: false, error: "No allowance and no fee gives nothing: set an allowance, or end the enrollment." };
263+ }
264+ const note = parseNote(text(form, "note"));
265+ if (!note.ok) return note;
266+ const chargePastAllowance = text(form, "past") === "charge";
267+ if (unlimited && chargePastAllowance) return { ok: false, error: "An unlimited allowance has nothing past it to charge." };
268+ return { ok: true, value: { allowanceMicros, unlimited, feeCents: feeMicros / 10_000, chargePastAllowance, note: note.value } };
269+}
270+
238271 /** The most one recorded payment can be; billing refuses more. */
239272 export const MAX_PAYMENT_MICROS = 100_000 * MICROS_PER_DOLLAR;
240273 const MAX_REFERENCE = 100;
+2−0
2525 export const DONE: Record<string, string> = {
2626 terms: "Terms saved. They apply to charges from now on.",
2727 allowances: "Plan, pools and caps saved. They apply from now on.",
28+ complimentary: "Complimentary terms saved. A new enrollment shows to the workspace once Stripe has its subscription, usually within seconds.",
29+ "complimentary-ended": "Enrollment ended. The workspace is on Free now; its Stripe subscription is canceled.",
2830 attach: "Workspace moved onto the enterprise.",
2931 detach: "Workspace moved off the enterprise. It pays for itself again.",
3032 credit: "Credit given. It is on the balance and the statement, and the owners were emailed.",
+21−2
4141 trustAbout,
4242 } from "~/components/ui";
4343 import { type Subject, billingAction } from "~/lib/billing-actions.server";
44+import { ComplimentarySection } from "~/components/complimentary";
4445 import { parseSlug, text } from "~/lib/forms";
4546 import { usd } from "~/lib/money";
4647 import { type ActionData, DONE, type SectionError, doneKey } from "~/lib/review";
9394 const figures = billedTo
9495 ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 }
9596 : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros };
96− const [enterprises, sales, invoices, plan, overages, book, credits] = await Promise.all([
97+ const [enterprises, sales, invoices, plan, overages, book, credits, complimentary] = await Promise.all([
9798 billedTo
9899 ? Promise.resolve([])
99100 : admin
109110 settle(admin.overages()),
110111 settle(priceBook()),
111112 settle(admin.credits({ workspace: slug })),
113+ settle(admin.complimentary(slug)),
112114 ]);
113115 const done = doneKey(request.url);
114116 return {
135137 ? { grants: credits.value.grants, leftMicros: credits.value.grants.reduce((sum, grant) => sum + grant.leftMicros, 0) }
136138 : null,
137139 creditsError: credits.ok ? null : credits.error,
140+ complimentary: complimentary.ok ? complimentary.value : null,
141+ complimentaryError: complimentary.ok ? null : complimentary.error,
138142 ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger,
139143 audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit,
140144 done: done && !SALES_DONE.has(done) ? DONE[done] : null,
187191 forgiveCap,
188192 credits,
189193 creditsError,
194+ complimentary,
195+ complimentaryError,
190196 ledger,
191197 audit,
192198 done,
355361 </Section>
356362 ) : (
357363 <>
364+ <ComplimentarySection
365+ workspace={slug}
366+ standing={complimentary}
367+ unavailable={complimentaryError}
368+ pathname={pathname}
369+ error={error("complimentary")}
370+ />
358371 <TermsForm terms={terms} pathname={pathname} error={error("terms")} />
359372 <AllowancesForm allowances={allowances} comped={fullDiscount(terms)} pathname={pathname} error={error("allowances")} />
360373 </>
523536 );
524537 }
525538
526−const PLAN_LABEL: Record<string, string> = { free: "Free", paid: "The g1t plan", internal: "Internal (100% discount)", enterprise: "Enterprise" };
539+const PLAN_LABEL: Record<string, string> = {
540+ free: "Free",
541+ paid: "The g1t plan",
542+ internal: "Internal (100% discount)",
543+ enterprise: "Enterprise",
544+ complimentary: "Complimentary",
545+};
527546
528547 function gigabytes(bytes: number): string {
529548 return `${Math.round((bytes / 1e9) * 100) / 100} GB`;
+106−3
1010 import type { ReactNode } from "react";
1111 import { Form, Link } from "react-router";
1212
13−import type { BillingDetails, FeatureState, Limit, UsageReport } from "@g1t/contracts";
13+import type { BillingDetails, ComplimentaryMonth, FeatureState, Limit, UsageReport } from "@g1t/contracts";
1414
1515 import type { PlanStatus } from "../lib/billing";
1616 import { money, wholeDollars } from "../lib/money";
2222 const FIELD =
2323 "w-full rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
2424
25+// --- Complimentary ------------------------------------------------------------------------------
26+
27+/**
28+ * A complimentary enrollment's month: the allowance g1t gives, what is used,
29+ * metered and held against it, what is left and when it starts again. Past
30+ * it, new work that costs money stops: nothing is charged, card or not. An
31+ * owner can start the plan on the same billing from here.
32+ */
33+export function ComplimentaryCard({
34+ month,
35+ owner,
36+ enabled,
37+ error,
38+}: {
39+ month: ComplimentaryMonth;
40+ owner: boolean;
41+ enabled: boolean;
42+ error?: string;
43+}) {
44+ const taken = month.usedMicros + (month.pendingMicros ?? 0) + (month.heldMicros ?? 0);
45+ const resets = new Date(month.resetsAt).toLocaleDateString("en-US", { month: "long", day: "numeric", timeZone: "UTC" });
46+ const charged = Boolean(month.chargePastAllowance);
47+ const used = month.unlimited ? 0 : Math.min(100, (taken / Math.max(1, month.allowanceMicros)) * 100);
48+ return (
49+ <Card
50+ id="complimentary"
51+ tone={!month.unlimited && month.remainingMicros <= 0 ? "warn" : "accent"}
52+ icon={<Sparkles size={16} />}
53+ title="Complimentary allowance"
54+ about={
55+ charged
56+ ? `Usage at its ordinary price, paid by g1t up to the allowance each calendar month (UTC). Past it, usage is charged to this workspace's card at the same prices, up to your spend limit. Unused allowance does not carry over.`
57+ : `Usage at its ordinary price, paid by g1t up to the allowance each calendar month (UTC). Past it, new work that costs money stops until ${resets}; nothing is ever charged for it, and unused allowance does not carry over.`
58+ }
59+ aside={
60+ <p className="shrink-0 sm:text-right">
61+ <span className="text-2xl font-semibold tabular-nums tracking-tight">{month.unlimited ? "Unlimited" : money(month.allowanceMicros)}</span>
62+ {!month.unlimited && <span className="text-sm text-muted"> / month</span>}
63+ <span className="block text-xs text-faint">{month.feeCents ? `Fee ${money(month.feeCents * 10_000)} a month, invoiced` : "No monthly fee"}</span>
64+ </p>
65+ }
66+ >
67+ {!month.unlimited && (
68+ <div className="mt-4">
69+ <div className="h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
70+ <div className={`h-full rounded-full ${month.remainingMicros <= 0 ? "bg-warn" : "bg-accent"}`} style={{ width: `${used}%` }} />
71+ </div>
72+ </div>
73+ )}
74+ <dl className="mt-3 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
75+ <div>
76+ <dt className="text-xs text-faint">Used</dt>
77+ <dd className="tabular-nums">{money(month.usedMicros)}</dd>
78+ </div>
79+ <div>
80+ <dt className="text-xs text-faint">Metered, not billed yet</dt>
81+ <dd className="tabular-nums">{money((month.pendingMicros ?? 0) + (month.heldMicros ?? 0))}</dd>
82+ </div>
83+ <div>
84+ <dt className="text-xs text-faint">Remaining</dt>
85+ <dd className="tabular-nums">{month.unlimited ? "Unlimited" : money(month.remainingMicros)}</dd>
86+ </div>
87+ <div>
88+ <dt className="text-xs text-faint">Starts again</dt>
89+ <dd>{resets}</dd>
90+ </div>
91+ </dl>
92+ {charged && !month.hasCard && (
93+ <p className="mt-4 text-sm text-muted">
94+ No card yet: past the allowance, work stops until one is added.{" "}
95+ {owner && enabled && (
96+ <Form method="post" className="inline">
97+ <SubmitButton variant="outline" name="intent" value="card-check" pending="Opening Stripe…">
98+ <CreditCard size={14} />
99+ Add a card
100+ </SubmitButton>
101+ </Form>
102+ )}
103+ </p>
104+ )}
105+ {owner && enabled && !month.unlimited && (
106+ <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
107+ <SubmitButton variant="outline" name="intent" value="convert" pending="Starting the plan…">
108+ <CreditCard size={14} />
109+ Start the g1t plan instead
110+ </SubmitButton>
111+ <span className="text-xs text-faint">
112+ $21 a month with usage past what is included charged, on this workspace's billing. Nothing changes until the first payment
113+ goes through.
114+ </span>
115+ </Form>
116+ )}
117+ {error && (
118+ <div className="mt-2">
119+ <ErrorText>{error}</ErrorText>
120+ </div>
121+ )}
122+ </Card>
123+ );
124+}
125+
25126 // --- The plan ---------------------------------------------------------------------------------
26127
27128 /**
57158 }) {
58159 const plan = state?.plan;
59160 const subscription = state?.subscription ?? null;
60− const on = ["paid", "canceling", "comped", "enterprise", "past_due"].includes(status.kind);
161+ const on = ["paid", "canceling", "comped", "enterprise", "past_due", "complimentary"].includes(status.kind);
61162 const now = new Date();
62163 const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
63164 const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0));
86187 <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2100) / 100).toFixed(0)}</span>
87188 <span className="text-sm text-muted"> / month</span>
88189 <span className="block text-xs text-faint">with {wholeDollars(report?.included?.of ?? 10_000_000)} of usage included</span>
89− {status.kind !== "comped" && status.kind !== "enterprise" && <span className="block text-xs text-faint">Plus tax where it applies</span>}
190+ {status.kind !== "comped" && status.kind !== "enterprise" && status.kind !== "complimentary" && (
191+ <span className="block text-xs text-faint">Plus tax where it applies</span>
192+ )}
90193 </p>
91194 }
92195 >
+50−35
1−import { Check, Hash, Lock, Plus, Search, SquarePen, X } from "lucide-react";
1+import { Check, Globe, Hash, Lock, Plus, Search, SquarePen, X } from "lucide-react";
22 import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { useFetcher, useNavigate, useRouteLoaderData } from "react-router";
44
5−import type { ChatSidebar, Result } from "@g1t/contracts";
5+import type { ChannelVisibility, ChatSidebar, Result } from "@g1t/contracts";
66
77 import { Avatar } from "../ui/avatar";
88 import { Button } from "../ui/button";
9−import { Card } from "../ui/card";
109 import { Hint } from "../ui/hint";
1110 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
12−import { Switch } from "../ui/switch";
11+import { RadioGroup, RadioOption } from "../ui/radio-group";
1312 import { channelName, shownHandle, shownName } from "../../lib/chat";
1413 import type { ChatLayoutData } from "../../routes/workspace/chat/layout";
1514 import { useLiveCounts } from "../../lib/notify-client";
9392 * What the viewer may make, from the workspace's chat settings (Settings,
9493 * Chat). Until the sidebar arrives, everything: the chat service checks.
9594 */
96−export function useChannelCreation(): { public: boolean; private: boolean; any: boolean } {
95+export function useChannelCreation(): { public: boolean; private: boolean; any: boolean; open: boolean } {
9796 const { sidebar } = useChatSidebar();
98− const open = sidebar?.can?.create_public_channels ?? true;
97+ // `create_public_channels` is the setting's old name for channels anyone in the workspace can join.
98+ const shared = sidebar?.can?.create_public_channels ?? true;
9999 const closed = sidebar?.can?.create_private_channels ?? true;
100− return { public: open, private: closed, any: open || closed };
100+ // Open to anyone on g1t: the workspace's owners only.
101+ const open = shared && sidebar?.can?.make_public === true;
102+ return { public: shared, private: closed, any: shared || closed, open };
101103 }
102104
103105 /**
104− * Making a channel: its name, what it is for, and whether it is private.
105− * Someone the workspace's settings do not let make one sees the button
106− * turned off, with why; someone who may make only one kind gets that kind.
106+ * Making a channel: its name, what it is for, and who can open it:
107+ * private, the workspace, or anyone on g1t (owners only). Someone the
108+ * workspace's settings do not let make one sees the button turned off,
109+ * with why; a kind they may not make is shown off, with why.
107110 */
108111 export function CreateChannelButton({ slug, variant = "icon" }: { slug: string; variant?: "icon" | "button" }) {
109112 const [open, setOpen] = useState(false);
110113 const [name, setName] = useState("");
111114 const [topic, setTopic] = useState("");
112− const [secret, setSecret] = useState(false);
115+ const [chosen, setChosen] = useState<ChannelVisibility>("workspace");
113116 const [error, setError] = useState<string | null>(null);
114117 const [busy, setBusy] = useState(false);
115118 const send = useChatSend(slug);
116119 const navigate = useNavigate();
117120 const may = useChannelCreation();
118− // Only one kind allowed: that kind, and the switch says why it is fixed.
119− const isPrivate = !may.public ? true : !may.private ? false : secret;
121+ // A kind they may not make falls back to one they may.
122+ const visibility: ChannelVisibility =
123+ chosen === "private" && !may.private ? "workspace" : chosen !== "private" && !may.public ? "private" : chosen === "public" && !may.open ? "workspace" : chosen;
124+ const isPrivate = visibility === "private";
120125 const clean = channelName(name);
121126 const submit = async () => {
122127 if (!clean) return setError("Give the channel a name.");
123128 setBusy(true);
124129 setError(null);
125− const made = await send<{ to: string }>({ intent: "create_channel", name: clean, topic, private: isPrivate });
130+ const made = await send<{ to: string }>({ intent: "create_channel", name: clean, topic, private: isPrivate, visibility });
126131 setBusy(false);
127132 if (!made.ok) return setError(made.error.message);
128133 setOpen(false);
177182 <label className="grid gap-1.5">
178183 <span className="text-sm font-medium text-muted">Name</span>
179184 <span className="flex h-10 items-center gap-1.5 rounded-md border border-line bg-bg px-3 focus-within:border-accent-dim">
180− {isPrivate ? <Lock size={14} className="text-faint" /> : <Hash size={14} className="text-faint" />}
185+ {isPrivate ? <Lock size={14} className="text-faint" /> : visibility === "public" ? <Globe size={14} className="text-faint" /> : <Hash size={14} className="text-faint" />}
181186 <input
182187 autoFocus
183188 value={name}
205210 className="h-10 rounded-md border border-line bg-bg px-3 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
206211 />
207212 </label>
208− <Card asChild tone="plain" radius="lg" className="flex items-start justify-between gap-4 bg-bg/60 p-3">
209− <label>
210− <span>
211− <span className="flex items-center gap-1.5 text-sm font-medium">
212− {isPrivate ? <Lock size={13} className="text-muted" /> : <Hash size={13} className="text-muted" />}
213− {isPrivate ? "Private" : "Public"}
214− </span>
215− <span className="mt-0.5 block text-xs text-muted">
216− {!may.public
217− ? "Only workspace owners can create public channels here, so this one is private: only people you invite can find and read it."
218− : !may.private
219− ? "Only workspace owners can create private channels here, so this one is public: anyone in the workspace can find, read and join it."
220− : isPrivate
221− ? "Only people you invite can find and read it."
222− : "Anyone in the workspace can find, read and join it. Turn on to keep it to the people you invite."}
223− </span>
224− </span>
225− <Switch checked={isPrivate} onCheckedChange={setSecret} disabled={!may.public || !may.private} aria-label="Private" />
226− </label>
227− </Card>
213+ <fieldset className="grid gap-2">
214+ <legend className="mb-1.5 text-sm font-medium text-muted">Who can open it</legend>
215+ <RadioGroup value={visibility} onValueChange={(value) => setChosen(value as ChannelVisibility)} className="grid gap-3">
216+ <RadioOption
217+ value="workspace"
218+ icon={<Hash />}
219+ label="Workspace"
220+ description={may.public ? "Anyone in the workspace can find, read and join it." : "Only workspace owners can create these here."}
221+ disabled={!may.public}
222+ />
223+ <RadioOption
224+ value="private"
225+ icon={<Lock />}
226+ label="Private"
227+ description={may.private ? "Only the people you invite can find and read it. Others can ask to join." : "Only workspace owners can create these here."}
228+ disabled={!may.private}
229+ />
230+ <RadioOption
231+ value="public"
232+ icon={<Globe />}
233+ label="Public"
234+ description={
235+ may.open
236+ ? "Anyone signed in to g1t can read, join and talk in it, marked Guest. A community or support channel."
237+ : "Open to anyone on g1t. Only workspace owners can make one."
238+ }
239+ disabled={!may.open}
240+ />
241+ </RadioGroup>
242+ </fieldset>
228243 {error && <p className="text-sm text-danger">{error}</p>}
229244 <DialogFooter>
230245 <Button type="button" onClick={() => setOpen(false)} variant="outline">
+262−24
2424
2525 import {
2626 type Channel,
27+ type ChannelAccessRequest,
2728 type ChannelChange,
29+ type ChannelInvitation,
30+ type ChannelVisibility,
2831 type ChannelMember,
2932 type ChatLiveEvent,
3033 type ChatMessage,
3942 import { CardBox, CardToasts, showToast } from "./card";
4043 import { Composer } from "./composer";
4144 import { type LiveState, useChatLive } from "./live";
42−import { AgentPill, MemberAvatar } from "./marks";
45+import { AgentPill, GuestPill, MemberAvatar } from "./marks";
46+import { ChannelMark } from "./sidebar";
4347 import { PersonStatusEmoji, PersonStatusLine, PresenceSummary, WithPresence } from "../presence";
4448 import { CardContext, MemberCard, type PersonCard, personCard } from "./profile-card";
4549
7175 import { Button } from "../ui/button";
7276 import { Card } from "../ui/card";
7377 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
78+import { Switch } from "../ui/switch";
7479 import {
7580 type Mentionable,
7681 type ShownMessage,
783788 <span>{formerAgentLine(formerAgent)}</span>
784789 </Card>
785790 ) : joined ? (
791+ <>
792+ {data.guest && (
793+ <p className="mb-1.5 px-1 text-xs text-faint">
794+ You&apos;re a guest in {data.workspace?.name ?? "this workspace"}: everyone here sees you marked Guest, and @g1t answers its members only.
795+ </p>
796+ )}
786797 <Composer
787798 draftKey={data.channel.id}
788799 placeholder={withG1t ? "Ask g1t about your work" : placeholder}
792803 onTyping={onTyping}
793804 autoFocus
794805 />
806+ </>
795807 ) : (
796808 <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
797809 <p className="text-sm text-muted">
798− You're reading <span className="font-medium text-fg">#{name}</span>. Join to post and get its notifications.
810+ {data.guest ? (
811+ <>
812+ You're reading <span className="font-medium text-fg">#{name}</span> as a guest: it's open to anyone on g1t. Join to talk in it;
813+ you'll be marked Guest, since you're not in {data.workspace?.name ?? "the workspace"}.
814+ </>
815+ ) : (
816+ <>
817+ You're reading <span className="font-medium text-fg">#{name}</span>. Join to post and get its notifications.
818+ </>
819+ )}
799820 </p>
800821 <Button
801822 type="button"
844865 slug={slug}
845866 people={people}
846867 onAdded={(m) => setMembers((now) => [...now, m])}
868+ onRemoved={(id) => setMembers((now) => now.filter((m) => !(m.member.kind === "user" && m.member.id === id)))}
847869 joined={joined}
848870 canManage={canManage}
849871 onUpdate={updateChannel}
922944 {channel.kind === "channel" ? (
923945 <>
924946 <h1 className="flex min-w-0 shrink-0 items-center gap-1 text-[0.9375rem] font-semibold">
925− {channel.private ? (
926− <Hint label="Private channel: only its members can find and read it">
927− <span className="flex">
928− <Lock size={15} className="text-faint" aria-label="Private channel" />
929− </span>
930− </Hint>
931− ) : (
932− <Hash size={16} className="text-faint" aria-label="Public channel" />
933− )}
947+ <Hint label={VISIBILITY_HINT[channel.visibility ?? (channel.private ? "private" : "workspace")]}>
948+ <span className="flex text-faint">
949+ <ChannelMark channel={channel} size={16} />
950+ </span>
951+ </Hint>
934952 <span className="truncate">{channel.name}</span>
935953 </h1>
954+ {channel.visibility === "public" && (
955+ <Hint label="Anyone signed in to g1t can read and join this channel. People outside the workspace are marked Guest.">
956+ <Badge tone="accent" className="shrink-0">
957+ Public
958+ </Badge>
959+ </Hint>
960+ )}
961+ {data.guest && (
962+ <Hint label={`You're not a member of ${data.workspace?.name ?? "this workspace"}: you're here as a guest.`}>
963+ <Badge className="shrink-0">Guest</Badge>
964+ </Hint>
965+ )}
936966 <StarButton starred={starred} onClick={onStar} />
937967 {muted && (
938968 <Hint label="Muted: no unread count or notifications">
9851015 <Hint label={info ? "Hide details" : "Members and details"}>
9861016 <Button variant="outline" size="sm" onClick={onInfo} aria-pressed={info} aria-label="Members and details" className="px-2 text-xs text-muted tabular-nums aria-pressed:border-line-strong aria-pressed:bg-raised aria-pressed:text-fg max-md:h-11">
9871017 <UsersStack count={memberCount} />
1018+ {(data.requests?.length ?? 0) > 0 && (
1019+ <span className="ml-1 rounded-full bg-accent px-1.5 text-[0.6875rem] leading-4 font-semibold text-bg" aria-label={`${data.requests!.length} asking to join`}>
1020+ {data.requests!.length}
1021+ </span>
1022+ )}
9881023 </Button>
9891024 </Hint>
9901025 )}
13451380 const author = message.author;
13461381 const time = clock(message.created_at, zone);
13471382 const press = useLongPress(onLongPress);
1383+ // Whose chat this is, for a guest's mark.
1384+ const workspaceName = useChatData()?.sidebar?.workspace?.name ?? null;
13481385 return (
13491386 <article
13501387 {...press}
13761413 </MemberCard>
13771414 {author.kind === "user" && <PersonStatusEmoji person={{ id: author.id, username: author.name }} size={14} className="self-center" />}
13781415 {author.kind === "agent" && <AgentPill className="self-center" deleted={author.deleted === true} />}
1416+ {author.guest && <GuestPill className="self-center" workspace={workspaceName} />}
13791417 <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums">
13801418 {time}
13811419 </time>
16291667 );
16301668 }
16311669
1670+/** Who can find and read a channel, in a line: its header's hint and its details. */
1671+const VISIBILITY_HINT: Record<ChannelVisibility, string> = {
1672+ private: "Private: only the people in it can find and read it",
1673+ workspace: "Workspace: anyone in the workspace can read and join it",
1674+ public: "Public: anyone signed in to g1t can read, join and talk in it",
1675+};
1676+
16321677 /**
16331678 * A channel's name, topic and visibility, and changing them: any member
16341679 * edits the topic; renaming and archiving are for whoever the workspace's
1635− * chat settings allow (`canManage`). #general keeps its name and stays.
1680+ * chat settings allow (`canManage`); opening it to anyone on g1t, or
1681+ * keeping it to the workspace again, for the workspace's owners
1682+ * (`canMakePublic`). #general keeps its name and stays.
16361683 */
16371684 function ChannelAbout({
16381685 channel,
16391686 joined,
16401687 canManage,
1688+ canMakePublic,
1689+ guest,
16411690 onUpdate,
16421691 }: {
16431692 channel: Channel;
16441693 joined: boolean;
16451694 canManage: boolean;
1695+ canMakePublic: boolean;
1696+ /** The viewer is a guest: they change nothing. */
1697+ guest: boolean;
16461698 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
16471699 }) {
16481700 const [editing, setEditing] = useState<"name" | "topic" | null>(null);
16531705 const general = channel.name === "general";
16541706 const archived = !!channel.archived_at;
16551707 const mayRename = canManage && !general && !archived;
1656− const mayTopic = joined && !archived;
1708+ const mayTopic = joined && !archived && !guest;
1709+ const visibility: ChannelVisibility = channel.visibility ?? (channel.private ? "private" : "workspace");
16571710 const save = async (change: ChannelChange) => {
16581711 setBusy(true);
16591712 setError(null);
16771730 }}
16781731 >
16791732 <span className="flex h-9 items-center gap-1.5 rounded-md border border-line bg-bg px-2.5 focus-within:border-accent-dim">
1680− {field === "name" && (channel.private ? <Lock size={13} className="text-faint" /> : <Hash size={13} className="text-faint" />)}
1733+ {field === "name" && (
1734+ <span className="flex text-faint">
1735+ <ChannelMark channel={channel} size={13} />
1736+ </span>
1737+ )}
16811738 <input
16821739 autoFocus
16831740 value={value}
17281785 </div>
17291786 {editing === "topic" ? edit("topic") : <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>}
17301787 <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3>
1731− <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft">
1732− {channel.private ? <Lock size={13} /> : <Hash size={13} />}
1733− {channel.private ? "Private: only its members can find and read it" : "Public: anyone in the workspace can read and join"}
1788+ <p className="mt-1 flex items-start gap-1.5 text-sm text-fg-soft">
1789+ <span className="mt-0.5 flex">
1790+ <ChannelMark channel={channel} size={13} />
1791+ </span>
1792+ {VISIBILITY_HINT[visibility]}
17341793 </p>
1794+ {canMakePublic && visibility !== "private" && !archived && (
1795+ <label className="mt-3 flex items-start gap-3 rounded-md bg-bg p-2.5 ring-1 ring-line">
1796+ <span className="min-w-0 grow">
1797+ <span className="block text-[0.8125rem] font-medium">Open to anyone on g1t</span>
1798+ <span className="mt-0.5 block text-xs leading-relaxed text-muted">
1799+ {visibility === "public"
1800+ ? "Turning it off keeps it to the workspace: people outside it who joined on their own leave; guests you asked in stay."
1801+ : "Anyone signed in to g1t can find it by its link, read it and talk in it, marked Guest. They see nothing else of the workspace, and @g1t answers members only."}
1802+ </span>
1803+ </span>
1804+ <Switch
1805+ checked={visibility === "public"}
1806+ disabled={busy}
1807+ onCheckedChange={(open) => void save({ visibility: open ? "public" : "workspace" })}
1808+ aria-label="Open to anyone on g1t"
1809+ />
1810+ </label>
1811+ )}
17351812 {error && <p className="mt-2 text-xs text-danger">{error}</p>}
17361813 </section>
17371814 </Card>
17791856 slug,
17801857 people,
17811858 onAdded,
1859+ onRemoved,
17821860 joined,
17831861 canManage,
17841862 onUpdate,
17881866 slug: string;
17891867 people: Mentionable[];
17901868 onAdded: (member: ChannelMember) => void;
1869+ /** Someone taken out of the channel, by their account id. */
1870+ onRemoved: (id: string) => void;
17911871 joined: boolean;
17921872 /** Whether the viewer may rename, archive and unarchive it. */
17931873 canManage: boolean;
17941874 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
17951875 }) {
17961876 const channel = data.channel;
1877+ const send = useChatSend(slug);
1878+ const me = useChatData()?.me.id ?? null;
1879+ const workspaceName = data.workspace?.name ?? null;
17971880 const humans = members.filter((m) => m.member.kind === "user");
1881+ const guests = humans.filter((m) => m.member.guest).length;
1882+ const [requests, setRequests] = useState<ChannelAccessRequest[]>(data.requests ?? []);
1883+ const [invited, setInvited] = useState<ChannelInvitation[]>(data.invited ?? []);
1884+ const [error, setError] = useState<string | null>(null);
1885+ const answer = async (request: ChannelAccessRequest, approve: boolean) => {
1886+ setError(null);
1887+ const done = await send({ intent: "answer_request", channel_id: channel.id, id: request.id, approve });
1888+ if (!done.ok) return setError(done.error.message);
1889+ setRequests((now) => now.filter((r) => r.id !== request.id));
1890+ if (approve) {
1891+ onAdded({ channel_id: channel.id, member: request.requester, role: "member", starred: false, muted: false, last_read_id: null, joined_at: new Date().toISOString() });
1892+ }
1893+ };
1894+ const takeOut = async (member: MemberProfile) => {
1895+ setError(null);
1896+ const done = await send({ intent: "remove_member", channel_id: channel.id, member: `user:${member.id}` });
1897+ if (!done.ok) return setError(done.error.message);
1898+ setInvited((now) => now.filter((i) => i.invitee.id !== member.id));
1899+ onRemoved(member.id);
1900+ };
17981901 return (
17991902 <div className="min-h-0 grow overflow-y-auto p-4 scrollbar-thin">
1800− {channel.kind === "channel" && <ChannelAbout channel={channel} joined={joined} canManage={canManage} onUpdate={onUpdate} />}
1903+ {channel.kind === "channel" && (
1904+ <ChannelAbout
1905+ channel={channel}
1906+ joined={joined}
1907+ canManage={canManage}
1908+ canMakePublic={data.can_make_public === true}
1909+ guest={data.guest === true}
1910+ onUpdate={onUpdate}
1911+ />
1912+ )}
1913+ {requests.length > 0 && (
1914+ <section className="mt-5">
1915+ <h3 className="mb-1 px-1 text-xs font-medium text-faint">Asking to join · {requests.length}</h3>
1916+ <ul className="space-y-1.5">
1917+ {requests.map((request) => (
1918+ <li key={request.id} className="rounded-md bg-surface p-2.5 ring-1 ring-line">
1919+ <div className="flex items-center gap-2">
1920+ <MemberAvatar member={request.requester} size={22} />
1921+ <span className="min-w-0 grow truncate text-sm">{shownName(request.requester)}</span>
1922+ {request.requester.guest && <GuestPill workspace={workspaceName} />}
1923+ </div>
1924+ {request.note && <p className="mt-1.5 text-xs leading-relaxed text-muted">{request.note}</p>}
1925+ {request.requester.guest && <p className="mt-1.5 text-xs text-faint">Letting them in makes them a guest here: they see this channel and nothing else of the workspace.</p>}
1926+ <div className="mt-2 flex gap-1.5">
1927+ <Button type="button" size="xs" variant="accent" onClick={() => void answer(request, true)}>
1928+ Let in
1929+ </Button>
1930+ <Button type="button" size="xs" variant="ghost" onClick={() => void answer(request, false)}>
1931+ Decline
1932+ </Button>
1933+ </div>
1934+ </li>
1935+ ))}
1936+ </ul>
1937+ </section>
1938+ )}
18011939 <section className="mt-5">
18021940 <div className="mb-1 flex items-center justify-between px-1">
1803− <h3 className="text-xs font-medium text-faint">People · {humans.length}</h3>
1804− {joined && channel.kind === "channel" && <AddMember slug={slug} channelId={channel.id} people={people} members={members} onAdded={onAdded} />}
1941+ <h3 className="text-xs font-medium text-faint">
1942+ People · {humans.length}
1943+ {guests > 0 && <span className="ml-1">({guests} {guests === 1 ? "guest" : "guests"})</span>}
1944+ </h3>
1945+ {joined && !data.guest && channel.kind === "channel" && (
1946+ <AddMember
1947+ slug={slug}
1948+ channelId={channel.id}
1949+ people={people}
1950+ members={members}
1951+ guests={data.can_invite_guests === true}
1952+ workspaceName={workspaceName}
1953+ onAdded={onAdded}
1954+ onInvited={(username) =>
1955+ setInvited((now) => [
1956+ ...now,
1957+ {
1958+ id: `pending-${username}`,
1959+ workspace: data.workspace ?? { slug, name: slug, avatar: null },
1960+ channel,
1961+ invitee: { kind: "user", id: username, name: username, display_name: username, avatar: null, role: null, guest: true },
1962+ invited_by: { kind: "user", id: me ?? "", name: "you", display_name: "you", avatar: null, role: null },
1963+ created_at: new Date().toISOString(),
1964+ },
1965+ ])
1966+ }
1967+ />
1968+ )}
18051969 </div>
18061970 <ul className="space-y-px">
18071971 {humans.map(({ member, role }) => (
1808− <li key={member.id}>
1972+ <li key={member.id} className="group/person relative">
18091973 <MemberCard member={member} className="flex w-full items-center gap-2.5 rounded-md px-1 py-1.5 transition-colors hover:bg-raised/60 max-md:min-h-11">
18101974 <MemberAvatar member={member} size={26} presence />
18111975 <span className="min-w-0 grow truncate text-sm">
18131977 {shownName(member) !== shownHandle(member) && <span className="ml-1.5 text-faint">@{shownHandle(member)}</span>}
18141978 <PersonStatusEmoji person={{ id: member.id, username: member.name }} size={13} className="ml-1.5 align-[-2px]" inert />
18151979 </span>
1980+ {member.guest && <GuestPill workspace={workspaceName} />}
18161981 {role === "owner" && <span className="text-[0.6875rem] text-faint">Owner</span>}
18171982 </MemberCard>
1983+ {data.can_remove_members && member.id !== me && !(channel.name === "general" && !member.guest) && (
1984+ <Hint label={`Take ${shownName(member)} out of #${channel.name}`}>
1985+ <Button
1986+ type="button"
1987+ variant="secondary"
1988+ size="icon"
1989+ aria-label={`Take ${shownName(member)} out`}
1990+ onClick={() => void takeOut(member)}
1991+ className="absolute top-1/2 right-1 hidden size-6 -translate-y-1/2 text-faint group-hover/person:flex group-focus-within/person:flex hover:text-fg [@media(hover:none)]:flex"
1992+ >
1993+ <X size={13} />
1994+ </Button>
1995+ </Hint>
1996+ )}
18181997 </li>
18191998 ))}
18201999 </ul>
18212000 </section>
2001+ {invited.length > 0 && (
2002+ <section className="mt-5">
2003+ <h3 className="mb-1 px-1 text-xs font-medium text-faint">Invited as guests · {invited.length}</h3>
2004+ <ul className="space-y-px">
2005+ {invited.map((invitation) => (
2006+ <li key={invitation.id} className="flex items-center gap-2.5 rounded-md px-1 py-1.5">
2007+ <MemberAvatar member={invitation.invitee} size={26} />
2008+ <span className="min-w-0 grow truncate text-sm text-muted">
2009+ @{invitation.invitee.display_username ?? invitation.invitee.name}
2010+ <span className="ml-1.5 text-xs text-faint">hasn&apos;t answered</span>
2011+ </span>
2012+ {data.can_remove_members && !invitation.id.startsWith("pending-") && (
2013+ <Button type="button" variant="ghost" size="xs" onClick={() => void takeOut(invitation.invitee)}>
2014+ Take back
2015+ </Button>
2016+ )}
2017+ </li>
2018+ ))}
2019+ </ul>
2020+ </section>
2021+ )}
2022+ {error && <p className="mt-3 px-1 text-xs text-danger">{error}</p>}
18222023 </div>
18232024 );
18242025 }
18252026
1826−/** Adding a person to the channel. An invite lets them read; it never grants write. */
2027+/**
2028+ * Adding a person to the channel: someone in the workspace joins at once.
2029+ * With `guests`, anyone else on g1t can be asked in by their username:
2030+ * they are sent an invitation and join as a guest when they accept.
2031+ */
18272032 function AddMember({
18282033 slug,
18292034 channelId,
18302035 people,
18312036 members,
2037+ guests,
2038+ workspaceName,
18322039 onAdded,
2040+ onInvited,
18332041 }: {
18342042 slug: string;
18352043 channelId: string;
18362044 people: Mentionable[];
18372045 members: ChannelMember[];
2046+ guests: boolean;
2047+ workspaceName: string | null;
18382048 onAdded: (member: ChannelMember) => void;
2049+ onInvited: (username: string) => void;
18392050 }) {
18402051 const [open, setOpen] = useState(false);
18412052 const [query, setQuery] = useState("");
18472058 .filter((p) => !inside.has(`${p.kind}:${p.name.toLowerCase()}`))
18482059 .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
18492060 .slice(0, 8);
2061+ // Someone not in the workspace, by their exact username: asked in as a guest.
2062+ const outsider = guests && /^[a-z0-9][a-z0-9-]*$/.test(q) && !people.some((p) => p.name.toLowerCase() === q) && !inside.has(`user:${q}`) ? q : null;
2063+ const [sent, setSent] = useState<string | null>(null);
2064+ const inviteGuest = async (username: string) => {
2065+ setError(null);
2066+ const done = await send<{ invited: boolean }>({ intent: "invite", channel_id: channelId, member: `user:${username}` });
2067+ if (!done.ok) return setError(done.error.message);
2068+ if (done.value.invited) {
2069+ onInvited(username);
2070+ setSent(username);
2071+ setQuery("");
2072+ } else setOpen(false);
2073+ };
18502074 const add = async (person: Mentionable) => {
18512075 setError(null);
18522076 const done = await send({ intent: "invite", channel_id: channelId, member: `user:${person.name}` });
18752099 autoFocus
18762100 value={query}
18772101 onChange={(event) => setQuery(event.target.value)}
1878− placeholder="Add a person"
2102+ placeholder={guests ? "Add a person, or anyone on g1t by username" : "Add a person"}
18792103 aria-label="Add a person"
18802104 autoComplete="off"
18812105 data-1p-ignore
18822106 className="mb-1 h-8 w-full rounded-md border border-line bg-bg px-2.5 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
18832107 />
1884− {options.length === 0 && <p className="px-2 py-3 text-xs text-faint">Everyone who matches is already here.</p>}
2108+ {options.length === 0 && !outsider && <p className="px-2 py-3 text-xs text-faint">Everyone who matches is already here.</p>}
18852109 {options.map((person) => (
18862110 <button
18872111 key={`${person.kind}:${person.name}`}
18932117 <span className="min-w-0 grow truncate">{person.display_name}</span>
18942118 </button>
18952119 ))}
2120+ {outsider && (
2121+ <button
2122+ type="button"
2123+ onClick={() => void inviteGuest(outsider)}
2124+ className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-line"
2125+ >
2126+ <UserPlus size={16} className="mt-0.5 shrink-0 text-faint" />
2127+ <span className="min-w-0 grow">
2128+ Invite @{outsider} as a guest
2129+ <span className="block text-xs text-faint">Not in {workspaceName ?? "the workspace"}: they join this channel only, once they accept.</span>
2130+ </span>
2131+ </button>
2132+ )}
2133+ {sent && <p className="px-2 pt-1 text-xs text-muted">Invited @{sent}. They&apos;ll see it in their Chat sidebar.</p>}
18962134 {error && <p className="px-2 pt-1 text-xs text-danger">{error}</p>}
18972135 </PopoverContent>
18982136 </Popover>
+62−2
1−import { Hash, MessagesSquare, PlugZap } from "lucide-react";
2−import type { ReactNode } from "react";
1+import { Check, Hash, Lock, MessagesSquare, PlugZap } from "lucide-react";
2+import { type ReactNode, useState } from "react";
33 import { useParams } from "react-router";
44
55 import { CreateChannelButton, NewMessageButton } from "./actions";
6+import { Button } from "../ui/button";
67 import { Card } from "../ui/card";
8+import { Textarea } from "../ui/textarea";
79
810 /** A calm full-height panel for a conversation that cannot be shown. */
911 function Panel({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
4547 export function ChannelGlyph({ className }: { className?: string }) {
4648 return <Hash size={16} className={className} />;
4749 }
50+
51+/**
52+ * A channel's link that does not open for the viewer: private, kept to a
53+ * workspace they are not in, or not there at all, which reads the same so
54+ * nothing about a channel they may not see is told. They may ask whoever
55+ * could add them to let them in, with a note.
56+ */
57+export function RequestAccess({ name, workspace, member }: { name: string; workspace: { slug: string; name: string }; member: boolean }) {
58+ const [note, setNote] = useState("");
59+ const [state, setState] = useState<"idle" | "sending" | "sent" | "failed">("idle");
60+ const ask = async () => {
61+ setState("sending");
62+ try {
63+ const response = await fetch(`/${workspace.slug}/-/chat/api`, {
64+ method: "POST",
65+ headers: { "content-type": "application/json" },
66+ body: JSON.stringify({ intent: "request_access", name, note }),
67+ });
68+ const result = (await response.json()) as { ok: boolean };
69+ setState(result.ok ? "sent" : "failed");
70+ } catch {
71+ setState("failed");
72+ }
73+ };
74+ return (
75+ <Panel icon={<Lock size={20} />} title={`You can't open #${name}`}>
76+ <p>
77+ {member
78+ ? `If #${name} is a private channel in ${workspace.name}, only the people in it can read it.`
79+ : `You're not a member of ${workspace.name}. If #${name} is one of its channels, it's for its members or the people asked into it.`}{" "}
80+ You can ask to be let in: whoever could add you will see your request.
81+ </p>
82+ {state === "sent" ? (
83+ <p className="mt-5 inline-flex items-center gap-1.5 text-fg">
84+ <Check size={15} className="text-accent" />
85+ Asked. You'll find it in your sidebar once someone lets you in.
86+ </p>
87+ ) : (
88+ <div className="mt-5 space-y-2 text-left">
89+ <Textarea
90+ value={note}
91+ onChange={(event) => setNote(event.target.value)}
92+ maxLength={300}
93+ rows={3}
94+ placeholder="Say who you are and why you'd like in (optional)"
95+ aria-label="A note for whoever lets you in"
96+ />
97+ <div className="flex items-center justify-between gap-2">
98+ <span className="text-xs text-danger">{state === "failed" ? "That didn't go through. Try again." : ""}</span>
99+ <Button type="button" onClick={() => void ask()} disabled={state === "sending"}>
100+ {state === "sending" ? "Asking…" : "Ask to join"}
101+ </Button>
102+ </div>
103+ </div>
104+ )}
105+ </Panel>
106+ );
107+}
+22−0
Loading the diff…
+144−11
Loading the diff…
+2−1
Loading the diff…
+12−0
Loading the diff…
+7−2
Loading the diff…
+4−0
Loading the diff…
+1−1
Loading the diff…
+18−2
Loading the diff…
+3−3
Loading the diff…
+33−14
Loading the diff…
+21−15
Loading the diff…
+44−8
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…

Comments

Sign in to comment.