Merge main into project-descriptions
G1t-Session: ses_01m4ybyg80e6qrrzww8qzs19yw # Conflicts: # services/repos/src/network.rs
106 files+1409−22274 files not counted0/106 viewed
| 1330 | 1330 | ||
| 1331 | 1331 | [[package]] | |
| 1332 | 1332 | name = "g1t-desktop" | |
| 1333 | − | version = "0.3.7" | |
| 1333 | + | version = "0.3.8" | |
| 1334 | 1334 | dependencies = [ | |
| 1335 | 1335 | "base64 0.22.1", | |
| 1336 | 1336 | "ed25519-dalek", |
| 1 | 1 | [package] | |
| 2 | 2 | name = "g1t-desktop" | |
| 3 | − | version = "0.3.7" | |
| 3 | + | version = "0.3.8" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license = "MIT" | |
| 6 | 6 | repository = "https://g1t.sh/flagon-io/g1t" |
| 63 | 63 | /// system can close it (not on Windows, whose WebView2 has no way to). | |
| 64 | 64 | pub fn setting_changed(app: &AppHandle) { | |
| 65 | 65 | 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 { | |
| 67 | 70 | let _ = item.set_enabled(usable(app)); | |
| 68 | 71 | } | |
| 69 | 72 | allow(app, on); |
| 152 | 152 | tray::install(&handle)?; | |
| 153 | 153 | links::install(&handle); | |
| 154 | 154 | 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). | |
| 156 | 156 | runner::install(&handle); | |
| 157 | 157 | Ok(()) | |
| 158 | 158 | }) |
| 6 | 6 | //! | |
| 7 | 7 | //! - **When.** On by default; settings turn it off ("Run jobs on this | |
| 8 | 8 | //! 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. | |
| 11 | 12 | //! - **How.** The backend settings choose (backend.rs): Sandboxed, the | |
| 12 | 13 | //! default, where nothing a job does reaches this computer; Docker; or | |
| 13 | 14 | //! directly on this computer. The two that are not sandboxes are only | |
| ⋯ | |||
| 65 | 66 | use std::io::{BufRead, BufReader, Read, Write}; | |
| 66 | 67 | use std::path::PathBuf; | |
| 67 | 68 | use std::process::{Child, Stdio}; | |
| 68 | − | use std::sync::Mutex; | |
| 69 | + | use std::panic::{AssertUnwindSafe, catch_unwind}; | |
| 69 | 70 | use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; | |
| 71 | + | use std::sync::{Mutex, MutexGuard, PoisonError}; | |
| 70 | 72 | use std::time::{Duration, Instant, SystemTime}; | |
| 71 | 73 | ||
| 72 | 74 | use serde::{Deserialize, Serialize}; | |
| ⋯ | |||
| 86 | 88 | /// What the site's page listens for: the app needs a registration token for a workspace. | |
| 87 | 89 | pub const WANTED_EVENT: &str = "g1t-runner-wanted"; | |
| 88 | 90 | ||
| 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 | + | } | |
| 91 | 113 | /// How often the thread looks at the runner when nothing has happened. | |
| 92 | 114 | const TICK: Duration = Duration::from_secs(5); | |
| 93 | 115 | /// How long after starting the first look for an update is, then how often. | |
| ⋯ | |||
| 331 | 353 | if let Some(registration) = Registration::load(app).filter(|r| r.server == server) { | |
| 332 | 354 | return Some(registration.workspace); | |
| 333 | 355 | } | |
| 334 | − | let site = app.state::<Shared>().site.lock().unwrap().clone(); | |
| 356 | + | let site = app.state::<Shared>().site.held().clone(); | |
| 335 | 357 | site.current.filter(|slug| site.owned.iter().any(|o| &o.slug == slug)).or_else(|| site.owned.first().map(|o| o.slug.clone())) | |
| 336 | 358 | } | |
| 337 | 359 | ||
| ⋯ | |||
| 359 | 381 | live: now.live, | |
| 360 | 382 | starting: now.starting.clone(), | |
| 361 | 383 | download: now.download.clone(), | |
| 362 | − | held: !settings.runner || *app.state::<Shared>().paused.lock().unwrap(), | |
| 384 | + | held: !settings.runner || *app.state::<Shared>().paused.held(), | |
| 363 | 385 | update: update_facts(app, now, backend), | |
| 364 | 386 | } | |
| 365 | 387 | } | |
| ⋯ | |||
| 369 | 391 | /// its program while they run; a runner with no kinds of job takes it | |
| 370 | 392 | /// itself. | |
| 371 | 393 | 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(); | |
| 373 | 395 | let sandbox = now.sandbox.as_ref().filter(|_| backend == Backend::Sandboxed); | |
| 374 | 396 | let mut waiting = Vec::new(); | |
| 375 | 397 | if updates.image { | |
| ⋯ | |||
| 417 | 439 | ||
| 418 | 440 | pub fn view(app: &AppHandle) -> View { | |
| 419 | 441 | let shared = app.state::<Shared>(); | |
| 420 | − | let now = shared.now.lock().unwrap().clone(); | |
| 442 | + | let now = shared.now.held().clone(); | |
| 421 | 443 | let settings = config::settings(app); | |
| 422 | 444 | let facts = facts(app, &now); | |
| 423 | 445 | let server = config::server(app); | |
| ⋯ | |||
| 431 | 453 | backend: settings.runner_backend, | |
| 432 | 454 | link: facts.workspace.as_ref().and_then(|w| server.join(&format!("{w}/-/runners")).ok()).map(|u| u.to_string()), | |
| 433 | 455 | workspace: facts.workspace, | |
| 434 | − | workspaces: shared.site.lock().unwrap().owned.clone(), | |
| 456 | + | workspaces: shared.site.held().owned.clone(), | |
| 435 | 457 | enabled: settings.runner, | |
| 436 | − | paused: *shared.paused.lock().unwrap(), | |
| 458 | + | paused: *shared.paused.held(), | |
| 437 | 459 | machine: facts.machine, | |
| 438 | 460 | platform: facts.platform, | |
| 439 | − | log: shared.tail.lock().unwrap().iter().cloned().collect(), | |
| 461 | + | log: shared.tail.held().iter().cloned().collect(), | |
| 440 | 462 | } | |
| 441 | 463 | } | |
| 442 | 464 | ||
| 443 | 465 | /// Whether the runner is running a job: what restarts g1t waits for it, | |
| 444 | 466 | /// since restarting stops the runner and would cut the job short. | |
| 445 | 467 | 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(); | |
| 447 | 469 | now.code == Code::Busy || now.sandbox.as_ref().is_some_and(|status| status.job().is_some()) | |
| 448 | 470 | } | |
| 449 | 471 | ||
| ⋯ | |||
| 464 | 486 | } | |
| 465 | 487 | ||
| 466 | 488 | 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() { | |
| 468 | 490 | let _ = tx.send(ask); | |
| 469 | 491 | } | |
| 470 | 492 | } | |
| ⋯ | |||
| 472 | 494 | /// Starts the thread that keeps the runner, as the app starts. | |
| 473 | 495 | pub fn install(app: &AppHandle) { | |
| 474 | 496 | let (tx, rx) = mpsc::channel(); | |
| 475 | − | *app.state::<Shared>().tx.lock().unwrap() = Some(tx.clone()); | |
| 497 | + | *app.state::<Shared>().tx.held() = Some(tx.clone()); | |
| 476 | 498 | 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)); | |
| 478 | 500 | if let Err(error) = spawned { | |
| 479 | 501 | eprintln!("g1t: the runner's thread could not start: {error}"); | |
| 480 | 502 | } | |
| 481 | 503 | } | |
| 482 | 504 | ||
| 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 | + | ||
| 483 | 567 | /// Settings changed: the thread looks again. | |
| 484 | 568 | pub fn settings_changed(app: &AppHandle) { | |
| 485 | 569 | ask(app, Ask::Look); | |
| ⋯ | |||
| 488 | 572 | ||
| 489 | 573 | /// Start or Stop, from settings or the tray. | |
| 490 | 574 | pub fn set_paused(app: &AppHandle, paused: bool) { | |
| 491 | − | *app.state::<Shared>().paused.lock().unwrap() = paused; | |
| 575 | + | *app.state::<Shared>().paused.held() = paused; | |
| 492 | 576 | // Start with the setting off turns the setting on: that is what was asked. | |
| 493 | 577 | if !paused && !config::settings(app).runner { | |
| 494 | 578 | let mut settings = config::settings(app); | |
| ⋯ | |||
| 508 | 592 | } | |
| 509 | 593 | if on { | |
| 510 | 594 | settings.runner = true; | |
| 511 | − | *app.state::<Shared>().paused.lock().unwrap() = false; | |
| 595 | + | *app.state::<Shared>().paused.held() = false; | |
| 512 | 596 | } | |
| 513 | 597 | settings::apply(app, settings)?; | |
| 514 | 598 | if on { | |
| ⋯ | |||
| 526 | 610 | ||
| 527 | 611 | /// Retry and Restart: now, rather than when the wait is over. | |
| 528 | 612 | pub fn retry(app: &AppHandle) { | |
| 529 | − | *app.state::<Shared>().paused.lock().unwrap() = false; | |
| 613 | + | *app.state::<Shared>().paused.held() = false; | |
| 530 | 614 | ask(app, Ask::Retry); | |
| 531 | 615 | } | |
| 532 | 616 | ||
| ⋯ | |||
| 536 | 620 | let next = Site { signed_in: Some(signed_in), owned, current: current.map(|c| c.to_ascii_lowercase()) }; | |
| 537 | 621 | let changed = { | |
| 538 | 622 | let shared = app.state::<Shared>(); | |
| 539 | − | let mut site = shared.site.lock().unwrap(); | |
| 623 | + | let mut site = shared.site.held(); | |
| 540 | 624 | let changed = *site != next; | |
| 541 | 625 | *site = next; | |
| 542 | 626 | changed | |
| ⋯ | |||
| 572 | 656 | "stop" => set_paused(app, true), | |
| 573 | 657 | "retry" | "restart" => retry(app), | |
| 574 | 658 | "remove_sandbox" => { | |
| 575 | − | *app.state::<Shared>().paused.lock().unwrap() = false; | |
| 659 | + | *app.state::<Shared>().paused.held() = false; | |
| 576 | 660 | ask(app, Ask::RemoveSandbox); | |
| 577 | 661 | } | |
| 578 | 662 | "check_updates" => ask(app, Ask::Check), | |
| ⋯ | |||
| 657 | 741 | ||
| 658 | 742 | /// The workspace a token is wanted for now, if one is: the site asks as it loads. | |
| 659 | 743 | pub fn wanted(app: &AppHandle) -> Option<String> { | |
| 660 | − | app.state::<Shared>().wanted.lock().unwrap().clone() | |
| 744 | + | app.state::<Shared>().wanted.held().clone() | |
| 661 | 745 | } | |
| 662 | 746 | ||
| 663 | 747 | /// The runner's log, for the settings window to open. | |
| ⋯ | |||
| 667 | 751 | ||
| 668 | 752 | /// Stops the runner as g1t quits or restarts, and waits for it. | |
| 669 | 753 | pub fn shutdown(app: &AppHandle) { | |
| 670 | − | let tx = app.state::<Shared>().tx.lock().unwrap().take(); | |
| 754 | + | let tx = app.state::<Shared>().tx.held().take(); | |
| 671 | 755 | if let Some(tx) = tx { | |
| 672 | 756 | let (done, wait) = mpsc::channel(); | |
| 673 | 757 | if tx.send(Ask::Quit(done)).is_ok() { | |
| ⋯ | |||
| 825 | 909 | } | |
| 826 | 910 | } | |
| 827 | 911 | ||
| 828 | − | fn keep(mut self, rx: Receiver<Ask>) { | |
| 912 | + | fn keep(mut self, rx: &Receiver<Ask>, asks: Vec<Ask>) { | |
| 829 | 913 | self.reap(); | |
| 830 | 914 | self.set(Code::Starting, detail("Starting after g1t's window.")); | |
| 915 | + | for ask in asks { | |
| 916 | + | self.handle(ask); | |
| 917 | + | } | |
| 831 | 918 | loop { | |
| 832 | 919 | match rx.recv_timeout(TICK) { | |
| 833 | 920 | Ok(Ask::Quit(done)) => { | |
| ⋯ | |||
| 860 | 947 | } | |
| 861 | 948 | ||
| 862 | 949 | 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()) | |
| 864 | 951 | } | |
| 865 | 952 | ||
| 866 | 953 | /// What an idle runner shows: why its last update failed, until one works. | |
| ⋯ | |||
| 874 | 961 | fn set(&mut self, code: Code, extra: Extra) { | |
| 875 | 962 | let changed = { | |
| 876 | 963 | let shared = self.app.state::<Shared>(); | |
| 877 | − | let mut now = shared.now.lock().unwrap(); | |
| 964 | + | let mut now = shared.now.held(); | |
| 878 | 965 | let job = if code == Code::Busy { now.job.clone() } else { None }; | |
| 879 | 966 | let next = Now { | |
| 880 | 967 | code, | |
| ⋯ | |||
| 896 | 983 | } | |
| 897 | 984 | ||
| 898 | 985 | fn code(&self) -> Code { | |
| 899 | − | self.app.state::<Shared>().now.lock().unwrap().code | |
| 986 | + | self.app.state::<Shared>().now.held().code | |
| 900 | 987 | } | |
| 901 | 988 | ||
| 902 | 989 | fn note(&mut self, job: Option<String>, version: Option<String>) { | |
| 903 | 990 | let shared = self.app.state::<Shared>(); | |
| 904 | − | let mut now = shared.now.lock().unwrap(); | |
| 991 | + | let mut now = shared.now.held(); | |
| 905 | 992 | if job.is_some() { | |
| 906 | 993 | now.job = job; | |
| 907 | 994 | } | |
| ⋯ | |||
| 914 | 1001 | fn write_log(&mut self, line: &str) { | |
| 915 | 1002 | { | |
| 916 | 1003 | let shared = self.app.state::<Shared>(); | |
| 917 | − | let mut tail = shared.tail.lock().unwrap(); | |
| 1004 | + | let mut tail = shared.tail.held(); | |
| 918 | 1005 | if tail.len() == TAIL { | |
| 919 | 1006 | tail.pop_front(); | |
| 920 | 1007 | } | |
| ⋯ | |||
| 969 | 1056 | Ask::Checked(found) => self.checked(found), | |
| 970 | 1057 | Ask::Token { workspace, token } => { | |
| 971 | 1058 | let shared = self.app.state::<Shared>(); | |
| 972 | − | let mut wanted = shared.wanted.lock().unwrap(); | |
| 1059 | + | let mut wanted = shared.wanted.held(); | |
| 973 | 1060 | if wanted.as_deref() == Some(workspace.as_str()) { | |
| 974 | 1061 | *wanted = None; | |
| 975 | 1062 | self.token = Some((workspace, token)); | |
| ⋯ | |||
| 979 | 1066 | Ask::Refused { workspace, allowed, why } => { | |
| 980 | 1067 | let matched = { | |
| 981 | 1068 | let shared = self.app.state::<Shared>(); | |
| 982 | − | let mut wanted = shared.wanted.lock().unwrap(); | |
| 1069 | + | let mut wanted = shared.wanted.held(); | |
| 983 | 1070 | let matched = wanted.as_deref() == Some(workspace.as_str()); | |
| 984 | 1071 | if matched { | |
| 985 | 1072 | *wanted = None; | |
| ⋯ | |||
| 1007 | 1094 | if let Some(download) = launcher::download(line) { | |
| 1008 | 1095 | let changed = { | |
| 1009 | 1096 | let shared = self.app.state::<Shared>(); | |
| 1010 | − | let mut now = shared.now.lock().unwrap(); | |
| 1097 | + | let mut now = shared.now.held(); | |
| 1011 | 1098 | let changed = now.download.as_ref() != Some(&download); | |
| 1012 | 1099 | now.download = Some(download); | |
| 1013 | 1100 | changed | |
| ⋯ | |||
| 1117 | 1204 | } | |
| 1118 | 1205 | }; | |
| 1119 | 1206 | let shared = self.app.state::<Shared>(); | |
| 1120 | − | let mut now = shared.now.lock().unwrap(); | |
| 1207 | + | let mut now = shared.now.held(); | |
| 1121 | 1208 | now.sandbox = stopped.filter(|_| how != Down::Delete); | |
| 1122 | 1209 | now.starting.clear(); | |
| 1123 | 1210 | now.download = None; | |
| ⋯ | |||
| 1192 | 1279 | ||
| 1193 | 1280 | /// Asks the site for a registration token for `workspace`. | |
| 1194 | 1281 | 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()); | |
| 1196 | 1283 | let _ = self.app.emit_to(EventTarget::webview(frame::SITE), WANTED_EVENT, workspace.to_owned()); | |
| 1197 | 1284 | 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); | |
| 1199 | 1286 | self.set(if signed_out { Code::SignedOut } else { Code::Registering }, Extra::default()); | |
| 1200 | 1287 | } | |
| 1201 | 1288 | ||
| 1202 | 1289 | /// The thread's look round: what should run, and whether it does. | |
| 1203 | 1290 | fn look(&mut self) { | |
| 1204 | 1291 | 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(); | |
| 1206 | 1293 | if !settings.runner || paused { | |
| 1207 | 1294 | if self.run.is_some() { | |
| 1208 | 1295 | self.halt(); | |
| 1209 | 1296 | } | |
| 1210 | − | *self.app.state::<Shared>().wanted.lock().unwrap() = None; | |
| 1297 | + | *self.app.state::<Shared>().wanted.held() = None; | |
| 1211 | 1298 | self.set(if paused { Code::Stopped } else { Code::Off }, Extra::default()); | |
| 1212 | 1299 | return; | |
| 1213 | 1300 | } | |
| ⋯ | |||
| 1264 | 1351 | }; | |
| 1265 | 1352 | let Some(workspace) = workspace else { | |
| 1266 | 1353 | 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 { | |
| 1268 | 1355 | Some(false) => Code::SignedOut, | |
| 1269 | 1356 | Some(true) => Code::NoWorkspace, | |
| 1270 | 1357 | None => Code::Starting, | |
| ⋯ | |||
| 1449 | 1536 | self.program = None; | |
| 1450 | 1537 | } | |
| 1451 | 1538 | // 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(); | |
| 1453 | 1540 | let launcher = self.run.as_ref().is_some_and(|run| run.engine == Engine::Launcher); | |
| 1454 | 1541 | let image = launcher | |
| 1455 | 1542 | && found.images | |
| ⋯ | |||
| 1510 | 1597 | self.not_before = Instant::now(); | |
| 1511 | 1598 | return true; | |
| 1512 | 1599 | } | |
| 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 }; | |
| 1514 | 1601 | if self.program.is_some() { | |
| 1515 | 1602 | let running = native_kinds(&status); | |
| 1516 | 1603 | if running.is_empty() { | |
| ⋯ | |||
| 1694 | 1781 | fn now_starting(&self, kinds: Vec<String>) { | |
| 1695 | 1782 | let changed = { | |
| 1696 | 1783 | let shared = self.app.state::<Shared>(); | |
| 1697 | − | let mut now = shared.now.lock().unwrap(); | |
| 1784 | + | let mut now = shared.now.held(); | |
| 1698 | 1785 | let changed = now.starting != kinds; | |
| 1699 | 1786 | now.starting = kinds; | |
| 1700 | 1787 | changed | |
| ⋯ | |||
| 1713 | 1800 | if !self.run.as_ref().is_some_and(|run| run.engine == Engine::Launcher) { | |
| 1714 | 1801 | return; | |
| 1715 | 1802 | } | |
| 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 }; | |
| 1717 | 1804 | if !status.per_kind() { | |
| 1718 | 1805 | return; | |
| 1719 | 1806 | } | |
| ⋯ | |||
| 1767 | 1854 | other => { | |
| 1768 | 1855 | self.token = other; | |
| 1769 | 1856 | 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()); | |
| 1771 | 1858 | let _ = self.app.emit_to(EventTarget::webview(frame::SITE), WANTED_EVENT, workspace.to_owned()); | |
| 1772 | 1859 | } | |
| 1773 | 1860 | return; | |
| ⋯ | |||
| 1884 | 1971 | /// Keeps what a status says for the tray, settings and the site. | |
| 1885 | 1972 | fn seen(&mut self, status: &Status) { | |
| 1886 | 1973 | let shared = self.app.state::<Shared>(); | |
| 1887 | − | let mut now = shared.now.lock().unwrap(); | |
| 1974 | + | let mut now = shared.now.held(); | |
| 1888 | 1975 | now.sandbox = Some(status.clone()); | |
| 1889 | 1976 | if let Some(version) = status.version() { | |
| 1890 | 1977 | now.version = Some(version); | |
| ⋯ | |||
| 1894 | 1981 | ||
| 1895 | 1982 | /// The sandbox as it is now, while it is the run. | |
| 1896 | 1983 | 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(); | |
| 1898 | 1985 | let Some(run) = self.run.as_mut() else { return }; | |
| 1899 | 1986 | let step = run.step(); | |
| 1900 | 1987 | let job = status.job().map(launcher::Job::said); | |
| ⋯ | |||
| 1990 | 2077 | let status = launcher::parse(&printed); | |
| 1991 | 2078 | { | |
| 1992 | 2079 | let shared = self.app.state::<Shared>(); | |
| 1993 | − | let mut now = shared.now.lock().unwrap(); | |
| 2080 | + | let mut now = shared.now.held(); | |
| 1994 | 2081 | now.starting.clear(); | |
| 1995 | 2082 | now.download = None; | |
| 1996 | 2083 | } | |
| ⋯ | |||
| 2059 | 2146 | self.not_before = Instant::now() + wait; | |
| 2060 | 2147 | // The cards say why from what `up` said, not from the stopped sandbox. | |
| 2061 | 2148 | 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); | |
| 2063 | 2150 | } | |
| 2064 | 2151 | return self.set(code, Extra { detail: Some(cause), fix, wait_seconds: Some(wait.as_secs()), ..Extra::default() }); | |
| 2065 | 2152 | } | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | 81 | /// 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. | |
| 82 | 88 | 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 | + | }); | |
| 90 | 102 | } | |
| 91 | 103 | ||
| 92 | 104 | /// The update is downloaded: the menus say so, and offer the restart. | |
| 93 | 105 | 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 { | |
| 95 | 109 | let _ = item.set_text(format!("Restart to update to {version}")); | |
| 96 | 110 | let _ = item.set_enabled(true); | |
| 97 | 111 | } |
| 17 | 17 | answer arrives in the place you asked. | |
| 18 | 18 | ||
| 19 | 19 | 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. | |
| 22 | 23 | ||
| 23 | 24 | Open **Chat** in the rail on the left, or go to | |
| 24 | 25 | `g1t.sh/<workspace>/-/chat`. | |
| ⋯ | |||
| 65 | 66 | A channel is a named conversation, such as `#web`, `#releases` or | |
| 66 | 67 | `#support`. Every workspace starts with `#general`, and everyone new is put | |
| 67 | 68 | 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)). | |
| 71 | 70 | ||
| 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 | | |
| 77 | 80 | ||
| 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 | + | ||
| 78 | 85 | ### Create a channel | |
| 79 | 86 | ||
| 80 | 87 | <Steps> | |
| ⋯ | |||
| 84 | 91 | lowercase letters, digits, dashes and underscores. `Web Team` becomes | |
| 85 | 92 | `web-team`. | |
| 86 | 93 | 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**. | |
| 89 | 96 | 5. Choose **Create channel**. You are its owner and first member. Invite | |
| 90 | 97 | people once it is made. | |
| 91 | 98 | ||
| 92 | 99 | </Steps> | |
| 93 | 100 | ||
| 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 | |
| 96 | 103 | 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. | |
| 98 | 113 | ||
| 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 | + | ||
| 99 | 170 | ### Join, leave and browse | |
| 100 | 171 | ||
| 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, | |
| 104 | 175 | and choose **Join** to post in it and see it in your sidebar. A private channel | |
| 105 | 176 | you're not in never appears. | |
| 106 | 177 | - **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. | |
| 108 | 179 | - When you join, everything already said counts as read, so a busy channel | |
| 109 | 180 | doesn't greet you with its whole history as unread. | |
| 110 | 181 | ||
| ⋯ | |||
| 394 | 465 | ||
| 395 | 466 | | Setting | Choices | | |
| 396 | 467 | | --- | --- | | |
| 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. | | |
| 398 | 469 | | **Renaming and archiving** | **Channel owners and workspace owners** (the default) or **Workspace owners only**. Any member of a channel can still change its topic. | | |
| 399 | 470 | | **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. | | |
| 401 | 472 | ||
| 402 | 473 | Changing a setting doesn't change what already exists: channels already | |
| 403 | 474 | made stay, whoever made them. | |
| 208 | 208 | The app runs a [self-hosted runner](/guides/self-hosted-runners/) for | |
| 209 | 209 | you while it is open: your workspace's jobs that ask for `self-hosted` | |
| 210 | 210 | 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 | |
| 213 | 215 | runners. | |
| 214 | 216 | ||
| 215 | 217 | To stop it, press **Stop** in [the runner menu](#the-runner-menu), or | |
| ⋯ | |||
| 261 | 263 | ||
| 262 | 264 | | Setting | Default | What it does | | |
| 263 | 265 | | --- | --- | --- | | |
| 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. | | |
| 265 | 267 | | **How jobs run** | Sandboxed | Sandboxed, in Docker, or directly on this computer. See [how jobs run](#how-jobs-run). | | |
| 266 | 268 | | **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. | | |
| 267 | 269 | | **Workspace** | The one you were in when it first registered | The workspace whose jobs the runner runs. Lists the workspaces you own. | | |
| ⋯ | |||
| 320 | 322 | ||
| 321 | 323 | | When | The app runs | | |
| 322 | 324 | | --- | --- | | |
| 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. | | |
| 324 | 326 | | Every few seconds while it runs | `g1t-runner sandbox status --json`, for the tray, the settings and the Runners page. | | |
| 325 | 327 | | When g1t quits, or you press **Stop running jobs** | `g1t-runner sandbox down`: a job that is running is cut short. | | |
| 326 | 328 | | 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. | | |
| 36 | 36 | ||
| 37 | 37 | | Setting | What it decides | Today | | |
| 38 | 38 | | --- | --- | --- | | |
| 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 | | |
| 40 | 40 | | **Private channels** | Who can create them: **Any member** or **Owners only**. | Any member | | |
| 41 | 41 | ||
| 42 | 42 | Renaming and archiving channels, custom emoji and default channels are on |
| 9 | 9 | deployed apps, and, on the plan, storage and git traffic past the free | |
| 10 | 10 | amounts. Each is priced from what it costs g1t, plus 20%: runner minutes | |
| 11 | 11 | 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. | |
| 14 | 14 | ||
| 15 | 15 | Wherever this page says an owner can do something to a workspace's | |
| 16 | 16 | billing, so can its [billing managers](/guides/workspaces/#roles-that-add-to-a-member): | |
| ⋯ | |||
| 41 | 41 | them are refused at the start of the push, with what to do: make the | |
| 42 | 42 | repository public, delete what you do not need, or start the plan, where | |
| 43 | 43 | 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. | |
| 48 | 50 | ||
| 49 | 51 | Compute is not free. Workflows, the merge queue, | |
| 50 | 52 | deployments and semantic search need [the g1t plan](#the-g1t-plan), or a | |
| ⋯ | |||
| 112 | 114 | - Usage past $10 is charged the same way, **up to your | |
| 113 | 115 | [spend limit](#limits)**. | |
| 114 | 116 | ||
| 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 | |
| 118 | 120 | 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. | |
| 121 | 123 | ||
| 122 | 124 | **Included usage** pays for the month's usage first, at the same prices as | |
| 123 | 125 | everything else: runner minutes, builds, app traffic, custom domains, | |
| ⋯ | |||
| 845 | 847 | have a 100% discount. Their usage is shown at price and kept apart from | |
| 846 | 848 | what customers pay. | |
| 847 | 849 | ||
| 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 | + | ||
| 848 | 895 | Each change is made by g1t staff and recorded with who made it and why. To | |
| 849 | 896 | ask for one, write to [support@g1t.sh](mailto:support@g1t.sh). | |
| 850 | 897 | ||
| 245 | 245 | Custom | |
| 246 | 246 | </Segment> | |
| 247 | 247 | </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> | |
| 249 | 249 | </fieldset> | |
| 250 | 250 | <div className="when-custom"> | |
| 251 | 251 | <Field label="Custom discount %" hint="A whole percent, 1 to 100."> | |
| ⋯ | |||
| 253 | 253 | </Field> | |
| 254 | 254 | </div> | |
| 255 | 255 | <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."> | |
| 257 | 257 | <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} /> | |
| 258 | 258 | </Field> | |
| 259 | 259 | <Field label="Until" hint="Blank: no end. UTC."> | |
| 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 | + | } |
| 9 | 9 | import { data, redirect } from "react-router"; | |
| 10 | 10 | ||
| 11 | 11 | 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"; | |
| 13 | 13 | import { FORGIVE_COST_MICROS, goodwillWarning } from "./pricing"; | |
| 14 | 14 | import type { ActionData } from "./review"; | |
| 15 | 15 | import { admin, identity, priceBook } from "./services.server"; | |
| ⋯ | |||
| 61 | 61 | return back("allowances"); | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 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 | + | ||
| 64 | 90 | if (intent === "attach") { | |
| 65 | 91 | const values = fields(form, "workspace", "target"); | |
| 66 | 92 | const section = isEnterprise ? "members" : "billed-to"; | |
| 235 | 235 | }; | |
| 236 | 236 | } | |
| 237 | 237 | ||
| 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 | + | ||
| 238 | 271 | /** The most one recorded payment can be; billing refuses more. */ | |
| 239 | 272 | export const MAX_PAYMENT_MICROS = 100_000 * MICROS_PER_DOLLAR; | |
| 240 | 273 | const MAX_REFERENCE = 100; |
| 25 | 25 | export const DONE: Record<string, string> = { | |
| 26 | 26 | terms: "Terms saved. They apply to charges from now on.", | |
| 27 | 27 | 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.", | |
| 28 | 30 | attach: "Workspace moved onto the enterprise.", | |
| 29 | 31 | detach: "Workspace moved off the enterprise. It pays for itself again.", | |
| 30 | 32 | credit: "Credit given. It is on the balance and the statement, and the owners were emailed.", |
| 41 | 41 | trustAbout, | |
| 42 | 42 | } from "~/components/ui"; | |
| 43 | 43 | import { type Subject, billingAction } from "~/lib/billing-actions.server"; | |
| 44 | + | import { ComplimentarySection } from "~/components/complimentary"; | |
| 44 | 45 | import { parseSlug, text } from "~/lib/forms"; | |
| 45 | 46 | import { usd } from "~/lib/money"; | |
| 46 | 47 | import { type ActionData, DONE, type SectionError, doneKey } from "~/lib/review"; | |
| ⋯ | |||
| 93 | 94 | const figures = billedTo | |
| 94 | 95 | ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 } | |
| 95 | 96 | : { 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([ | |
| 97 | 98 | billedTo | |
| 98 | 99 | ? Promise.resolve([]) | |
| 99 | 100 | : admin | |
| ⋯ | |||
| 109 | 110 | settle(admin.overages()), | |
| 110 | 111 | settle(priceBook()), | |
| 111 | 112 | settle(admin.credits({ workspace: slug })), | |
| 113 | + | settle(admin.complimentary(slug)), | |
| 112 | 114 | ]); | |
| 113 | 115 | const done = doneKey(request.url); | |
| 114 | 116 | return { | |
| ⋯ | |||
| 135 | 137 | ? { grants: credits.value.grants, leftMicros: credits.value.grants.reduce((sum, grant) => sum + grant.leftMicros, 0) } | |
| 136 | 138 | : null, | |
| 137 | 139 | creditsError: credits.ok ? null : credits.error, | |
| 140 | + | complimentary: complimentary.ok ? complimentary.value : null, | |
| 141 | + | complimentaryError: complimentary.ok ? null : complimentary.error, | |
| 138 | 142 | ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger, | |
| 139 | 143 | audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit, | |
| 140 | 144 | done: done && !SALES_DONE.has(done) ? DONE[done] : null, | |
| ⋯ | |||
| 187 | 191 | forgiveCap, | |
| 188 | 192 | credits, | |
| 189 | 193 | creditsError, | |
| 194 | + | complimentary, | |
| 195 | + | complimentaryError, | |
| 190 | 196 | ledger, | |
| 191 | 197 | audit, | |
| 192 | 198 | done, | |
| ⋯ | |||
| 355 | 361 | </Section> | |
| 356 | 362 | ) : ( | |
| 357 | 363 | <> | |
| 364 | + | <ComplimentarySection | |
| 365 | + | workspace={slug} | |
| 366 | + | standing={complimentary} | |
| 367 | + | unavailable={complimentaryError} | |
| 368 | + | pathname={pathname} | |
| 369 | + | error={error("complimentary")} | |
| 370 | + | /> | |
| 358 | 371 | <TermsForm terms={terms} pathname={pathname} error={error("terms")} /> | |
| 359 | 372 | <AllowancesForm allowances={allowances} comped={fullDiscount(terms)} pathname={pathname} error={error("allowances")} /> | |
| 360 | 373 | </> | |
| ⋯ | |||
| 523 | 536 | ); | |
| 524 | 537 | } | |
| 525 | 538 | ||
| 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 | + | }; | |
| 527 | 546 | ||
| 528 | 547 | function gigabytes(bytes: number): string { | |
| 529 | 548 | return `${Math.round((bytes / 1e9) * 100) / 100} GB`; | |
| 10 | 10 | import type { ReactNode } from "react"; | |
| 11 | 11 | import { Form, Link } from "react-router"; | |
| 12 | 12 | ||
| 13 | − | import type { BillingDetails, FeatureState, Limit, UsageReport } from "@g1t/contracts"; | |
| 13 | + | import type { BillingDetails, ComplimentaryMonth, FeatureState, Limit, UsageReport } from "@g1t/contracts"; | |
| 14 | 14 | ||
| 15 | 15 | import type { PlanStatus } from "../lib/billing"; | |
| 16 | 16 | import { money, wholeDollars } from "../lib/money"; | |
| ⋯ | |||
| 22 | 22 | const FIELD = | |
| 23 | 23 | "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"; | |
| 24 | 24 | ||
| 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 | + | ||
| 25 | 126 | // --- The plan --------------------------------------------------------------------------------- | |
| 26 | 127 | ||
| 27 | 128 | /** | |
| ⋯ | |||
| 57 | 158 | }) { | |
| 58 | 159 | const plan = state?.plan; | |
| 59 | 160 | 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); | |
| 61 | 162 | const now = new Date(); | |
| 62 | 163 | const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); | |
| 63 | 164 | const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)); | |
| ⋯ | |||
| 86 | 187 | <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2100) / 100).toFixed(0)}</span> | |
| 87 | 188 | <span className="text-sm text-muted"> / month</span> | |
| 88 | 189 | <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 | + | )} | |
| 90 | 193 | </p> | |
| 91 | 194 | } | |
| 92 | 195 | > | |
| 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"; | |
| 2 | 2 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { useFetcher, useNavigate, useRouteLoaderData } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import type { ChatSidebar, Result } from "@g1t/contracts"; | |
| 5 | + | import type { ChannelVisibility, ChatSidebar, Result } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { Avatar } from "../ui/avatar"; | |
| 8 | 8 | import { Button } from "../ui/button"; | |
| 9 | − | import { Card } from "../ui/card"; | |
| 10 | 9 | import { Hint } from "../ui/hint"; | |
| 11 | 10 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog"; | |
| 12 | − | import { Switch } from "../ui/switch"; | |
| 11 | + | import { RadioGroup, RadioOption } from "../ui/radio-group"; | |
| 13 | 12 | import { channelName, shownHandle, shownName } from "../../lib/chat"; | |
| 14 | 13 | import type { ChatLayoutData } from "../../routes/workspace/chat/layout"; | |
| 15 | 14 | import { useLiveCounts } from "../../lib/notify-client"; | |
| ⋯ | |||
| 93 | 92 | * What the viewer may make, from the workspace's chat settings (Settings, | |
| 94 | 93 | * Chat). Until the sidebar arrives, everything: the chat service checks. | |
| 95 | 94 | */ | |
| 96 | − | export function useChannelCreation(): { public: boolean; private: boolean; any: boolean } { | |
| 95 | + | export function useChannelCreation(): { public: boolean; private: boolean; any: boolean; open: boolean } { | |
| 97 | 96 | 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; | |
| 99 | 99 | 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 }; | |
| 101 | 103 | } | |
| 102 | 104 | ||
| 103 | 105 | /** | |
| 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. | |
| 107 | 110 | */ | |
| 108 | 111 | export function CreateChannelButton({ slug, variant = "icon" }: { slug: string; variant?: "icon" | "button" }) { | |
| 109 | 112 | const [open, setOpen] = useState(false); | |
| 110 | 113 | const [name, setName] = useState(""); | |
| 111 | 114 | const [topic, setTopic] = useState(""); | |
| 112 | − | const [secret, setSecret] = useState(false); | |
| 115 | + | const [chosen, setChosen] = useState<ChannelVisibility>("workspace"); | |
| 113 | 116 | const [error, setError] = useState<string | null>(null); | |
| 114 | 117 | const [busy, setBusy] = useState(false); | |
| 115 | 118 | const send = useChatSend(slug); | |
| 116 | 119 | const navigate = useNavigate(); | |
| 117 | 120 | 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"; | |
| 120 | 125 | const clean = channelName(name); | |
| 121 | 126 | const submit = async () => { | |
| 122 | 127 | if (!clean) return setError("Give the channel a name."); | |
| 123 | 128 | setBusy(true); | |
| 124 | 129 | 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 }); | |
| 126 | 131 | setBusy(false); | |
| 127 | 132 | if (!made.ok) return setError(made.error.message); | |
| 128 | 133 | setOpen(false); | |
| ⋯ | |||
| 177 | 182 | <label className="grid gap-1.5"> | |
| 178 | 183 | <span className="text-sm font-medium text-muted">Name</span> | |
| 179 | 184 | <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" />} | |
| 181 | 186 | <input | |
| 182 | 187 | autoFocus | |
| 183 | 188 | value={name} | |
| ⋯ | |||
| 205 | 210 | className="h-10 rounded-md border border-line bg-bg px-3 text-sm outline-none placeholder:text-faint focus:border-accent-dim" | |
| 206 | 211 | /> | |
| 207 | 212 | </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> | |
| 228 | 243 | {error && <p className="text-sm text-danger">{error}</p>} | |
| 229 | 244 | <DialogFooter> | |
| 230 | 245 | <Button type="button" onClick={() => setOpen(false)} variant="outline"> | |
| 24 | 24 | ||
| 25 | 25 | import { | |
| 26 | 26 | type Channel, | |
| 27 | + | type ChannelAccessRequest, | |
| 27 | 28 | type ChannelChange, | |
| 29 | + | type ChannelInvitation, | |
| 30 | + | type ChannelVisibility, | |
| 28 | 31 | type ChannelMember, | |
| 29 | 32 | type ChatLiveEvent, | |
| 30 | 33 | type ChatMessage, | |
| ⋯ | |||
| 39 | 42 | import { CardBox, CardToasts, showToast } from "./card"; | |
| 40 | 43 | import { Composer } from "./composer"; | |
| 41 | 44 | import { type LiveState, useChatLive } from "./live"; | |
| 42 | − | import { AgentPill, MemberAvatar } from "./marks"; | |
| 45 | + | import { AgentPill, GuestPill, MemberAvatar } from "./marks"; | |
| 46 | + | import { ChannelMark } from "./sidebar"; | |
| 43 | 47 | import { PersonStatusEmoji, PersonStatusLine, PresenceSummary, WithPresence } from "../presence"; | |
| 44 | 48 | import { CardContext, MemberCard, type PersonCard, personCard } from "./profile-card"; | |
| 45 | 49 | ||
| ⋯ | |||
| 71 | 75 | import { Button } from "../ui/button"; | |
| 72 | 76 | import { Card } from "../ui/card"; | |
| 73 | 77 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 78 | + | import { Switch } from "../ui/switch"; | |
| 74 | 79 | import { | |
| 75 | 80 | type Mentionable, | |
| 76 | 81 | type ShownMessage, | |
| ⋯ | |||
| 783 | 788 | <span>{formerAgentLine(formerAgent)}</span> | |
| 784 | 789 | </Card> | |
| 785 | 790 | ) : joined ? ( | |
| 791 | + | <> | |
| 792 | + | {data.guest && ( | |
| 793 | + | <p className="mb-1.5 px-1 text-xs text-faint"> | |
| 794 | + | You're a guest in {data.workspace?.name ?? "this workspace"}: everyone here sees you marked Guest, and @g1t answers its members only. | |
| 795 | + | </p> | |
| 796 | + | )} | |
| 786 | 797 | <Composer | |
| 787 | 798 | draftKey={data.channel.id} | |
| 788 | 799 | placeholder={withG1t ? "Ask g1t about your work" : placeholder} | |
| ⋯ | |||
| 792 | 803 | onTyping={onTyping} | |
| 793 | 804 | autoFocus | |
| 794 | 805 | /> | |
| 806 | + | </> | |
| 795 | 807 | ) : ( | |
| 796 | 808 | <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"> | |
| 797 | 809 | <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 | + | )} | |
| 799 | 820 | </p> | |
| 800 | 821 | <Button | |
| 801 | 822 | type="button" | |
| ⋯ | |||
| 844 | 865 | slug={slug} | |
| 845 | 866 | people={people} | |
| 846 | 867 | onAdded={(m) => setMembers((now) => [...now, m])} | |
| 868 | + | onRemoved={(id) => setMembers((now) => now.filter((m) => !(m.member.kind === "user" && m.member.id === id)))} | |
| 847 | 869 | joined={joined} | |
| 848 | 870 | canManage={canManage} | |
| 849 | 871 | onUpdate={updateChannel} | |
| ⋯ | |||
| 922 | 944 | {channel.kind === "channel" ? ( | |
| 923 | 945 | <> | |
| 924 | 946 | <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> | |
| 934 | 952 | <span className="truncate">{channel.name}</span> | |
| 935 | 953 | </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 | + | )} | |
| 936 | 966 | <StarButton starred={starred} onClick={onStar} /> | |
| 937 | 967 | {muted && ( | |
| 938 | 968 | <Hint label="Muted: no unread count or notifications"> | |
| ⋯ | |||
| 985 | 1015 | <Hint label={info ? "Hide details" : "Members and details"}> | |
| 986 | 1016 | <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"> | |
| 987 | 1017 | <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 | + | )} | |
| 988 | 1023 | </Button> | |
| 989 | 1024 | </Hint> | |
| 990 | 1025 | )} | |
| ⋯ | |||
| 1345 | 1380 | const author = message.author; | |
| 1346 | 1381 | const time = clock(message.created_at, zone); | |
| 1347 | 1382 | const press = useLongPress(onLongPress); | |
| 1383 | + | // Whose chat this is, for a guest's mark. | |
| 1384 | + | const workspaceName = useChatData()?.sidebar?.workspace?.name ?? null; | |
| 1348 | 1385 | return ( | |
| 1349 | 1386 | <article | |
| 1350 | 1387 | {...press} | |
| ⋯ | |||
| 1376 | 1413 | </MemberCard> | |
| 1377 | 1414 | {author.kind === "user" && <PersonStatusEmoji person={{ id: author.id, username: author.name }} size={14} className="self-center" />} | |
| 1378 | 1415 | {author.kind === "agent" && <AgentPill className="self-center" deleted={author.deleted === true} />} | |
| 1416 | + | {author.guest && <GuestPill className="self-center" workspace={workspaceName} />} | |
| 1379 | 1417 | <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums"> | |
| 1380 | 1418 | {time} | |
| 1381 | 1419 | </time> | |
| ⋯ | |||
| 1629 | 1667 | ); | |
| 1630 | 1668 | } | |
| 1631 | 1669 | ||
| 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 | + | ||
| 1632 | 1677 | /** | |
| 1633 | 1678 | * A channel's name, topic and visibility, and changing them: any member | |
| 1634 | 1679 | * 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. | |
| 1636 | 1683 | */ | |
| 1637 | 1684 | function ChannelAbout({ | |
| 1638 | 1685 | channel, | |
| 1639 | 1686 | joined, | |
| 1640 | 1687 | canManage, | |
| 1688 | + | canMakePublic, | |
| 1689 | + | guest, | |
| 1641 | 1690 | onUpdate, | |
| 1642 | 1691 | }: { | |
| 1643 | 1692 | channel: Channel; | |
| 1644 | 1693 | joined: boolean; | |
| 1645 | 1694 | canManage: boolean; | |
| 1695 | + | canMakePublic: boolean; | |
| 1696 | + | /** The viewer is a guest: they change nothing. */ | |
| 1697 | + | guest: boolean; | |
| 1646 | 1698 | onUpdate: (change: ChannelChange) => Promise<Result<unknown>>; | |
| 1647 | 1699 | }) { | |
| 1648 | 1700 | const [editing, setEditing] = useState<"name" | "topic" | null>(null); | |
| ⋯ | |||
| 1653 | 1705 | const general = channel.name === "general"; | |
| 1654 | 1706 | const archived = !!channel.archived_at; | |
| 1655 | 1707 | 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"); | |
| 1657 | 1710 | const save = async (change: ChannelChange) => { | |
| 1658 | 1711 | setBusy(true); | |
| 1659 | 1712 | setError(null); | |
| ⋯ | |||
| 1677 | 1730 | }} | |
| 1678 | 1731 | > | |
| 1679 | 1732 | <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 | + | )} | |
| 1681 | 1738 | <input | |
| 1682 | 1739 | autoFocus | |
| 1683 | 1740 | value={value} | |
| ⋯ | |||
| 1728 | 1785 | </div> | |
| 1729 | 1786 | {editing === "topic" ? edit("topic") : <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>} | |
| 1730 | 1787 | <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]} | |
| 1734 | 1793 | </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 | + | )} | |
| 1735 | 1812 | {error && <p className="mt-2 text-xs text-danger">{error}</p>} | |
| 1736 | 1813 | </section> | |
| 1737 | 1814 | </Card> | |
| ⋯ | |||
| 1779 | 1856 | slug, | |
| 1780 | 1857 | people, | |
| 1781 | 1858 | onAdded, | |
| 1859 | + | onRemoved, | |
| 1782 | 1860 | joined, | |
| 1783 | 1861 | canManage, | |
| 1784 | 1862 | onUpdate, | |
| ⋯ | |||
| 1788 | 1866 | slug: string; | |
| 1789 | 1867 | people: Mentionable[]; | |
| 1790 | 1868 | onAdded: (member: ChannelMember) => void; | |
| 1869 | + | /** Someone taken out of the channel, by their account id. */ | |
| 1870 | + | onRemoved: (id: string) => void; | |
| 1791 | 1871 | joined: boolean; | |
| 1792 | 1872 | /** Whether the viewer may rename, archive and unarchive it. */ | |
| 1793 | 1873 | canManage: boolean; | |
| 1794 | 1874 | onUpdate: (change: ChannelChange) => Promise<Result<unknown>>; | |
| 1795 | 1875 | }) { | |
| 1796 | 1876 | const channel = data.channel; | |
| 1877 | + | const send = useChatSend(slug); | |
| 1878 | + | const me = useChatData()?.me.id ?? null; | |
| 1879 | + | const workspaceName = data.workspace?.name ?? null; | |
| 1797 | 1880 | 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 | + | }; | |
| 1798 | 1901 | return ( | |
| 1799 | 1902 | <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 | + | )} | |
| 1801 | 1939 | <section className="mt-5"> | |
| 1802 | 1940 | <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 | + | )} | |
| 1805 | 1969 | </div> | |
| 1806 | 1970 | <ul className="space-y-px"> | |
| 1807 | 1971 | {humans.map(({ member, role }) => ( | |
| 1808 | − | <li key={member.id}> | |
| 1972 | + | <li key={member.id} className="group/person relative"> | |
| 1809 | 1973 | <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"> | |
| 1810 | 1974 | <MemberAvatar member={member} size={26} presence /> | |
| 1811 | 1975 | <span className="min-w-0 grow truncate text-sm"> | |
| ⋯ | |||
| 1813 | 1977 | {shownName(member) !== shownHandle(member) && <span className="ml-1.5 text-faint">@{shownHandle(member)}</span>} | |
| 1814 | 1978 | <PersonStatusEmoji person={{ id: member.id, username: member.name }} size={13} className="ml-1.5 align-[-2px]" inert /> | |
| 1815 | 1979 | </span> | |
| 1980 | + | {member.guest && <GuestPill workspace={workspaceName} />} | |
| 1816 | 1981 | {role === "owner" && <span className="text-[0.6875rem] text-faint">Owner</span>} | |
| 1817 | 1982 | </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 | + | )} | |
| 1818 | 1997 | </li> | |
| 1819 | 1998 | ))} | |
| 1820 | 1999 | </ul> | |
| 1821 | 2000 | </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'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>} | |
| 1822 | 2023 | </div> | |
| 1823 | 2024 | ); | |
| 1824 | 2025 | } | |
| 1825 | 2026 | ||
| 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 | + | */ | |
| 1827 | 2032 | function AddMember({ | |
| 1828 | 2033 | slug, | |
| 1829 | 2034 | channelId, | |
| 1830 | 2035 | people, | |
| 1831 | 2036 | members, | |
| 2037 | + | guests, | |
| 2038 | + | workspaceName, | |
| 1832 | 2039 | onAdded, | |
| 2040 | + | onInvited, | |
| 1833 | 2041 | }: { | |
| 1834 | 2042 | slug: string; | |
| 1835 | 2043 | channelId: string; | |
| 1836 | 2044 | people: Mentionable[]; | |
| 1837 | 2045 | members: ChannelMember[]; | |
| 2046 | + | guests: boolean; | |
| 2047 | + | workspaceName: string | null; | |
| 1838 | 2048 | onAdded: (member: ChannelMember) => void; | |
| 2049 | + | onInvited: (username: string) => void; | |
| 1839 | 2050 | }) { | |
| 1840 | 2051 | const [open, setOpen] = useState(false); | |
| 1841 | 2052 | const [query, setQuery] = useState(""); | |
| ⋯ | |||
| 1847 | 2058 | .filter((p) => !inside.has(`${p.kind}:${p.name.toLowerCase()}`)) | |
| 1848 | 2059 | .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q)) | |
| 1849 | 2060 | .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 | + | }; | |
| 1850 | 2074 | const add = async (person: Mentionable) => { | |
| 1851 | 2075 | setError(null); | |
| 1852 | 2076 | const done = await send({ intent: "invite", channel_id: channelId, member: `user:${person.name}` }); | |
| ⋯ | |||
| 1875 | 2099 | autoFocus | |
| 1876 | 2100 | value={query} | |
| 1877 | 2101 | 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"} | |
| 1879 | 2103 | aria-label="Add a person" | |
| 1880 | 2104 | autoComplete="off" | |
| 1881 | 2105 | data-1p-ignore | |
| 1882 | 2106 | 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" | |
| 1883 | 2107 | /> | |
| 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>} | |
| 1885 | 2109 | {options.map((person) => ( | |
| 1886 | 2110 | <button | |
| 1887 | 2111 | key={`${person.kind}:${person.name}`} | |
| ⋯ | |||
| 1893 | 2117 | <span className="min-w-0 grow truncate">{person.display_name}</span> | |
| 1894 | 2118 | </button> | |
| 1895 | 2119 | ))} | |
| 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'll see it in their Chat sidebar.</p>} | |
| 1896 | 2134 | {error && <p className="px-2 pt-1 text-xs text-danger">{error}</p>} | |
| 1897 | 2135 | </PopoverContent> | |
| 1898 | 2136 | </Popover> | |
| 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"; | |
| 3 | 3 | import { useParams } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { CreateChannelButton, NewMessageButton } from "./actions"; | |
| 6 | + | import { Button } from "../ui/button"; | |
| 6 | 7 | import { Card } from "../ui/card"; | |
| 8 | + | import { Textarea } from "../ui/textarea"; | |
| 7 | 9 | ||
| 8 | 10 | /** A calm full-height panel for a conversation that cannot be shown. */ | |
| 9 | 11 | function Panel({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) { | |
| ⋯ | |||
| 45 | 47 | export function ChannelGlyph({ className }: { className?: string }) { | |
| 46 | 48 | return <Hash size={16} className={className} />; | |
| 47 | 49 | } | |
| 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 | + | } | |
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…
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.