Chat controls, public profiles, shadcn selects, and no Docs tab in a project
- Chat: owners decide who creates public and private channels, who renames and archives them, who adds emoji, and which channels new members join (Workspace settings > Chat); rename, archive and topic in a channel's details; Browse lists private channels you're in and archived ones; channel.updated live; typing in threads; session cards with a live state chip; agents update their own messages in place (update_as_agent) - Profiles: signed out, a public frame with no workspace sidebar; signed in, g1t's own pages light no mode and open no mode sidebar; a contribution calendar counted only in repositories the viewer can read (work: contributions) - Every native select is now the shadcn Select - Code is not docs: a project has no Docs tab - The rail's workspace avatar sits in the top bar's band; rail items centred
| 82 | 82 | ## Channels | |
| 83 | 83 | ||
| 84 | 84 | A channel is a named conversation, such as `#web`, `#releases` or | |
| 85 | − | `#support`. Every workspace starts with `#general`. | |
| 85 | + | `#support`. Every workspace starts with `#general`, and everyone new is put | |
| 86 | + | in it the first time they open Chat (owners can choose other | |
| 87 | + | [default channels](#workspace-chat-settings)). A private channel shows a | |
| 88 | + | lock in place of `#`, in the sidebar, in Browse channels and at the top of | |
| 89 | + | the channel. | |
| 86 | 90 | ||
| 87 | 91 | | | Public channel | Private channel | | |
| 88 | 92 | | --- | --- | --- | | |
| ⋯ | |||
| 107 | 111 | ||
| 108 | 112 | </Steps> | |
| 109 | 113 | ||
| 114 | + | A workspace's owners can keep creating public channels, private channels or | |
| 115 | + | both to themselves ([chat settings](#workspace-chat-settings)). When they | |
| 116 | + | have, **Create a channel** is turned off for everyone else and says why, and | |
| 117 | + | someone who may create only one kind gets that kind. | |
| 118 | + | ||
| 110 | 119 | ### Join, leave and browse | |
| 111 | 120 | ||
| 112 | − | - **Browse channels** lists every public channel you haven't joined, at | |
| 113 | − | `g1t.sh/<workspace>/-/chat/browse`. Open one to read it, and choose | |
| 114 | − | **Join** to post in it and see it in your sidebar. | |
| 121 | + | - **Browse channels** lists every public channel, and the private ones | |
| 122 | + | you're in, at `g1t.sh/<workspace>/-/chat/browse`. Open one to read it, and | |
| 123 | + | choose **Join** to post in it and see it in your sidebar. A private channel | |
| 124 | + | you're not in never appears. | |
| 125 | + | - **Archived**, on the same page, lists archived channels. | |
| 115 | 126 | - You can leave a channel, and join a public one again at any time. | |
| 116 | 127 | - When you join, everything already said counts as read, so a busy channel | |
| 117 | 128 | doesn't greet you with its whole history as unread. | |
| 118 | 129 | ||
| 130 | + | ### Rename, archive and change the topic | |
| 131 | + | ||
| 132 | + | Open a channel's details (the members button at the top right) to change | |
| 133 | + | it. | |
| 134 | + | ||
| 135 | + | | Change | Who can | | |
| 136 | + | | --- | --- | | |
| 137 | + | | **Topic** | Any member of the channel. | | |
| 138 | + | | **Rename** | The channel's owners and the workspace's owners, or only the workspace's owners if they chose so in [chat settings](#workspace-chat-settings). The address follows the new name for everyone who has it open. | | |
| 139 | + | | **Archive** | The same people as rename. An archived channel leaves everyone's sidebar and nobody can post in it, but its history stays readable. | | |
| 140 | + | | **Unarchive** | The same people, from the archived channel itself or **Browse channels → Archived**. | | |
| 141 | + | ||
| 142 | + | `#general` is never renamed or archived. Everyone looking at a channel sees | |
| 143 | + | a change to it the moment it is made. | |
| 144 | + | ||
| 119 | 145 | ### Star and mute | |
| 120 | 146 | ||
| 121 | 147 | - **Star** a channel to keep it at the top of your sidebar. | |
| ⋯ | |||
| 177 | 203 | as a person. If it is out of budget, it says so in the thread instead of | |
| 178 | 204 | answering, and tells you who can raise its limit. | |
| 179 | 205 | ||
| 206 | + | ### Session cards | |
| 207 | + | ||
| 208 | + | When an agent starts a longer piece of work, it posts a **session card**: | |
| 209 | + | the session's title, a line such as *Step 3 · 12 tools · $0.14*, and its | |
| 210 | + | state. The card changes in place as the session moves, for everyone looking, | |
| 211 | + | and opens the session's page in Agents. | |
| 212 | + | ||
| 213 | + | | State | Means | | |
| 214 | + | | --- | --- | | |
| 215 | + | | **Queued** | Waiting to start. | | |
| 216 | + | | **Working** | Running now. The chip pulses softly. | | |
| 217 | + | | **Waiting on helpers** | Another agent is doing part of the work. | | |
| 218 | + | | **Needs approval** | Waiting for someone to approve a step. Shown in amber. | | |
| 219 | + | | **Done** | Finished. Shown in green. | | |
| 220 | + | | **Stopped** | Someone stopped it. | | |
| 221 | + | | **Failed** | It could not finish. Shown in red. | | |
| 222 | + | ||
| 180 | 223 | ## Live | |
| 181 | 224 | ||
| 182 | 225 | Chat is live. Messages, edits and deletions appear for everyone in the | |
| 183 | 226 | channel the moment they happen, without reloading. You also see: | |
| 184 | 227 | ||
| 185 | − | - **Typing.** Who is writing right now, people and agents. | |
| 228 | + | - **Typing.** Who is writing right now, people and agents, in the channel | |
| 229 | + | or in a thread. | |
| 230 | + | - **Channel changes.** A new name, topic, or archiving. | |
| 231 | + | - **Cards.** An agent's session card, or any other card, changing state. | |
| 186 | 232 | - **Read state.** Your sidebar shows unread counts per channel, and the | |
| 187 | 233 | channel scrolls to the first message you haven't read. | |
| 188 | 234 | ||
| ⋯ | |||
| 256 | 302 | ||
| 257 | 303 | ### Who can add emoji | |
| 258 | 304 | ||
| 259 | − | Owners choose, on the same page: | |
| 305 | + | Owners choose in the workspace's [chat settings](#workspace-chat-settings): | |
| 260 | 306 | ||
| 261 | 307 | | Setting | Means | | |
| 262 | 308 | | --- | --- | | |
| 263 | − | | **Every member** (the default) | Anyone in the workspace can add emoji and aliases. | | |
| 309 | + | | **Any member** (the default) | Anyone in the workspace can add emoji and aliases. | | |
| 264 | 310 | | **Owners only** | Members use the workspace's emoji; only owners add them. | | |
| 265 | 311 | ||
| 266 | 312 | ### Remove an emoji | |
| ⋯ | |||
| 275 | 321 | move through it, Enter or Tab puts the emoji in, and Escape closes it. A whole | |
| 276 | 322 | `:shortcode:` you type yourself works too. | |
| 277 | 323 | ||
| 324 | + | ## Workspace chat settings | |
| 325 | + | ||
| 326 | + | A workspace's owners decide what members can do in its chat, under | |
| 327 | + | **Workspace → Settings → Chat** (`g1t.sh/<workspace>/-/settings/chat`). | |
| 328 | + | Every member can open the page to see what is allowed; only owners can | |
| 329 | + | change it. g1t enforces each setting, whichever app or API the request | |
| 330 | + | comes from. | |
| 331 | + | ||
| 332 | + | | Setting | Choices | | |
| 333 | + | | --- | --- | | |
| 334 | + | | **Public channels** | Who can create them: **Any member** (the default) or **Owners only**. | | |
| 335 | + | | **Private channels** | Who can create them: **Any member** (the default) or **Owners only**. | | |
| 336 | + | | **Renaming and archiving** | **Channel owners and workspace owners** (the default) or **Workspace owners only**. Any member of a channel can still change its topic. | | |
| 337 | + | | **Custom emoji** | Who can add them: **Any member** (the default) or **Owners only**. | | |
| 338 | + | | **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. | | |
| 339 | + | ||
| 340 | + | Changing a setting doesn't change what already exists: channels already | |
| 341 | + | made stay, whoever made them. | |
| 342 | + | ||
| 278 | 343 | ## Notifications | |
| 279 | 344 | ||
| 280 | 345 | g1t tells you when something is for you, wherever you are in the app. | |
| 24 | 24 | ||
| 25 | 25 | - **Spaces** hold pages: one per team or project, plus one for the whole | |
| 26 | 26 | workspace. | |
| 27 | + | - Docs is its own mode in the workspace's rail, not a tab in a project. To | |
| 28 | + | see one project's pages, filter Docs by that project. | |
| 27 | 29 | - **Pages** are edited together, live, with mentions of people, agents, | |
| 28 | 30 | issues, pull requests, channels and other pages. | |
| 29 | 31 | - Every page has **history**, **comments**, **backlinks** and **owners**. |
| 704 | 704 | **What it shows.** Your picture, name, username, pronouns, bio, location, | |
| 705 | 705 | website and when you joined; then your work in three tabs: | |
| 706 | 706 | ||
| 707 | − | - **Overview:** pull requests merged, open pull requests and issues | |
| 708 | − | opened, and your most recent activity. | |
| 707 | + | - **Overview:** your contribution calendar, then pull requests merged, | |
| 708 | + | open pull requests and issues opened, and your most recent activity. | |
| 709 | 709 | - **Pull requests** and **Issues:** everything you opened, and what g1t | |
| 710 | 710 | opened for you, newest first, | |
| 711 | 711 | with filters beside the list for state (open, closed, merged), type, | |
| 712 | 712 | repository and sort order. Add `?tab=pulls&state=merged` and the like to | |
| 713 | 713 | link to a filtered list. | |
| 714 | 714 | ||
| 715 | + | **The contribution calendar.** The last year as a square a day, a column a | |
| 716 | + | week, shaded more strongly the more you did that day, with the total | |
| 717 | + | above it ("128 contributions in the last year"). A contribution is an | |
| 718 | + | issue or pull request you opened (or g1t opened for you) and a review you | |
| 719 | + | gave. Days are counted in UTC. Hover over a square, or tap it, to see its | |
| 720 | + | day and count. On a narrow screen the calendar scrolls sideways inside | |
| 721 | + | its card, starting at today. | |
| 722 | + | ||
| 715 | 723 | **Edit it** in [Settings → Profile](https://g1t.sh/settings/profile). Every | |
| 716 | 724 | field is optional. The bio takes up to 160 characters and is also what a | |
| 717 | 725 | link to your profile says. The website must be an `https://` address; | |
| ⋯ | |||
| 731 | 739 | | On the profile | Shown to a visitor when | | |
| 732 | 740 | | --- | --- | | |
| 733 | 741 | | An issue or pull request, and its title | They can read its repository: it is public, or they are a member of its workspace | | |
| 734 | − | | The counts | Only what they could see is counted | | |
| 742 | + | | The counts, and the contribution calendar | Only what they could see is counted | | |
| 735 | 743 | | A workspace | They are a member of it too, or you made a public project in it, whose page shows that already | | |
| 736 | 744 | ||
| 737 | 745 | Someone signed out sees your public work and the workspaces where you made | |
| 738 | − | a public project; nothing else. The link preview for a profile uses only | |
| 739 | − | public work. | |
| 746 | + | a public project; nothing else, and their calendar counts only work in | |
| 747 | + | public repositories. The link preview for a profile uses only public work. | |
| 748 | + | ||
| 749 | + | **How it is laid out.** Signed out, a profile, Explore and Search are | |
| 750 | + | shown with g1t's public top bar (search, Explore, signing in) and the page | |
| 751 | + | at full width, with no workspace sidebar. Signed in, the rail stays, but | |
| 752 | + | no mode is lit and no mode's sidebar opens beside these pages: they are | |
| 753 | + | nobody's workspace, and the profile's own left column says whose it is. | |
| 740 | 754 | ||
| 741 | 755 | ### The card over a name | |
| 742 | 756 | ||
| 1 | − | import { ArrowUpRight, ChevronDown, LoaderCircle, Sparkles } from "lucide-react"; | |
| 1 | + | import { ArrowUpRight, LoaderCircle, Sparkles } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useEffect, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 8 | 8 | import type { NotStarted } from "../lib/delegate"; | |
| 9 | 9 | import { Avatar } from "./ui"; | |
| 10 | 10 | import { CONTROL } from "./ui/input"; | |
| 11 | + | import { SelectField } from "./ui/select"; | |
| 11 | 12 | ||
| 12 | 13 | /** What the composer's action said back: why nothing opened, or the issue that opened without its agent. */ | |
| 13 | 14 | export type ComposerResult = { error: string | null; notStarted: NotStarted | null } | null; | |
| ⋯ | |||
| 46 | 47 | // Closed by Escape or by a click outside it, as a menu is. | |
| 47 | 48 | useEffect(() => { | |
| 48 | 49 | if (!open) return; | |
| 49 | − | const onKey = (event: KeyboardEvent) => event.key === "Escape" && setOpen(false); | |
| 50 | + | // A select's list, open inside it, takes its own Escape and clicks. | |
| 51 | + | const onKey = (event: KeyboardEvent) => event.key === "Escape" && !event.defaultPrevented && setOpen(false); | |
| 50 | 52 | const onClick = (event: MouseEvent) => { | |
| 53 | + | if ((event.target as Element | null)?.closest?.("[data-radix-popper-content-wrapper]")) return; | |
| 51 | 54 | if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false); | |
| 52 | 55 | }; | |
| 53 | 56 | document.addEventListener("keydown", onKey); | |
| ⋯ | |||
| 86 | 89 | <div className="mt-4 space-y-3"> | |
| 87 | 90 | <label className="block"> | |
| 88 | 91 | <span className="mb-1.5 block text-xs font-medium text-muted">Project</span> | |
| 89 | − | <span className="relative block"> | |
| 90 | − | <select name="repo" required className={cn(CONTROL, "h-9 appearance-none pr-8")} defaultValue={repos[0] ? `${repos[0].namespace}/${repos[0].name}` : ""}> | |
| 91 | − | {repos.map((repo) => ( | |
| 92 | − | <option key={`${repo.namespace}/${repo.name}`} value={`${repo.namespace}/${repo.name}`}> | |
| 93 | − | {repo.name} | |
| 94 | − | </option> | |
| 95 | − | ))} | |
| 96 | − | </select> | |
| 97 | − | <ChevronDown size={14} className="pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 text-faint" /> | |
| 98 | − | </span> | |
| 92 | + | <SelectField | |
| 93 | + | name="repo" | |
| 94 | + | required | |
| 95 | + | defaultValue={repos[0] ? `${repos[0].namespace}/${repos[0].name}` : undefined} | |
| 96 | + | placeholder="Choose a project" | |
| 97 | + | options={repos.map((repo) => ({ value: `${repo.namespace}/${repo.name}`, label: repo.name }))} | |
| 98 | + | /> | |
| 99 | 99 | </label> | |
| 100 | 100 | <label className="block"> | |
| 101 | 101 | <span className="mb-1.5 block text-xs font-medium text-muted">Title</span> | |
| 16 | 16 | import { money } from "../lib/usage"; | |
| 17 | 17 | import { Card } from "./billing"; | |
| 18 | 18 | import { ErrorText, SubmitButton } from "./ui"; | |
| 19 | + | import { SelectField } from "./ui/select"; | |
| 19 | 20 | import { Skeleton } from "./ui/skeleton"; | |
| 20 | 21 | ||
| 21 | 22 | const FIELD = | |
| ⋯ | |||
| 623 | 624 | <label className="block"> | |
| 624 | 625 | <span className="text-xs text-muted">Tax ID</span> | |
| 625 | 626 | <span className="mt-1 flex gap-2"> | |
| 626 | − | <select name="taxIdType" defaultValue={details?.taxIdType ?? ""} className={`${FIELD} w-auto`} aria-label="Kind of tax ID"> | |
| 627 | − | {TAX_IDS.map(([value, label]) => ( | |
| 628 | − | <option key={value} value={value}> | |
| 629 | − | {label} | |
| 630 | − | </option> | |
| 631 | − | ))} | |
| 632 | − | </select> | |
| 627 | + | <SelectField | |
| 628 | + | name="taxIdType" | |
| 629 | + | defaultValue={details?.taxIdType ?? ""} | |
| 630 | + | aria-label="Kind of tax ID" | |
| 631 | + | className="h-auto w-auto shrink-0 px-2.5 py-1.5" | |
| 632 | + | options={TAX_IDS.map(([value, label]) => ({ value, label }))} | |
| 633 | + | /> | |
| 633 | 634 | <input name="taxId" defaultValue={details?.taxId ?? ""} aria-label="Tax ID" className={FIELD} /> | |
| 634 | 635 | </span> | |
| 635 | 636 | {details?.taxId && details.taxIdStatus && ( | |
| ⋯ | |||
| 642 | 643 | </label> | |
| 643 | 644 | <label className="block"> | |
| 644 | 645 | <span className="text-xs text-muted">Invoice language</span> | |
| 645 | − | <select name="language" defaultValue={details?.language ?? ""} className={`mt-1 ${FIELD}`}> | |
| 646 | − | {LANGUAGES.map(([value, label]) => ( | |
| 647 | − | <option key={value} value={value}> | |
| 648 | − | {label} | |
| 649 | − | </option> | |
| 650 | − | ))} | |
| 651 | − | </select> | |
| 646 | + | <SelectField | |
| 647 | + | name="language" | |
| 648 | + | defaultValue={details?.language ?? ""} | |
| 649 | + | aria-label="Invoice language" | |
| 650 | + | className="mt-1 h-auto px-2.5 py-1.5" | |
| 651 | + | options={LANGUAGES.map(([value, label]) => ({ value, label }))} | |
| 652 | + | /> | |
| 652 | 653 | </label> | |
| 653 | 654 | {!disabled && ( | |
| 654 | 655 | <div className="flex items-end"> | |
| 91 | 91 | const ICON_BUTTON = | |
| 92 | 92 | "flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent"; | |
| 93 | 93 | ||
| 94 | − | /** Making a channel: its name, what it is for, and whether it is private. */ | |
| 94 | + | /** Why someone may not make a channel: the workspace keeps it to its owners. */ | |
| 95 | + | export const OWNERS_ONLY_CHANNELS = "Only workspace owners can create channels here."; | |
| 96 | + | ||
| 97 | + | /** | |
| 98 | + | * What the viewer may make, from the workspace's chat settings (Settings, | |
| 99 | + | * Chat). Until the sidebar arrives, everything: the chat service checks. | |
| 100 | + | */ | |
| 101 | + | export function useChannelCreation(): { public: boolean; private: boolean; any: boolean } { | |
| 102 | + | const { sidebar } = useChatSidebar(); | |
| 103 | + | const open = sidebar?.can?.create_public_channels ?? true; | |
| 104 | + | const closed = sidebar?.can?.create_private_channels ?? true; | |
| 105 | + | return { public: open, private: closed, any: open || closed }; | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /** | |
| 109 | + | * Making a channel: its name, what it is for, and whether it is private. | |
| 110 | + | * Someone the workspace's settings do not let make one sees the button | |
| 111 | + | * turned off, with why; someone who may make only one kind gets that kind. | |
| 112 | + | */ | |
| 95 | 113 | export function CreateChannelButton({ slug, variant = "icon" }: { slug: string; variant?: "icon" | "button" }) { | |
| 96 | 114 | const [open, setOpen] = useState(false); | |
| 97 | 115 | const [name, setName] = useState(""); | |
| ⋯ | |||
| 101 | 119 | const [busy, setBusy] = useState(false); | |
| 102 | 120 | const send = useChatSend(slug); | |
| 103 | 121 | const navigate = useNavigate(); | |
| 122 | + | const may = useChannelCreation(); | |
| 123 | + | // Only one kind allowed: that kind, and the switch says why it is fixed. | |
| 124 | + | const isPrivate = !may.public ? true : !may.private ? false : secret; | |
| 104 | 125 | const clean = channelName(name); | |
| 105 | 126 | const submit = async () => { | |
| 106 | 127 | if (!clean) return setError("Give the channel a name."); | |
| 107 | 128 | setBusy(true); | |
| 108 | 129 | setError(null); | |
| 109 | − | const made = await send<{ to: string }>({ intent: "create_channel", name: clean, topic, private: secret }); | |
| 130 | + | const made = await send<{ to: string }>({ intent: "create_channel", name: clean, topic, private: isPrivate }); | |
| 110 | 131 | setBusy(false); | |
| 111 | 132 | if (!made.ok) return setError(made.error.message); | |
| 112 | 133 | setOpen(false); | |
| ⋯ | |||
| 114 | 135 | setTopic(""); | |
| 115 | 136 | navigate(made.value.to); | |
| 116 | 137 | }; | |
| 138 | + | if (!may.any) { | |
| 139 | + | // Shown off, not hidden, so nobody wonders where it went. | |
| 140 | + | return ( | |
| 141 | + | <Hint label={OWNERS_ONLY_CHANNELS} disabled> | |
| 142 | + | {variant === "icon" ? ( | |
| 143 | + | <button type="button" aria-label="Create a channel" disabled className={`${ICON_BUTTON} opacity-40`}> | |
| 144 | + | <Plus size={14} /> | |
| 145 | + | </button> | |
| 146 | + | ) : ( | |
| 147 | + | <button type="button" disabled className={`${QUIET_BUTTON} opacity-50`}> | |
| 148 | + | <Lock size={14} /> | |
| 149 | + | Create a channel | |
| 150 | + | </button> | |
| 151 | + | )} | |
| 152 | + | </Hint> | |
| 153 | + | ); | |
| 154 | + | } | |
| 117 | 155 | return ( | |
| 118 | 156 | <> | |
| 119 | 157 | {variant === "icon" ? ( | |
| ⋯ | |||
| 144 | 182 | <label className="grid gap-1.5"> | |
| 145 | 183 | <span className="text-sm font-medium text-muted">Name</span> | |
| 146 | 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"> | |
| 147 | − | {secret ? <Lock size={14} className="text-faint" /> : <Hash size={14} className="text-faint" />} | |
| 185 | + | {isPrivate ? <Lock size={14} className="text-faint" /> : <Hash size={14} className="text-faint" />} | |
| 148 | 186 | <input | |
| 149 | 187 | autoFocus | |
| 150 | 188 | value={name} | |
| ⋯ | |||
| 174 | 212 | </label> | |
| 175 | 213 | <label className="flex items-start justify-between gap-4 rounded-lg border border-line bg-bg/60 p-3"> | |
| 176 | 214 | <span> | |
| 177 | − | <span className="block text-sm font-medium">Private</span> | |
| 178 | − | <span className="mt-0.5 block text-xs text-muted">Only people and agents you invite can find and read it.</span> | |
| 215 | + | <span className="flex items-center gap-1.5 text-sm font-medium"> | |
| 216 | + | {isPrivate ? <Lock size={13} className="text-muted" /> : <Hash size={13} className="text-muted" />} | |
| 217 | + | {isPrivate ? "Private" : "Public"} | |
| 218 | + | </span> | |
| 219 | + | <span className="mt-0.5 block text-xs text-muted"> | |
| 220 | + | {!may.public | |
| 221 | + | ? "Only workspace owners can create public channels here, so this one is private: only people and agents you invite can find and read it." | |
| 222 | + | : !may.private | |
| 223 | + | ? "Only workspace owners can create private channels here, so this one is public: anyone in the workspace can find, read and join it." | |
| 224 | + | : isPrivate | |
| 225 | + | ? "Only people and agents you invite can find and read it." | |
| 226 | + | : "Anyone in the workspace can find, read and join it. Turn on to keep it to the people and agents you invite."} | |
| 227 | + | </span> | |
| 179 | 228 | </span> | |
| 180 | − | <Switch checked={secret} onCheckedChange={setSecret} aria-label="Private" /> | |
| 229 | + | <Switch checked={isPrivate} onCheckedChange={setSecret} disabled={!may.public || !may.private} aria-label="Private" /> | |
| 181 | 230 | </label> | |
| 182 | 231 | {error && <p className="text-sm text-danger">{error}</p>} | |
| 183 | 232 | <DialogFooter> | |
| 1 | 1 | import { | |
| 2 | + | Archive, | |
| 3 | + | ArchiveRestore, | |
| 4 | + | Bot, | |
| 2 | 5 | ChevronLeft, | |
| 3 | 6 | Copy, | |
| 4 | 7 | Pencil, | |
| ⋯ | |||
| 20 | 23 | X, | |
| 21 | 24 | } from "lucide-react"; | |
| 22 | 25 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 23 | − | import { Link, useRouteLoaderData, useSearchParams } from "react-router"; | |
| 26 | + | import { Link, useNavigate, useRouteLoaderData, useSearchParams } from "react-router"; | |
| 24 | 27 | ||
| 25 | − | import { type ChannelMember, type ChatLiveEvent, type ChatMessage, type MemberProfile, type MessageCard, type Result, hasCodeAccess } from "@g1t/contracts"; | |
| 28 | + | import { | |
| 29 | + | type Channel, | |
| 30 | + | type ChannelChange, | |
| 31 | + | type ChannelMember, | |
| 32 | + | type ChatLiveEvent, | |
| 33 | + | type ChatMessage, | |
| 34 | + | type MemberProfile, | |
| 35 | + | type MessageCard, | |
| 36 | + | type Result, | |
| 37 | + | hasCodeAccess, | |
| 38 | + | } from "@g1t/contracts"; | |
| 26 | 39 | ||
| 27 | 40 | import { useChatData, useChatSend, useChatSidebar } from "./actions"; | |
| 28 | 41 | import { Composer } from "./composer"; | |
| ⋯ | |||
| 33 | 46 | import { localTime } from "../../lib/time-zone"; | |
| 34 | 47 | import { BottomSheet, SheetRow, useBack, useSwipeBack } from "../mobile"; | |
| 35 | 48 | import { MessageText, type TextContext } from "./text"; | |
| 49 | + | import { | |
| 50 | + | AlertDialog, | |
| 51 | + | AlertDialogAction, | |
| 52 | + | AlertDialogCancel, | |
| 53 | + | AlertDialogContent, | |
| 54 | + | AlertDialogDescription, | |
| 55 | + | AlertDialogFooter, | |
| 56 | + | AlertDialogHeader, | |
| 57 | + | AlertDialogTitle, | |
| 58 | + | } from "../ui/alert-dialog"; | |
| 36 | 59 | import { Badge, type BadgeTone } from "../ui/badge"; | |
| 37 | 60 | import { Hint } from "../ui/hint"; | |
| 38 | 61 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 39 | − | import { type Mentionable, type ShownMessage, mergeMessages, shownName, timeline } from "../../lib/chat"; | |
| 62 | + | import { type Mentionable, type ShownMessage, channelPath, mergeMessages, shownName, timeline } from "../../lib/chat"; | |
| 63 | + | import { sessionChip } from "../../lib/session-card"; | |
| 40 | 64 | import { codeAccessPath } from "../../lib/workspace-nav"; | |
| 41 | 65 | import { conversationCache } from "./conversation-cache"; | |
| 42 | 66 | // Reactions and the workspace's own emoji (components/emoji). | |
| ⋯ | |||
| 97 | 121 | const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map()); | |
| 98 | 122 | const [joined, setJoined] = useState(data.joined); | |
| 99 | 123 | const [members, setMembers] = useState<ChannelMember[]>(data.members); | |
| 124 | + | // The channel as it is now: renamed, its topic changed, archived, live. | |
| 125 | + | const [channel, setChannel] = useState<Channel>(data.channel); | |
| 126 | + | const [canManage, setCanManage] = useState(data.can_manage === true); | |
| 127 | + | const view = useMemo<Loaded>(() => ({ ...data, channel, title: channel.kind === "channel" ? `#${channel.name}` : data.title }), [data, channel]); | |
| 100 | 128 | // The server's answer behind a cached draw: merged in by id. | |
| 101 | 129 | useEffect(() => { | |
| 102 | 130 | const onFresh = (event: Event) => { | |
| ⋯ | |||
| 105 | 133 | setMessages((now) => mergeMessages(now, value.messages)); | |
| 106 | 134 | setMembers(value.members); | |
| 107 | 135 | setJoined(value.joined); | |
| 136 | + | setChannel(value.channel); | |
| 137 | + | setCanManage(value.can_manage === true); | |
| 108 | 138 | }; | |
| 109 | 139 | window.addEventListener("g1t:chat-fresh", onFresh); | |
| 110 | 140 | return () => window.removeEventListener("g1t:chat-fresh", onFresh); | |
| ⋯ | |||
| 113 | 143 | useEffect(() => { | |
| 114 | 144 | const path = window.location.pathname; | |
| 115 | 145 | const timer = setTimeout( | |
| 116 | − | () => conversationCache.set(path, { ...data, messages: messages.filter((m) => !m.pending).slice(-80), members, joined }), | |
| 146 | + | () => conversationCache.set(path, { ...view, messages: messages.filter((m) => !m.pending).slice(-80), members, joined, can_manage: canManage }), | |
| 117 | 147 | 400, | |
| 118 | 148 | ); | |
| 119 | 149 | return () => clearTimeout(timer); | |
| 120 | − | }, [data, messages, members, joined]); | |
| 150 | + | }, [view, messages, members, joined, canManage]); | |
| 151 | + | // Renamed while open, here or by someone else: the address follows the name. | |
| 152 | + | const navigate = useNavigate(); | |
| 153 | + | const nameShown = useRef(data.channel.name); | |
| 154 | + | useEffect(() => { | |
| 155 | + | if (channel.kind !== "channel" || channel.name === nameShown.current || !slug) return; | |
| 156 | + | nameShown.current = channel.name; | |
| 157 | + | navigate(`${channelPath(slug, channel)}${window.location.search}`, { replace: true, preventScrollReset: true }); | |
| 158 | + | }, [channel, slug, navigate]); | |
| 121 | 159 | const [info, setInfo] = useState(false); | |
| 122 | 160 | // A phone: back to the list, by the chevron or a swipe from the edge; | |
| 123 | 161 | // and a long-pressed message's actions. | |
| ⋯ | |||
| 269 | 307 | if (event.channel_id !== data.channel.id) return; | |
| 270 | 308 | if (event.member.kind === "user" && event.member.id === me?.id) return; | |
| 271 | 309 | setTyping((now) => new Map(now).set(`${event.member.kind}:${event.member.id}`, { member: event.member, until: new Date(event.until).getTime() })); | |
| 310 | + | } else if (event.type === "channel.updated") { | |
| 311 | + | // Renamed, a new topic, archived or back: everyone looking sees it at once. | |
| 312 | + | if (event.channel.id !== data.channel.id) return; | |
| 313 | + | setChannel(event.channel); | |
| 314 | + | refresh(); | |
| 272 | 315 | } | |
| 273 | 316 | }, | |
| 274 | − | [data.channel.id, threadId, me?.id, countReply], | |
| 317 | + | [data.channel.id, threadId, me?.id, countReply, refresh], | |
| 275 | 318 | ); | |
| 276 | 319 | // Back after a drop: what was missed. | |
| 277 | 320 | const onReconnect = useCallback(async () => { | |
| ⋯ | |||
| 438 | 481 | setParams(next, { preventScrollReset: true, replace: !!threadId && !!id }); | |
| 439 | 482 | }; | |
| 440 | 483 | ||
| 484 | + | const updateChannel = useCallback( | |
| 485 | + | async (change: ChannelChange): Promise<Result<unknown>> => { | |
| 486 | + | const done = await send<{ channel: Channel }>({ intent: "update_channel", channel_id: data.channel.id, ...change }); | |
| 487 | + | if (done.ok) setChannel(done.value.channel); | |
| 488 | + | return done; | |
| 489 | + | }, | |
| 490 | + | [send, data.channel.id], | |
| 491 | + | ); | |
| 492 | + | ||
| 441 | 493 | const toggleStar = async () => { | |
| 442 | 494 | await send({ intent: "preferences", channel_id: data.channel.id, starred: !starred }); | |
| 443 | 495 | refresh(); | |
| ⋯ | |||
| 466 | 518 | ||
| 467 | 519 | const rows = useMemo(() => timeline(messages, new Date(), zone), [messages, zone]); | |
| 468 | 520 | const typers = [...typing.values()].map((t) => t.member); | |
| 469 | − | const name = data.channel.name ?? ""; | |
| 521 | + | const name = channel.name ?? ""; | |
| 522 | + | const archived = channel.kind === "channel" && !!channel.archived_at; | |
| 470 | 523 | const placeholder = isDm | |
| 471 | 524 | ? `Message ${others.map(shownName).join(", ") || "yourself"}` | |
| 472 | 525 | : `Message #${name}. @ a teammate or an agent`; | |
| ⋯ | |||
| 496 | 549 | <section aria-label={data.title} className="flex min-w-0 grow flex-col"> | |
| 497 | 550 | <ChannelHeader | |
| 498 | 551 | onBack={back} | |
| 499 | − | data={data} | |
| 552 | + | data={view} | |
| 500 | 553 | others={others} | |
| 501 | 554 | starred={starred} | |
| 502 | 555 | muted={muted} | |
| ⋯ | |||
| 526 | 579 | </button> | |
| 527 | 580 | </div> | |
| 528 | 581 | ) : ( | |
| 529 | − | <ConversationStart data={data} others={others} /> | |
| 582 | + | <ConversationStart data={view} others={others} /> | |
| 530 | 583 | )} | |
| 531 | 584 | {rows.map((row) => | |
| 532 | 585 | row.kind === "day" ? ( | |
| ⋯ | |||
| 552 | 605 | <div className="shrink-0 px-2 pb-3 sm:px-4 sm:pb-4 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))] in-data-[keyboard=open]:pb-2"> | |
| 553 | 606 | <div className="mx-auto max-w-[56rem]"> | |
| 554 | 607 | <TypingLine members={typers} /> | |
| 555 | − | {joined ? ( | |
| 608 | + | {archived ? ( | |
| 609 | + | <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3"> | |
| 610 | + | <p className="flex items-center gap-2 text-sm text-muted"> | |
| 611 | + | <Archive size={15} className="shrink-0 text-faint" /> | |
| 612 | + | <span> | |
| 613 | + | <span className="font-medium text-fg">#{name}</span> is archived. Its history stays here to read; nobody can post in it. | |
| 614 | + | </span> | |
| 615 | + | </p> | |
| 616 | + | {canManage && ( | |
| 617 | + | <button | |
| 618 | + | type="button" | |
| 619 | + | onClick={() => void updateChannel({ archived: false })} | |
| 620 | + | className="inline-flex h-9 items-center gap-2 rounded-md border border-line px-3 text-sm font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-raised" | |
| 621 | + | > | |
| 622 | + | <ArchiveRestore size={15} /> | |
| 623 | + | Unarchive | |
| 624 | + | </button> | |
| 625 | + | )} | |
| 626 | + | </div> | |
| 627 | + | ) : joined ? ( | |
| 556 | 628 | <Composer draftKey={data.channel.id} placeholder={placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus /> | |
| 557 | 629 | ) : ( | |
| 558 | 630 | <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3"> | |
| ⋯ | |||
| 581 | 653 | code={code} | |
| 582 | 654 | slug={slug} | |
| 583 | 655 | people={people} | |
| 584 | − | joined={joined} | |
| 656 | + | joined={joined && !archived} | |
| 585 | 657 | onSend={(body) => void post(body, threadId)} | |
| 586 | 658 | onRetry={retry} | |
| 659 | + | onTyping={onTyping} | |
| 587 | 660 | draftKey={`${data.channel.id}:${threadId}`} | |
| 588 | 661 | /> | |
| 589 | 662 | </SidePanel> | |
| ⋯ | |||
| 594 | 667 | ) : ( | |
| 595 | 668 | info && ( | |
| 596 | 669 | <SidePanel title={isDm ? "Details" : "About this channel"} onClose={toggleInfo}> | |
| 597 | − | <InfoPanel data={data} members={members} slug={slug} people={people} onAdded={(m) => setMembers((now) => [...now, m])} joined={joined} /> | |
| 670 | + | <InfoPanel | |
| 671 | + | data={view} | |
| 672 | + | members={members} | |
| 673 | + | slug={slug} | |
| 674 | + | people={people} | |
| 675 | + | onAdded={(m) => setMembers((now) => [...now, m])} | |
| 676 | + | joined={joined} | |
| 677 | + | canManage={canManage} | |
| 678 | + | onUpdate={updateChannel} | |
| 679 | + | /> | |
| 598 | 680 | </SidePanel> | |
| 599 | 681 | ) | |
| 600 | 682 | )} | |
| ⋯ | |||
| 694 | 776 | {channel.kind === "channel" ? ( | |
| 695 | 777 | <> | |
| 696 | 778 | <h1 className="flex min-w-0 shrink-0 items-center gap-1 text-[0.9375rem] font-semibold"> | |
| 697 | − | {channel.private ? <Lock size={15} className="text-faint" /> : <Hash size={16} className="text-faint" />} | |
| 779 | + | {channel.private ? ( | |
| 780 | + | <Hint label="Private channel: only its members can find and read it"> | |
| 781 | + | <span className="flex"> | |
| 782 | + | <Lock size={15} className="text-faint" aria-label="Private channel" /> | |
| 783 | + | </span> | |
| 784 | + | </Hint> | |
| 785 | + | ) : ( | |
| 786 | + | <Hash size={16} className="text-faint" aria-label="Public channel" /> | |
| 787 | + | )} | |
| 698 | 788 | <span className="truncate">{channel.name}</span> | |
| 699 | 789 | </h1> | |
| 700 | 790 | <StarButton starred={starred} onClick={onStar} /> | |
| ⋯ | |||
| 705 | 795 | </span> | |
| 706 | 796 | </Hint> | |
| 707 | 797 | )} | |
| 798 | + | {channel.archived_at && ( | |
| 799 | + | <Badge tone="neutral" className="shrink-0"> | |
| 800 | + | <Archive size={11} /> | |
| 801 | + | Archived | |
| 802 | + | </Badge> | |
| 803 | + | )} | |
| 708 | 804 | {channel.topic && ( | |
| 709 | 805 | <> | |
| 710 | 806 | <span aria-hidden="true" className="hidden h-4 w-px shrink-0 bg-line sm:block" /> | |
| ⋯ | |||
| 851 | 947 | task: <ListChecks size={16} />, | |
| 852 | 948 | deploy: <Rocket size={16} />, | |
| 853 | 949 | approval: <ShieldCheck size={16} />, | |
| 950 | + | session: <Bot size={16} />, | |
| 854 | 951 | }; | |
| 855 | 952 | ||
| 953 | + | /** | |
| 954 | + | * An agent session's state, as a chip: Working pulses softly in lavender, | |
| 955 | + | * Needs approval is amber, Done green, Failed red, Stopped and Queued quiet. | |
| 956 | + | * The agent changes the card in place, so this follows the session live. | |
| 957 | + | */ | |
| 958 | + | function SessionStateChip({ state }: { state: string }) { | |
| 959 | + | const chip = sessionChip(state); | |
| 960 | + | return ( | |
| 961 | + | <Badge tone={chip.tone} className="mt-0.5"> | |
| 962 | + | {chip.live && ( | |
| 963 | + | <span aria-hidden="true" className="relative flex size-1.5"> | |
| 964 | + | <span className="absolute inline-flex size-full animate-ping rounded-full bg-current opacity-60 motion-reduce:animate-none" /> | |
| 965 | + | <span className="relative inline-flex size-1.5 rounded-full bg-current" /> | |
| 966 | + | </span> | |
| 967 | + | )} | |
| 968 | + | {state} | |
| 969 | + | </Badge> | |
| 970 | + | ); | |
| 971 | + | } | |
| 972 | + | ||
| 856 | 973 | /** How loud a card's state reads, from what it says. */ | |
| 857 | 974 | function stateTone(state: string): BadgeTone { | |
| 858 | 975 | const s = state.toLowerCase(); | |
| ⋯ | |||
| 866 | 983 | ||
| 867 | 984 | /** A card g1t or an agent posted: what it is about, a line of detail, and its state. */ | |
| 868 | 985 | export function CardBox({ card, href }: { card: MessageCard; href: string | null }) { | |
| 986 | + | // An agent session's card: updated in place as the session moves (`message.updated`). | |
| 987 | + | const session = card.kind === "session"; | |
| 869 | 988 | const inner = ( | |
| 870 | 989 | <> | |
| 871 | − | <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-raised text-muted group-hover:text-fg"> | |
| 990 | + | <span | |
| 991 | + | className={`mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors group-hover:text-fg ${ | |
| 992 | + | session && sessionChip(card.state).live ? "bg-accent/10 text-accent" : "bg-raised text-muted" | |
| 993 | + | }`} | |
| 994 | + | > | |
| 872 | 995 | {CARD_ICONS[card.kind] ?? <Sparkles size={16} />} | |
| 873 | 996 | </span> | |
| 874 | 997 | <span className="min-w-0 grow"> | |
| 875 | 998 | <span className="line-clamp-2 text-sm font-medium text-fg">{card.title}</span> | |
| 876 | − | {card.detail && <span className="mt-0.5 block truncate font-mono text-xs text-muted">{card.detail}</span>} | |
| 999 | + | {card.detail && <span className="mt-0.5 block truncate font-mono text-xs text-muted tabular-nums">{card.detail}</span>} | |
| 877 | 1000 | </span> | |
| 878 | − | {card.state && ( | |
| 879 | − | <Badge tone={stateTone(card.state)} className="mt-0.5"> | |
| 880 | − | {card.state} | |
| 881 | − | </Badge> | |
| 882 | − | )} | |
| 1001 | + | {card.state && | |
| 1002 | + | (session ? ( | |
| 1003 | + | <SessionStateChip state={card.state} /> | |
| 1004 | + | ) : ( | |
| 1005 | + | <Badge tone={stateTone(card.state)} className="mt-0.5"> | |
| 1006 | + | {card.state} | |
| 1007 | + | </Badge> | |
| 1008 | + | ))} | |
| 883 | 1009 | </> | |
| 884 | 1010 | ); | |
| 885 | 1011 | const box = "group mt-1.5 flex max-w-xl items-start gap-3 rounded-xl border border-line bg-surface px-3.5 py-3 transition-colors"; | |
| ⋯ | |||
| 1269 | 1395 | joined, | |
| 1270 | 1396 | onSend, | |
| 1271 | 1397 | onRetry, | |
| 1398 | + | onTyping, | |
| 1272 | 1399 | draftKey, | |
| 1273 | 1400 | }: { | |
| 1274 | 1401 | root: ShownMessage | null; | |
| ⋯ | |||
| 1281 | 1408 | joined: boolean; | |
| 1282 | 1409 | onSend: (body: string) => void; | |
| 1283 | 1410 | onRetry: (message: ShownMessage) => void; | |
| 1411 | + | /** Says "is typing" to the others in the channel, as the main composer does. */ | |
| 1412 | + | onTyping: () => void; | |
| 1284 | 1413 | draftKey: string; | |
| 1285 | 1414 | }) { | |
| 1286 | 1415 | const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]); | |
| ⋯ | |||
| 1314 | 1443 | </div> | |
| 1315 | 1444 | {joined && ( | |
| 1316 | 1445 | <div className="shrink-0 p-3"> | |
| 1317 | − | <Composer draftKey={draftKey} placeholder="Reply…" people={people} onSend={onSend} compact autoFocus /> | |
| 1446 | + | <Composer draftKey={draftKey} placeholder="Reply…" people={people} onSend={onSend} onTyping={onTyping} compact autoFocus /> | |
| 1447 | + | </div> | |
| 1448 | + | )} | |
| 1449 | + | </> | |
| 1450 | + | ); | |
| 1451 | + | } | |
| 1452 | + | ||
| 1453 | + | /** | |
| 1454 | + | * A channel's name, topic and visibility, and changing them: any member | |
| 1455 | + | * edits the topic; renaming and archiving are for whoever the workspace's | |
| 1456 | + | * chat settings allow (`canManage`). #general keeps its name and stays. | |
| 1457 | + | */ | |
| 1458 | + | function ChannelAbout({ | |
| 1459 | + | channel, | |
| 1460 | + | joined, | |
| 1461 | + | canManage, | |
| 1462 | + | onUpdate, | |
| 1463 | + | }: { | |
| 1464 | + | channel: Channel; | |
| 1465 | + | joined: boolean; | |
| 1466 | + | canManage: boolean; | |
| 1467 | + | onUpdate: (change: ChannelChange) => Promise<Result<unknown>>; | |
| 1468 | + | }) { | |
| 1469 | + | const [editing, setEditing] = useState<"name" | "topic" | null>(null); | |
| 1470 | + | const [value, setValue] = useState(""); | |
| 1471 | + | const [error, setError] = useState<string | null>(null); | |
| 1472 | + | const [busy, setBusy] = useState(false); | |
| 1473 | + | const [confirm, setConfirm] = useState(false); | |
| 1474 | + | const general = channel.name === "general"; | |
| 1475 | + | const archived = !!channel.archived_at; | |
| 1476 | + | const mayRename = canManage && !general && !archived; | |
| 1477 | + | const mayTopic = joined && !archived; | |
| 1478 | + | const save = async (change: ChannelChange) => { | |
| 1479 | + | setBusy(true); | |
| 1480 | + | setError(null); | |
| 1481 | + | const done = await onUpdate(change); | |
| 1482 | + | setBusy(false); | |
| 1483 | + | if (!done.ok) return setError(done.error.message); | |
| 1484 | + | setEditing(null); | |
| 1485 | + | setConfirm(false); | |
| 1486 | + | }; | |
| 1487 | + | const start = (field: "name" | "topic") => { | |
| 1488 | + | setEditing(field); | |
| 1489 | + | setValue(field === "name" ? (channel.name ?? "") : (channel.topic ?? "")); | |
| 1490 | + | setError(null); | |
| 1491 | + | }; | |
| 1492 | + | const edit = (field: "name" | "topic") => ( | |
| 1493 | + | <form | |
| 1494 | + | className="mt-1.5 space-y-2" | |
| 1495 | + | onSubmit={(event) => { | |
| 1496 | + | event.preventDefault(); | |
| 1497 | + | void save(field === "name" ? { name: value } : { topic: value }); | |
| 1498 | + | }} | |
| 1499 | + | > | |
| 1500 | + | <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"> | |
| 1501 | + | {field === "name" && (channel.private ? <Lock size={13} className="text-faint" /> : <Hash size={13} className="text-faint" />)} | |
| 1502 | + | <input | |
| 1503 | + | autoFocus | |
| 1504 | + | value={value} | |
| 1505 | + | onChange={(event) => setValue(event.target.value)} | |
| 1506 | + | onKeyDown={(event) => event.key === "Escape" && setEditing(null)} | |
| 1507 | + | maxLength={field === "name" ? 80 : 250} | |
| 1508 | + | placeholder={field === "name" ? "channel-name" : "What this channel is for"} | |
| 1509 | + | aria-label={field === "name" ? "Channel name" : "Topic"} | |
| 1510 | + | autoComplete="off" | |
| 1511 | + | data-1p-ignore | |
| 1512 | + | className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" | |
| 1513 | + | /> | |
| 1514 | + | </span> | |
| 1515 | + | <div className="flex justify-end gap-1.5"> | |
| 1516 | + | <button type="button" onClick={() => setEditing(null)} className="h-7 rounded-md px-2.5 text-xs text-muted hover:bg-raised hover:text-fg"> | |
| 1517 | + | Cancel | |
| 1518 | + | </button> | |
| 1519 | + | <button type="submit" disabled={busy} className="h-7 rounded-md bg-accent px-2.5 text-xs font-medium text-bg hover:bg-accent-hover disabled:opacity-50"> | |
| 1520 | + | {busy ? "Saving…" : "Save"} | |
| 1521 | + | </button> | |
| 1522 | + | </div> | |
| 1523 | + | </form> | |
| 1524 | + | ); | |
| 1525 | + | const editButton = (field: "name" | "topic", label: string) => ( | |
| 1526 | + | <button | |
| 1527 | + | type="button" | |
| 1528 | + | onClick={() => start(field)} | |
| 1529 | + | aria-label={label} | |
| 1530 | + | className="rounded px-1.5 py-0.5 text-xs text-muted opacity-0 transition-opacity group-hover/about:opacity-100 hover:bg-raised hover:text-fg focus-visible:opacity-100 [@media(hover:none)]:opacity-100" | |
| 1531 | + | > | |
| 1532 | + | Edit | |
| 1533 | + | </button> | |
| 1534 | + | ); | |
| 1535 | + | return ( | |
| 1536 | + | <> | |
| 1537 | + | <section className="group/about rounded-xl border border-line bg-surface p-3.5"> | |
| 1538 | + | <div className="flex items-center justify-between"> | |
| 1539 | + | <h3 className="text-xs font-medium text-faint">Name</h3> | |
| 1540 | + | {mayRename && editing !== "name" && editButton("name", "Rename channel")} | |
| 1318 | 1541 | </div> | |
| 1542 | + | {editing === "name" ? edit("name") : <p className="mt-1 text-sm text-fg-soft">#{channel.name}</p>} | |
| 1543 | + | <div className="mt-3 flex items-center justify-between"> | |
| 1544 | + | <h3 className="text-xs font-medium text-faint">Topic</h3> | |
| 1545 | + | {mayTopic && editing !== "topic" && editButton("topic", "Edit topic")} | |
| 1546 | + | </div> | |
| 1547 | + | {editing === "topic" ? edit("topic") : <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>} | |
| 1548 | + | <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3> | |
| 1549 | + | <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft"> | |
| 1550 | + | {channel.private ? <Lock size={13} /> : <Hash size={13} />} | |
| 1551 | + | {channel.private ? "Private: only its members can find and read it" : "Public: anyone in the workspace can read and join"} | |
| 1552 | + | </p> | |
| 1553 | + | {error && <p className="mt-2 text-xs text-danger">{error}</p>} | |
| 1554 | + | </section> | |
| 1555 | + | {canManage && !general && ( | |
| 1556 | + | <button | |
| 1557 | + | type="button" | |
| 1558 | + | onClick={() => (archived ? void save({ archived: false }) : setConfirm(true))} | |
| 1559 | + | disabled={busy} | |
| 1560 | + | className="mt-2 flex h-8 w-full items-center justify-center gap-2 rounded-md text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50" | |
| 1561 | + | > | |
| 1562 | + | {archived ? <ArchiveRestore size={14} /> : <Archive size={14} />} | |
| 1563 | + | {archived ? "Unarchive channel" : "Archive channel"} | |
| 1564 | + | </button> | |
| 1319 | 1565 | )} | |
| 1566 | + | <AlertDialog open={confirm} onOpenChange={setConfirm}> | |
| 1567 | + | <AlertDialogContent> | |
| 1568 | + | <AlertDialogHeader> | |
| 1569 | + | <AlertDialogTitle>Archive #{channel.name}?</AlertDialogTitle> | |
| 1570 | + | <AlertDialogDescription> | |
| 1571 | + | It leaves everyone's sidebar and nobody can post in it. Its history stays readable, and it can be unarchived from Browse | |
| 1572 | + | channels, Archived. | |
| 1573 | + | </AlertDialogDescription> | |
| 1574 | + | </AlertDialogHeader> | |
| 1575 | + | <AlertDialogFooter> | |
| 1576 | + | <AlertDialogCancel>Cancel</AlertDialogCancel> | |
| 1577 | + | <AlertDialogAction | |
| 1578 | + | onClick={(event) => { | |
| 1579 | + | event.preventDefault(); | |
| 1580 | + | void save({ archived: true }); | |
| 1581 | + | }} | |
| 1582 | + | disabled={busy} | |
| 1583 | + | > | |
| 1584 | + | {busy ? "Archiving…" : "Archive"} | |
| 1585 | + | </AlertDialogAction> | |
| 1586 | + | </AlertDialogFooter> | |
| 1587 | + | </AlertDialogContent> | |
| 1588 | + | </AlertDialog> | |
| 1320 | 1589 | </> | |
| 1321 | 1590 | ); | |
| 1322 | 1591 | } | |
| ⋯ | |||
| 1328 | 1597 | people, | |
| 1329 | 1598 | onAdded, | |
| 1330 | 1599 | joined, | |
| 1600 | + | canManage, | |
| 1601 | + | onUpdate, | |
| 1331 | 1602 | }: { | |
| 1332 | 1603 | data: Loaded; | |
| 1333 | 1604 | members: ChannelMember[]; | |
| ⋯ | |||
| 1335 | 1606 | people: Mentionable[]; | |
| 1336 | 1607 | onAdded: (member: ChannelMember) => void; | |
| 1337 | 1608 | joined: boolean; | |
| 1609 | + | /** Whether the viewer may rename, archive and unarchive it. */ | |
| 1610 | + | canManage: boolean; | |
| 1611 | + | onUpdate: (change: ChannelChange) => Promise<Result<unknown>>; | |
| 1338 | 1612 | }) { | |
| 1339 | 1613 | const channel = data.channel; | |
| 1340 | 1614 | const agents = members.filter((m) => m.member.kind === "agent"); | |
| 1341 | 1615 | const humans = members.filter((m) => m.member.kind === "user"); | |
| 1342 | 1616 | return ( | |
| 1343 | 1617 | <div className="min-h-0 grow overflow-y-auto p-4 [scrollbar-width:thin]"> | |
| 1344 | − | {channel.kind === "channel" && ( | |
| 1345 | − | <section className="rounded-xl border border-line bg-surface p-3.5"> | |
| 1346 | − | <h3 className="text-xs font-medium text-faint">Topic</h3> | |
| 1347 | − | <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p> | |
| 1348 | − | <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3> | |
| 1349 | − | <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft"> | |
| 1350 | − | {channel.private ? <Lock size={13} /> : <Hash size={13} />} | |
| 1351 | − | {channel.private ? "Private: invited members only" : "Public to the workspace"} | |
| 1352 | − | </p> | |
| 1353 | − | </section> | |
| 1354 | − | )} | |
| 1618 | + | {channel.kind === "channel" && <ChannelAbout channel={channel} joined={joined} canManage={canManage} onUpdate={onUpdate} />} | |
| 1355 | 1619 | {agents.length > 0 && ( | |
| 1356 | 1620 | <section className="mt-5"> | |
| 1357 | 1621 | <h3 className="mb-2 px-1 text-xs font-medium text-faint">Agents · {agents.length}</h3> | |
| 1 | + | /** | |
| 2 | + | * A person's last year on their profile: a square a day, a column a week, | |
| 3 | + | * shaded in the accent by how much they did (issues and pull requests | |
| 4 | + | * opened, reviews given) on repositories the viewer can see. The layout is | |
| 5 | + | * lib/contribution-calendar.ts; the counts come from the work service's | |
| 6 | + | * `contributions`, which never counts work the viewer could not open. | |
| 7 | + | * | |
| 8 | + | * One hint serves every square: it follows the pointer, so a year of days | |
| 9 | + | * costs one tooltip, not 365. | |
| 10 | + | */ | |
| 11 | + | import { type PointerEvent, useEffect, useMemo, useRef, useState } from "react"; | |
| 12 | + | ||
| 13 | + | import { type Level, buildCalendar, dayLabel, totalLabel } from "../lib/contribution-calendar"; | |
| 14 | + | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 15 | + | ||
| 16 | + | /** A square's side and the space between squares, in pixels. */ | |
| 17 | + | const CELL = 10; | |
| 18 | + | const GAP = 3; | |
| 19 | + | const STEP = CELL + GAP; | |
| 20 | + | ||
| 21 | + | /** Each level's shade: the raised gray for nothing, then the accent, stronger with more. */ | |
| 22 | + | const SHADES: Record<Level, string> = { | |
| 23 | + | 0: "bg-raised", | |
| 24 | + | 1: "bg-accent/25", | |
| 25 | + | 2: "bg-accent/45", | |
| 26 | + | 3: "bg-accent/70", | |
| 27 | + | 4: "bg-accent", | |
| 28 | + | }; | |
| 29 | + | ||
| 30 | + | const WEEKDAYS = ["", "Mon", "", "Wed", "", "Fri", ""]; | |
| 31 | + | ||
| 32 | + | type Hovered = { date: string; count: number; left: number; top: number }; | |
| 33 | + | ||
| 34 | + | export function ContributionCalendar({ | |
| 35 | + | days, | |
| 36 | + | total, | |
| 37 | + | today, | |
| 38 | + | from, | |
| 39 | + | }: { | |
| 40 | + | days: readonly { date: string; count: number }[]; | |
| 41 | + | total: number; | |
| 42 | + | /** `YYYY-MM-DD`, UTC: the last day shown. */ | |
| 43 | + | today: string; | |
| 44 | + | /** The first day counted; a year before `today` when absent. */ | |
| 45 | + | from?: string; | |
| 46 | + | }) { | |
| 47 | + | const calendar = useMemo(() => buildCalendar(days, today, from), [days, today, from]); | |
| 48 | + | const scroller = useRef<HTMLDivElement>(null); | |
| 49 | + | const [hovered, setHovered] = useState<Hovered | null>(null); | |
| 50 | + | ||
| 51 | + | // On a narrow screen the calendar scrolls inside its card; start at today. | |
| 52 | + | useEffect(() => { | |
| 53 | + | const element = scroller.current; | |
| 54 | + | if (element) element.scrollLeft = element.scrollWidth; | |
| 55 | + | }, []); | |
| 56 | + | ||
| 57 | + | const over = (event: PointerEvent<HTMLDivElement>) => { | |
| 58 | + | const cell = (event.target as HTMLElement).closest<HTMLElement>("[data-date]"); | |
| 59 | + | if (!cell) return setHovered(null); | |
| 60 | + | const date = cell.dataset.date!; | |
| 61 | + | if (hovered?.date === date) return; | |
| 62 | + | setHovered({ date, count: Number(cell.dataset.count ?? 0), left: cell.offsetLeft, top: cell.offsetTop }); | |
| 63 | + | }; | |
| 64 | + | ||
| 65 | + | const heading = totalLabel(total); | |
| 66 | + | return ( | |
| 67 | + | <section aria-label="Contributions" className="rounded-xl border border-line bg-surface/50 px-4 pt-3.5 pb-3"> | |
| 68 | + | <h2 className="text-sm font-medium">{heading}</h2> | |
| 69 | + | <div ref={scroller} className="mt-3 overflow-x-auto overflow-y-hidden pb-1 [scrollbar-width:thin]"> | |
| 70 | + | <div className="flex w-max text-[0.625rem] leading-none text-faint"> | |
| 71 | + | {/* The weekdays, under the months' row. */} | |
| 72 | + | <div aria-hidden="true" className="mr-1.5 flex shrink-0 flex-col" style={{ gap: GAP, paddingTop: 16 }}> | |
| 73 | + | {WEEKDAYS.map((label, index) => ( | |
| 74 | + | <span key={index} className="flex items-center" style={{ height: CELL }}> | |
| 75 | + | {label} | |
| 76 | + | </span> | |
| 77 | + | ))} | |
| 78 | + | </div> | |
| 79 | + | <div | |
| 80 | + | role="img" | |
| 81 | + | aria-label={heading} | |
| 82 | + | className="relative" | |
| 83 | + | onPointerOver={over} | |
| 84 | + | // A tap's pointer leaves as the finger lifts: its hint stays until the next tap elsewhere. | |
| 85 | + | onPointerLeave={(event) => event.pointerType === "mouse" && setHovered(null)} | |
| 86 | + | > | |
| 87 | + | <div aria-hidden="true" className="relative h-4" style={{ width: calendar.weeks.length * STEP - GAP }}> | |
| 88 | + | {calendar.months.map((month) => ( | |
| 89 | + | <span key={`${month.week}:${month.label}`} className="absolute top-0" style={{ left: month.week * STEP }}> | |
| 90 | + | {month.label} | |
| 91 | + | </span> | |
| 92 | + | ))} | |
| 93 | + | </div> | |
| 94 | + | <div className="flex" style={{ gap: GAP }}> | |
| 95 | + | {calendar.weeks.map((week, column) => ( | |
| 96 | + | <div key={column} className="flex flex-col" style={{ gap: GAP }}> | |
| 97 | + | {week.map((day, row) => | |
| 98 | + | day ? ( | |
| 99 | + | <span | |
| 100 | + | key={row} | |
| 101 | + | data-date={day.date} | |
| 102 | + | data-count={day.count} | |
| 103 | + | className={`block rounded-[2px] ${SHADES[day.level]} ${hovered?.date === day.date ? "ring-1 ring-fg/60" : ""}`} | |
| 104 | + | style={{ width: CELL, height: CELL }} | |
| 105 | + | /> | |
| 106 | + | ) : ( | |
| 107 | + | <span key={row} className="block" style={{ width: CELL, height: CELL }} /> | |
| 108 | + | ), | |
| 109 | + | )} | |
| 110 | + | </div> | |
| 111 | + | ))} | |
| 112 | + | </div> | |
| 113 | + | {/* The one hint, over whichever square the pointer is on. */} | |
| 114 | + | <Tooltip open={hovered != null} onOpenChange={(open) => !open && setHovered(null)}> | |
| 115 | + | <TooltipTrigger asChild> | |
| 116 | + | <span | |
| 117 | + | aria-hidden="true" | |
| 118 | + | className="pointer-events-none absolute" | |
| 119 | + | style={{ left: hovered?.left ?? 0, top: hovered?.top ?? 0, width: CELL, height: CELL }} | |
| 120 | + | /> | |
| 121 | + | </TooltipTrigger> | |
| 122 | + | <TooltipContent side="top">{hovered ? dayLabel(hovered.count, hovered.date) : null}</TooltipContent> | |
| 123 | + | </Tooltip> | |
| 124 | + | </div> | |
| 125 | + | </div> | |
| 126 | + | </div> | |
| 127 | + | <div aria-hidden="true" className="mt-2 flex items-center justify-end gap-1 text-[0.6875rem] text-faint"> | |
| 128 | + | <span className="mr-1">Less</span> | |
| 129 | + | {([0, 1, 2, 3, 4] as Level[]).map((level) => ( | |
| 130 | + | <span key={level} className={`block rounded-[2px] ${SHADES[level]}`} style={{ width: CELL, height: CELL }} /> | |
| 131 | + | ))} | |
| 132 | + | <span className="ml-1">More</span> | |
| 133 | + | </div> | |
| 134 | + | </section> | |
| 135 | + | ); | |
| 136 | + | } |
| 6 | 6 | import { addressActions, backupChoices } from "../lib/emails"; | |
| 7 | 7 | import type { EmailActionData } from "../lib/emails.server"; | |
| 8 | 8 | import { ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui"; | |
| 9 | + | import { SelectField } from "./ui/select"; | |
| 9 | 10 | import { SwitchCard } from "./ui/switch"; | |
| 10 | 11 | ||
| 11 | 12 | /** One small form posting one intent about one address. */ | |
| ⋯ | |||
| 181 | 182 | </p> | |
| 182 | 183 | <Form method="post" className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-end"> | |
| 183 | 184 | <input type="hidden" name="intent" value="backup-email" /> | |
| 184 | − | <label className="block grow"> | |
| 185 | − | <span className="sr-only">Backup address</span> | |
| 186 | − | <select | |
| 185 | + | <div className="block grow"> | |
| 186 | + | <SelectField | |
| 187 | 187 | name="backup" | |
| 188 | + | aria-label="Backup address" | |
| 188 | 189 | defaultValue={backup?.email ?? ""} | |
| 189 | 190 | disabled={backups.length === 0} | |
| 190 | − | className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors hover:border-line-strong focus:border-accent-dim disabled:opacity-60" | |
| 191 | − | > | |
| 192 | − | <option value="">Primary address only</option> | |
| 193 | − | {backups.map((email) => ( | |
| 194 | − | <option key={email.email} value={email.email}> | |
| 195 | − | {email.email} | |
| 196 | − | </option> | |
| 197 | − | ))} | |
| 198 | − | </select> | |
| 199 | − | </label> | |
| 191 | + | className="h-auto py-2" | |
| 192 | + | options={[{ value: "", label: "Primary address only" }, ...backups.map((email) => ({ value: email.email, label: email.email }))]} | |
| 193 | + | /> | |
| 194 | + | </div> | |
| 200 | 195 | <SubmitButton variant="quiet" pending="Saving…" match={{ intent: "backup-email" }} disabled={backups.length === 0}> | |
| 201 | 196 | Save | |
| 202 | 197 | </SubmitButton> | |
| 1 | 1 | import { ImagePlus, Lock, Plus, Search, Trash2, Upload } from "lucide-react"; | |
| 2 | 2 | import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | − | import { useFetcher } from "react-router"; | |
| 3 | + | import { Link, useFetcher } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { type CustomEmoji, type EmojiList, type EmojiUpload, MAX_EMOJI_BYTES, MAX_EMOJI_SIDE } from "@g1t/contracts"; | |
| 6 | 6 | ||
| ⋯ | |||
| 11 | 11 | import { Button, EmptyState, ErrorText, TimeAgo, notACredential } from "../ui"; | |
| 12 | 12 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog"; | |
| 13 | 13 | import { Hint } from "../ui/hint"; | |
| 14 | + | import { SelectField } from "../ui/select"; | |
| 14 | 15 | import { cleanEmojiName, emojiNameProblem, standardCodes } from "../../lib/emoji"; | |
| 15 | 16 | ||
| 16 | 17 | /** What the page's action answers (routes/workspace/emoji.tsx). */ | |
| ⋯ | |||
| 22 | 23 | /** | |
| 23 | 24 | * A workspace's own emoji: every one with its name, who added it and when; | |
| 24 | 25 | * adding one from an image, giving one another name, and removing them. | |
| 25 | − | * Owners also choose who may add them. | |
| 26 | + | * Who may add them is a chat setting (routes/workspace/chat-settings.tsx). | |
| 26 | 27 | */ | |
| 27 | 28 | export function EmojiSettings({ slug, list, usercontent, meId }: { slug: string; list: EmojiList; usercontent: string; meId: string }) { | |
| 28 | 29 | const [query, setQuery] = useState(""); | |
| ⋯ | |||
| 99 | 100 | </section> | |
| 100 | 101 | )} | |
| 101 | 102 | ||
| 102 | − | {list.can_manage && <UploadSetting value={list.emoji_upload} />} | |
| 103 | + | {list.can_manage && <UploadSetting slug={slug} value={list.emoji_upload} />} | |
| 103 | 104 | </div> | |
| 104 | 105 | <UploadDialog open={adding} onOpenChange={setAdding} taken={taken} /> | |
| 105 | 106 | <AliasDialog open={aliasing} onOpenChange={setAliasing} emoji={list.emoji} taken={taken} usercontent={usercontent} /> | |
| ⋯ | |||
| 169 | 170 | } | |
| 170 | 171 | ||
| 171 | 172 | /** Owners: who may add emoji. */ | |
| 172 | − | function UploadSetting({ value }: { value: EmojiUpload }) { | |
| 173 | − | const fetcher = useFetcher<EmojiActionResult>(); | |
| 174 | − | const current = (fetcher.formData?.get("value") as EmojiUpload | null) ?? value; | |
| 175 | − | const choices: { value: EmojiUpload; label: string; detail: string }[] = [ | |
| 176 | − | { value: "members", label: "Every member", detail: "Anyone in the workspace can add emoji and aliases." }, | |
| 177 | − | { value: "admins", label: "Owners only", detail: "Members use the workspace’s emoji; only owners add them." }, | |
| 178 | − | ]; | |
| 173 | + | /** Who may add emoji: one of the workspace's chat settings, changed in Settings, Chat. */ | |
| 174 | + | function UploadSetting({ slug, value }: { slug: string; value: EmojiUpload }) { | |
| 179 | 175 | return ( | |
| 180 | − | <section aria-labelledby="emoji-upload" className="rounded-xl border border-line p-4"> | |
| 181 | − | <h2 id="emoji-upload" className="text-sm font-medium text-fg"> | |
| 182 | − | Who can add emoji | |
| 183 | − | </h2> | |
| 184 | − | <p className="mt-1 text-xs text-muted">Whoever added an emoji, and owners, can remove it.</p> | |
| 185 | − | <div role="radiogroup" aria-labelledby="emoji-upload" className="mt-3 grid gap-2 sm:grid-cols-2"> | |
| 186 | − | {choices.map((choice) => ( | |
| 187 | − | <button | |
| 188 | − | key={choice.value} | |
| 189 | − | type="button" | |
| 190 | − | role="radio" | |
| 191 | − | aria-checked={current === choice.value} | |
| 192 | − | onClick={() => current !== choice.value && fetcher.submit({ intent: "setting", value: choice.value }, { method: "post" })} | |
| 193 | − | className={`rounded-lg border px-3 py-2.5 text-left transition-colors ${ | |
| 194 | − | current === choice.value ? "border-accent/60 bg-accent/10" : "border-line hover:border-line-strong hover:bg-surface" | |
| 195 | − | }`} | |
| 196 | − | > | |
| 197 | − | <span className="flex items-center gap-2 text-sm font-medium text-fg"> | |
| 198 | − | <span className={`size-3.5 rounded-full border ${current === choice.value ? "border-4 border-accent" : "border-line-strong"}`} aria-hidden /> | |
| 199 | − | {choice.label} | |
| 200 | − | </span> | |
| 201 | − | <span className="mt-0.5 block pl-5.5 text-xs text-muted">{choice.detail}</span> | |
| 202 | − | </button> | |
| 203 | − | ))} | |
| 176 | + | <section aria-labelledby="emoji-upload" className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line p-4"> | |
| 177 | + | <div> | |
| 178 | + | <h2 id="emoji-upload" className="text-sm font-medium text-fg"> | |
| 179 | + | Who can add emoji: {value === "admins" ? "owners only" : "any member"} | |
| 180 | + | </h2> | |
| 181 | + | <p className="mt-1 text-xs text-muted">Whoever added an emoji, and owners, can remove it. Who can add them is one of the workspace's chat settings.</p> | |
| 204 | 182 | </div> | |
| 205 | − | {fetcher.data && !fetcher.data.ok && <ErrorText>{fetcher.data.error}</ErrorText>} | |
| 183 | + | <Link | |
| 184 | + | to={`/${slug}/-/settings/chat`} | |
| 185 | + | className="inline-flex h-8 items-center rounded-md border border-line px-3 text-[0.8125rem] font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-surface" | |
| 186 | + | > | |
| 187 | + | Chat settings | |
| 188 | + | </Link> | |
| 206 | 189 | </section> | |
| 207 | 190 | ); | |
| 208 | 191 | } | |
| ⋯ | |||
| 403 | 386 | } | |
| 404 | 387 | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 405 | 388 | }, [fetcher.state, fetcher.data]); | |
| 406 | − | const picked = originals.find((e) => e.name === target); | |
| 407 | 389 | return ( | |
| 408 | 390 | <Dialog open={open} onOpenChange={onOpenChange}> | |
| 409 | 391 | <DialogContent className="max-w-md"> | |
| ⋯ | |||
| 411 | 393 | <DialogTitle>Add an alias</DialogTitle> | |
| 412 | 394 | <DialogDescription>Another name for an emoji: both show the same image. Removing the emoji removes its aliases.</DialogDescription> | |
| 413 | 395 | </DialogHeader> | |
| 414 | − | <label className="block"> | |
| 415 | − | <span className="text-sm font-medium text-fg">Emoji</span> | |
| 416 | − | <span className="mt-1.5 flex items-center gap-2 rounded-md border border-line-strong bg-bg px-2.5"> | |
| 417 | − | {picked && <CustomEmojiImage name={picked.name} file={picked.file} usercontent={usercontent} size={20} />} | |
| 418 | − | <select value={target} onChange={(event) => setTarget(event.target.value)} className="min-w-0 grow bg-transparent py-1.5 font-mono text-sm text-fg outline-none"> | |
| 419 | − | {originals.map((e) => ( | |
| 420 | − | <option key={e.name} value={e.name}> | |
| 421 | − | :{e.name}: | |
| 422 | − | </option> | |
| 423 | − | ))} | |
| 424 | − | </select> | |
| 396 | + | <div className="block"> | |
| 397 | + | <span id="alias-target-label" className="text-sm font-medium text-fg"> | |
| 398 | + | Emoji | |
| 425 | 399 | </span> | |
| 426 | − | </label> | |
| 400 | + | <SelectField | |
| 401 | + | aria-labelledby="alias-target-label" | |
| 402 | + | value={target} | |
| 403 | + | onValueChange={setTarget} | |
| 404 | + | className="mt-1.5 border-line-strong font-mono" | |
| 405 | + | itemClassName="font-mono" | |
| 406 | + | options={originals.map((e) => ({ | |
| 407 | + | value: e.name, | |
| 408 | + | label: `:${e.name}:`, | |
| 409 | + | icon: <CustomEmojiImage name={e.name} file={e.file} usercontent={usercontent} size={18} />, | |
| 410 | + | }))} | |
| 411 | + | /> | |
| 412 | + | </div> | |
| 427 | 413 | <label className="block"> | |
| 428 | 414 | <span className="text-sm font-medium text-fg">Alias</span> | |
| 429 | 415 | <input | |
| 7 | 7 | import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 8 | 8 | import { CheckboxOption } from "./ui/checkbox"; | |
| 9 | 9 | import { Hint } from "./ui/hint"; | |
| 10 | + | import { SelectField } from "./ui/select"; | |
| 10 | 11 | import { G1T_INVITES, inviteFor, inviteKind, inviteLink, invitesPage, inviteState, moreInvitesMailto, remainingLine } from "../lib/invites"; | |
| 11 | 12 | import type { BringIntoChoices } from "../lib/invites.server"; | |
| 12 | 13 | ||
| 13 | − | const SELECT = | |
| 14 | − | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim sm:w-auto"; | |
| 14 | + | const SELECT = "h-auto w-full py-2 sm:w-auto sm:min-w-40"; | |
| 15 | 15 | ||
| 16 | 16 | const TONE: Record<"pending" | "done" | "dead", string> = { | |
| 17 | 17 | pending: "border-accent/40 text-accent", | |
| ⋯ | |||
| 99 | 99 | {workspaces.length > 0 && ( | |
| 100 | 100 | <label className="block"> | |
| 101 | 101 | <span className="mb-1.5 block text-sm font-medium text-muted">Use</span> | |
| 102 | − | <select name="charge" className={SELECT}> | |
| 103 | − | <option value="mine">Your invites</option> | |
| 104 | − | {workspaces | |
| 105 | − | .filter((workspace) => workspace.allowance.remaining !== 0) | |
| 106 | − | .map((workspace) => ( | |
| 107 | − | <option key={workspace.slug} value={workspace.slug}> | |
| 108 | − | {workspace.slug}'s invites | |
| 109 | − | </option> | |
| 110 | − | ))} | |
| 111 | − | </select> | |
| 102 | + | <SelectField | |
| 103 | + | name="charge" | |
| 104 | + | defaultValue="mine" | |
| 105 | + | className={SELECT} | |
| 106 | + | options={[ | |
| 107 | + | { value: "mine", label: "Your invites" }, | |
| 108 | + | ...workspaces | |
| 109 | + | .filter((workspace) => workspace.allowance.remaining !== 0) | |
| 110 | + | .map((workspace) => ({ value: workspace.slug, label: `${workspace.slug}'s invites` })), | |
| 111 | + | ]} | |
| 112 | + | /> | |
| 112 | 113 | </label> | |
| 113 | 114 | )} | |
| 114 | 115 | <SubmitButton match={{ intent: "create-invite" }} pending="Creating…"> | |
| ⋯ | |||
| 130 | 131 | <div className="flex flex-col gap-3 pl-6 sm:flex-row sm:items-end"> | |
| 131 | 132 | <label className="block"> | |
| 132 | 133 | <span className="mb-1.5 block text-sm font-medium text-muted">Workspace</span> | |
| 133 | − | <select name="join" required defaultValue="" className={SELECT}> | |
| 134 | − | <option value="" disabled> | |
| 135 | − | Choose a workspace | |
| 136 | − | </option> | |
| 137 | − | {bringInto.options.map((workspace) => ( | |
| 138 | − | <option key={workspace.slug} value={workspace.slug}> | |
| 139 | − | {workspace.name === workspace.slug ? workspace.slug : `${workspace.name} (${workspace.slug})`} | |
| 140 | − | </option> | |
| 141 | − | ))} | |
| 142 | − | </select> | |
| 134 | + | <SelectField | |
| 135 | + | name="join" | |
| 136 | + | required | |
| 137 | + | placeholder="Choose a workspace" | |
| 138 | + | className={SELECT} | |
| 139 | + | options={bringInto.options.map((workspace) => ({ | |
| 140 | + | value: workspace.slug, | |
| 141 | + | label: workspace.name === workspace.slug ? workspace.slug : `${workspace.name} (${workspace.slug})`, | |
| 142 | + | }))} | |
| 143 | + | /> | |
| 143 | 144 | </label> | |
| 144 | 145 | <label className="block"> | |
| 145 | 146 | <span className="mb-1.5 block text-sm font-medium text-muted">Role</span> | |
| 146 | − | <select name="join_role" defaultValue="member" className={SELECT}> | |
| 147 | − | <option value="member">Member</option> | |
| 148 | − | <option value="owner">Owner</option> | |
| 149 | − | </select> | |
| 147 | + | <SelectField | |
| 148 | + | name="join_role" | |
| 149 | + | defaultValue="member" | |
| 150 | + | className={SELECT} | |
| 151 | + | options={[ | |
| 152 | + | { value: "member", label: "Member" }, | |
| 153 | + | { value: "owner", label: "Owner" }, | |
| 154 | + | ]} | |
| 155 | + | /> | |
| 150 | 156 | </label> | |
| 151 | 157 | </div> | |
| 152 | 158 | )} | |
| 52 | 52 | ); | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | + | /** A mode's name under its icon: centred on the rail, whatever its length. */ | |
| 55 | 56 | function RailLabel({ current, children }: { current: boolean; children: ReactNode }) { | |
| 56 | 57 | return ( | |
| 57 | − | <span className={`text-[0.6875rem] leading-none font-medium transition-colors ${current ? "text-fg" : "text-faint group-hover:text-muted"}`}> | |
| 58 | + | <span | |
| 59 | + | className={`block w-full truncate text-center text-[0.6875rem] leading-none font-medium transition-colors ${current ? "text-fg" : "text-faint group-hover:text-muted"}`} | |
| 60 | + | > | |
| 58 | 61 | {children} | |
| 59 | 62 | </span> | |
| 60 | 63 | ); | |
| 61 | 64 | } | |
| 62 | 65 | ||
| 63 | − | const RAIL_ITEM = "group flex w-full flex-col items-center gap-1 rounded-lg py-1 outline-none focus-visible:ring-2 focus-visible:ring-accent"; | |
| 66 | + | // Each item fills the rail's width and centres its icon and name on it. | |
| 67 | + | const RAIL_ITEM = | |
| 68 | + | "group flex w-full flex-col items-center justify-center gap-1 rounded-lg py-1 text-center outline-none focus-visible:ring-2 focus-visible:ring-accent"; | |
| 64 | 69 | ||
| 65 | 70 | function RailButton({ mode, to, current }: { mode: Mode; to: string; current: boolean }) { | |
| 66 | 71 | return ( | |
| ⋯ | |||
| 94 | 99 | <Hint label={displayName(workspace)} side="right"> | |
| 95 | 100 | <DropdownMenuTrigger | |
| 96 | 101 | aria-label={`${displayName(workspace)}: switch workspace`} | |
| 97 | − | className="rounded-[11px] outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:ring-2 data-[state=open]:ring-line-strong" | |
| 102 | + | className="flex rounded-[10px] outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:ring-2 data-[state=open]:ring-line-strong" | |
| 98 | 103 | > | |
| 99 | − | <Avatar name={workspace.slug} image={workspace.avatar} size={40} square /> | |
| 104 | + | <Avatar name={workspace.slug} image={workspace.avatar} size={36} square /> | |
| 100 | 105 | </DropdownMenuTrigger> | |
| 101 | 106 | </Hint> | |
| 102 | 107 | <DropdownMenuContent side="right" align="start" className="w-72 p-1.5"> | |
| ⋯ | |||
| 265 | 270 | ]; | |
| 266 | 271 | const workspaceMode: Mode = { key: "workspace", label: "Workspace", icon: <Building2 size={19} /> }; | |
| 267 | 272 | return ( | |
| 273 | + | // The right-hand rule is an inset shadow, not a border, so the rail's | |
| 274 | + | // whole width is its content box and every item centres on it exactly. | |
| 268 | 275 | <nav | |
| 269 | 276 | aria-label="Modes" | |
| 270 | 277 | style={{ width: RAIL_WIDTH, ["--rail-bg" as string]: "#0b0b0d" }} | |
| 271 | − | className="flex h-full shrink-0 flex-col items-center overflow-y-auto border-r border-line bg-[var(--rail-bg)] pt-3 pb-3 [scrollbar-width:none]" | |
| 278 | + | className="flex h-full shrink-0 flex-col items-center overflow-y-auto bg-[var(--rail-bg)] pb-3 shadow-[inset_-1px_0_0_var(--color-line)] [scrollbar-width:none]" | |
| 272 | 279 | > | |
| 273 | − | <WorkspaceButton user={user} workspace={workspace} /> | |
| 274 | − | <div className="mt-4 flex w-full flex-col items-center gap-2 px-2"> | |
| 280 | + | {/* The top bar's height and rule, so the workspace sits on the same line as the sidebar's heading and the page's top bar. */} | |
| 281 | + | <div className="flex h-14 w-full shrink-0 items-center justify-center border-b border-line"> | |
| 282 | + | <WorkspaceButton user={user} workspace={workspace} /> | |
| 283 | + | </div> | |
| 284 | + | <div className="mt-3 flex w-full flex-col items-center gap-2 px-1.5"> | |
| 275 | 285 | {modes.map((mode) => ( | |
| 276 | 286 | <RailButton key={mode.key} mode={mode} to={modeHome(mode.key, workspace.slug)} current={here === mode.key} /> | |
| 277 | 287 | ))} | |
| 278 | 288 | </div> | |
| 279 | − | <div className="mt-auto flex w-full flex-col items-center gap-2 px-2 pt-4"> | |
| 289 | + | <div className="mt-auto flex w-full flex-col items-center gap-2 px-1.5 pt-4"> | |
| 280 | 290 | <RailButton mode={workspaceMode} to={modeHome("workspace", workspace.slug)} current={here === "workspace"} /> | |
| 281 | 291 | {help} | |
| 282 | 292 | <div className="mt-1.5">{account}</div> | |
| 6 | 6 | ||
| 7 | 7 | import type { RunnersAction, RunnersData } from "../lib/runners.server"; | |
| 8 | 8 | import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui"; | |
| 9 | + | import { SelectField } from "./ui/select"; | |
| 9 | 10 | import { Switch } from "./ui/switch"; | |
| 10 | 11 | import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; | |
| 11 | 12 | ||
| ⋯ | |||
| 152 | 153 | <input type="hidden" name="intent" value="token" /> | |
| 153 | 154 | {!repoScoped && groups.length > 1 && ( | |
| 154 | 155 | <Field label="Group"> | |
| 155 | − | <select name="group" className="h-9 rounded-md border border-line bg-bg px-2.5 text-sm" defaultValue={groups.find((g) => g.default)?.name}> | |
| 156 | − | {groups.map((group) => ( | |
| 157 | − | <option key={group.id} value={group.name}> | |
| 158 | − | {group.name} | |
| 159 | − | </option> | |
| 160 | − | ))} | |
| 161 | − | </select> | |
| 156 | + | <SelectField | |
| 157 | + | name="group" | |
| 158 | + | defaultValue={groups.find((g) => g.default)?.name ?? groups[0]?.name} | |
| 159 | + | className="w-auto min-w-40" | |
| 160 | + | options={groups.map((group) => ({ value: group.name, label: group.name }))} | |
| 161 | + | /> | |
| 162 | 162 | </Field> | |
| 163 | 163 | )} | |
| 164 | 164 | <SubmitButton match={{ intent: "token" }} pending="Making a token…"> | |
| 1609 | 1609 | <BackRow to={`/${slug}/-/workspace`} label="Settings" context={slug} /> | |
| 1610 | 1610 | <div className="mt-2 space-y-px"> | |
| 1611 | 1611 | {owner && ( | |
| 1612 | − | <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}> | |
| 1612 | + | <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />} end> | |
| 1613 | 1613 | General | |
| 1614 | 1614 | </SidebarLink> | |
| 1615 | 1615 | )} | |
| 1616 | + | <SidebarLink to={`/${slug}/-/settings/chat`} icon={<MessagesSquare size={15} />}> | |
| 1617 | + | Chat | |
| 1618 | + | </SidebarLink> | |
| 1616 | 1619 | <SidebarLink to={`/${slug}/-/repositories`} icon={<BookMarked size={15} />}> | |
| 1617 | 1620 | Repositories | |
| 1618 | 1621 | </SidebarLink> | |
| ⋯ | |||
| 1764 | 1767 | return <span className="text-sm font-medium">{words[parts[0]!]}</span>; | |
| 1765 | 1768 | } | |
| 1766 | 1769 | // A person's profile, by their handle. | |
| 1767 | − | if (parts[0] === "u" && parts[1]) { | |
| 1768 | − | return <span className="truncate font-mono text-[0.8125rem] font-medium">@{parts[1]}</span>; | |
| 1769 | − | } | |
| 1770 | + | if (parts[0] === "u" && parts[1]) return <ProfileCrumb username={parts[1]} />; | |
| 1770 | 1771 | const [owner, second, third, fourth] = parts; | |
| 1771 | 1772 | const trail: Crumb[] = [{ label: owner!, to: `/${owner}`, mono: true }]; | |
| 1772 | 1773 | if (second === "-") { | |
| ⋯ | |||
| 1792 | 1793 | return <Trail trail={trail} />; | |
| 1793 | 1794 | } | |
| 1794 | 1795 | ||
| 1796 | + | /** A profile's place in the top bar: the person's avatar and @handle, from the page once it has loaded. */ | |
| 1797 | + | function ProfileCrumb({ username }: { username: string }) { | |
| 1798 | + | const page = useRouteLoaderData("routes/user") as { profile?: { username: string; avatar: string | null } } | undefined; | |
| 1799 | + | const profile = page?.profile?.username.toLowerCase() === username.toLowerCase() ? page.profile : null; | |
| 1800 | + | return ( | |
| 1801 | + | <Link to={`/u/${profile?.username ?? username}`} aria-current="page" className="flex min-w-0 items-center gap-2 rounded px-1 py-0.5 transition-colors hover:bg-raised"> | |
| 1802 | + | <Avatar name={profile?.username ?? username} image={profile?.avatar ?? null} size={20} /> | |
| 1803 | + | <span className="truncate font-mono text-[0.8125rem] font-medium">@{profile?.username ?? username}</span> | |
| 1804 | + | </Link> | |
| 1805 | + | ); | |
| 1806 | + | } | |
| 1807 | + | ||
| 1795 | 1808 | /** One link of the trail; `short` is what a phone shows when it is the page itself. */ | |
| 1796 | 1809 | type Crumb = { label: string; to: string; mono?: boolean; short?: string }; | |
| 1797 | 1810 | ||
| ⋯ | |||
| 2032 | 2045 | const SIDEBAR_BOX = "h-full border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))]"; | |
| 2033 | 2046 | ||
| 2034 | 2047 | /** | |
| 2035 | − | * Which sidebar sits beside the rail: each mode's own, or none (Docs, and | |
| 2036 | − | * the Inbox, which is a page of its own). Without a workspace (a visitor), | |
| 2037 | − | * the one sidebar there always was. | |
| 2048 | + | * Which sidebar sits beside the rail: each mode's own, or none (Docs, the | |
| 2049 | + | * Inbox, which is a page of its own, and g1t's public pages, such as a | |
| 2050 | + | * profile, which are no workspace's and carry their own left column). | |
| 2051 | + | * Without a workspace (a visitor), the one sidebar there always was. | |
| 2038 | 2052 | */ | |
| 2039 | 2053 | function sidebarFor(mode: ModeKey | null): Panel | null { | |
| 2040 | 2054 | if (mode == null) return "code"; | |
| 2041 | − | if (mode === "docs" || mode === "inbox") return null; | |
| 2055 | + | if (mode === "docs" || mode === "inbox" || mode === "site") return null; | |
| 2042 | 2056 | return mode; | |
| 2043 | 2057 | } | |
| 2044 | 2058 | ||
| 1 | 1 | import { Check, ChevronDown, ChevronUp } from "lucide-react"; | |
| 2 | 2 | import { Select as Primitive } from "radix-ui"; | |
| 3 | − | import type { ComponentProps, ReactNode } from "react"; | |
| 3 | + | import { type ComponentProps, type ReactNode, useState } from "react"; | |
| 4 | 4 | ||
| 5 | 5 | import { cn } from "../../lib/cn"; | |
| 6 | 6 | ||
| ⋯ | |||
| 157 | 157 | </Primitive.ScrollDownButton> | |
| 158 | 158 | ); | |
| 159 | 159 | } | |
| 160 | + | ||
| 161 | + | export type SelectOption = { value: string; label: ReactNode; disabled?: boolean; description?: ReactNode; icon?: ReactNode }; | |
| 162 | + | ||
| 163 | + | /** Radix items cannot have "" as a value; this stands in for it inside. */ | |
| 164 | + | const EMPTY = "__g1t_empty__"; | |
| 165 | + | const inside = (value: string | undefined) => (value === "" ? EMPTY : value); | |
| 166 | + | const outside = (value: string) => (value === EMPTY ? "" : value); | |
| 167 | + | ||
| 168 | + | /** | |
| 169 | + | * A select for what used to be a native <select>: a list of options, posted | |
| 170 | + | * by `name` with its form. Options may have "" as their value (Any, None), | |
| 171 | + | * which posts "" as a native select would. `onValueChange` sees the new | |
| 172 | + | * value at once; `afterChange` runs once the form's fields hold it, so it | |
| 173 | + | * can submit the form (filters that apply as they change). | |
| 174 | + | */ | |
| 175 | + | export function SelectField({ | |
| 176 | + | options, | |
| 177 | + | name, | |
| 178 | + | form, | |
| 179 | + | value, | |
| 180 | + | defaultValue, | |
| 181 | + | onValueChange, | |
| 182 | + | afterChange, | |
| 183 | + | placeholder, | |
| 184 | + | required, | |
| 185 | + | disabled, | |
| 186 | + | size, | |
| 187 | + | id, | |
| 188 | + | className, | |
| 189 | + | contentClassName, | |
| 190 | + | itemClassName, | |
| 191 | + | align, | |
| 192 | + | "aria-label": ariaLabel, | |
| 193 | + | "aria-labelledby": ariaLabelledBy, | |
| 194 | + | }: { | |
| 195 | + | options: SelectOption[]; | |
| 196 | + | name?: string; | |
| 197 | + | form?: string; | |
| 198 | + | value?: string; | |
| 199 | + | defaultValue?: string; | |
| 200 | + | onValueChange?: (value: string) => void; | |
| 201 | + | afterChange?: (value: string) => void; | |
| 202 | + | placeholder?: ReactNode; | |
| 203 | + | required?: boolean; | |
| 204 | + | disabled?: boolean; | |
| 205 | + | size?: "sm" | "default" | "lg"; | |
| 206 | + | id?: string; | |
| 207 | + | className?: string; | |
| 208 | + | contentClassName?: string; | |
| 209 | + | itemClassName?: string; | |
| 210 | + | align?: "start" | "center" | "end"; | |
| 211 | + | "aria-label"?: string; | |
| 212 | + | "aria-labelledby"?: string; | |
| 213 | + | }) { | |
| 214 | + | const [own, setOwn] = useState(defaultValue); | |
| 215 | + | const current = value !== undefined ? value : own; | |
| 216 | + | // With a "" option, a hidden input posts the value, so "" posts as "". | |
| 217 | + | const empty = options.some((option) => option.value === ""); | |
| 218 | + | return ( | |
| 219 | + | <> | |
| 220 | + | <Select | |
| 221 | + | value={(empty ? inside(current) : current) ?? ""} | |
| 222 | + | onValueChange={(next) => { | |
| 223 | + | const chosen = outside(next); | |
| 224 | + | setOwn(chosen); | |
| 225 | + | onValueChange?.(chosen); | |
| 226 | + | if (afterChange) setTimeout(() => afterChange(chosen), 0); | |
| 227 | + | }} | |
| 228 | + | name={empty ? undefined : name} | |
| 229 | + | form={empty ? undefined : form} | |
| 230 | + | required={required} | |
| 231 | + | disabled={disabled} | |
| 232 | + | > | |
| 233 | + | <SelectTrigger id={id} size={size} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} className={className}> | |
| 234 | + | <SelectValue placeholder={placeholder} /> | |
| 235 | + | </SelectTrigger> | |
| 236 | + | <SelectContent className={contentClassName} align={align}> | |
| 237 | + | {options.map((option) => ( | |
| 238 | + | <SelectItem | |
| 239 | + | key={option.value} | |
| 240 | + | value={inside(option.value)!} | |
| 241 | + | disabled={option.disabled} | |
| 242 | + | description={option.description} | |
| 243 | + | icon={option.icon} | |
| 244 | + | className={itemClassName} | |
| 245 | + | > | |
| 246 | + | {option.label} | |
| 247 | + | </SelectItem> | |
| 248 | + | ))} | |
| 249 | + | </SelectContent> | |
| 250 | + | </Select> | |
| 251 | + | {empty && name && <input type="hidden" name={name} form={form} value={current ?? ""} disabled={disabled} />} | |
| 252 | + | </> | |
| 253 | + | ); | |
| 254 | + | } | |
| 32 | 32 | import { cn } from "../lib/cn"; | |
| 33 | 33 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 34 | 34 | import { Hint } from "./ui/hint"; | |
| 35 | + | import { SelectField } from "./ui/select"; | |
| 35 | 36 | import { Skeleton } from "./ui/skeleton"; | |
| 36 | 37 | ||
| 37 | 38 | const SELECT = | |
| ⋯ | |||
| 117 | 118 | }; | |
| 118 | 119 | return ( | |
| 119 | 120 | <Form ref={form} method="get" preventScrollReset className="flex flex-wrap items-center gap-2"> | |
| 120 | − | <select name="period" defaultValue={filters.period} onChange={submit} className={SELECT} aria-label="Period"> | |
| 121 | − | {(Object.keys(PERIODS) as Period[]).map((key) => ( | |
| 122 | − | <option key={key} value={key}> | |
| 123 | − | {PERIODS[key]} | |
| 124 | − | </option> | |
| 125 | − | ))} | |
| 126 | − | </select> | |
| 121 | + | <SelectField | |
| 122 | + | key={`period-${filters.period}`} | |
| 123 | + | name="period" | |
| 124 | + | defaultValue={filters.period} | |
| 125 | + | afterChange={submit} | |
| 126 | + | size="sm" | |
| 127 | + | className="w-auto" | |
| 128 | + | aria-label="Period" | |
| 129 | + | options={(Object.keys(PERIODS) as Period[]).map((key) => ({ value: key, label: PERIODS[key] }))} | |
| 130 | + | /> | |
| 127 | 131 | {filters.period === "custom" ? ( | |
| 128 | 132 | <span className="flex items-center gap-1 text-sm"> | |
| 129 | 133 | <input type="date" name="from" defaultValue={filters.from} aria-label="From" className={SELECT} /> | |
| ⋯ | |||
| 135 | 139 | )} | |
| 136 | 140 | <MultiSelect name="product" label="Products" options={products.map((p) => ({ value: p.key, label: p.label }))} chosen={filters.products} /> | |
| 137 | 141 | {!fixedProject && <MultiSelect name="project" label="Projects" options={projects.map((p) => ({ value: p, label: p }))} chosen={filters.projects} />} | |
| 138 | − | <select name="group" defaultValue={filters.group} onChange={submit} className={SELECT} aria-label="Group by"> | |
| 139 | − | <option value="product">Group by product</option> | |
| 140 | − | {!fixedProject && <option value="project">Group by project</option>} | |
| 141 | − | <option value="day">Group by day</option> | |
| 142 | − | </select> | |
| 142 | + | <SelectField | |
| 143 | + | key={`group-${filters.group}`} | |
| 144 | + | name="group" | |
| 145 | + | defaultValue={filters.group} | |
| 146 | + | afterChange={submit} | |
| 147 | + | size="sm" | |
| 148 | + | className="w-auto" | |
| 149 | + | aria-label="Group by" | |
| 150 | + | options={[ | |
| 151 | + | { value: "product", label: "Group by product" }, | |
| 152 | + | ...(fixedProject ? [] : [{ value: "project", label: "Group by project" }]), | |
| 153 | + | { value: "day", label: "Group by day" }, | |
| 154 | + | ]} | |
| 155 | + | /> | |
| 143 | 156 | <input type="hidden" name="grain" value={filters.grain} /> | |
| 144 | 157 | {filters.cumulative && <input type="hidden" name="cumulative" value="1" />} | |
| 145 | 158 | <noscript> | |
| 14 | 14 | "/acme/web", | |
| 15 | 15 | "/acme/web/code", | |
| 16 | 16 | "/acme/web/issues/4", | |
| 17 | − | "/explore", | |
| 18 | − | "/search", | |
| 19 | − | "/u/ada", | |
| 20 | 17 | "/acme", | |
| 21 | 18 | "/nothing/here/at/all", | |
| 22 | 19 | ]) { | |
| ⋯ | |||
| 30 | 27 | } | |
| 31 | 28 | }); | |
| 32 | 29 | ||
| 30 | + | test("a visitor reads profiles, Explore and Search in the public frame, with no sidebar", () => { | |
| 31 | + | for (const path of ["/u/ada", "/u/ada/", "/explore", "/explore/", "/search"]) { | |
| 32 | + | assert.equal(usesAppShell(path, false), false, path); | |
| 33 | + | } | |
| 34 | + | // A workspace named like a page is still a workspace. | |
| 35 | + | assert.equal(usesAppShell("/u", false), true); | |
| 36 | + | assert.equal(usesAppShell("/explorers", false), true); | |
| 37 | + | }); | |
| 38 | + | ||
| 33 | 39 | test("a visitor reads the policies, security, support and status in the marketing frame", () => { | |
| 34 | 40 | for (const path of ["/policies", "/policies/terms", "/policies/privacy/", "/security", "/support", "/status"]) { | |
| 35 | 41 | assert.equal(usesAppShell(path, false), false, path); | |
| 2 | 2 | * Which frame a page is drawn in, and what the sidebar offers someone who | |
| 3 | 3 | * is not signed in. | |
| 4 | 4 | * | |
| 5 | − | * g1t is a sidebar site: projects, Explore, Search, profiles and | |
| 6 | − | * not-found pages are drawn in the app's sidebar for everyone, so a | |
| 7 | − | * visitor browsing public projects finds their way the same as a member. | |
| 8 | − | * Only the front page and the pages about signing in or paying keep the | |
| 9 | − | * marketing header and footer. | |
| 5 | + | * Someone signed in always gets the app's frame: the rail and its modes. | |
| 6 | + | * A visitor gets it on projects, workspaces and not-found pages, where the | |
| 7 | + | * sidebar is the project's own menu. g1t's public pages that belong to no | |
| 8 | + | * workspace (a person's profile, Explore, Search) are drawn for a visitor | |
| 9 | + | * in the public frame instead: the top bar with the mark, search and | |
| 10 | + | * signing in, the page at full width, and the footer. So do the front page | |
| 11 | + | * and the pages about signing in, paying and trust. | |
| 10 | 12 | */ | |
| 11 | 13 | ||
| 12 | 14 | import type { Abilities, Capability } from "@g1t/contracts"; | |
| ⋯ | |||
| 35 | 37 | "/workspaces/new", | |
| 36 | 38 | ]); | |
| 37 | 39 | ||
| 40 | + | /** g1t's public pages that are no workspace's: a visitor reads them in the public frame. */ | |
| 41 | + | const PUBLIC_PAGES = new Set(["/explore", "/search"]); | |
| 42 | + | ||
| 38 | 43 | /** Whether the page is drawn in the app's sidebar frame. */ | |
| 39 | 44 | export function usesAppShell(pathname: string, signedIn: boolean): boolean { | |
| 40 | 45 | if (signedIn) return true; | |
| 41 | 46 | const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname; | |
| 42 | − | if (MARKETING.has(path)) return false; | |
| 47 | + | if (MARKETING.has(path) || PUBLIC_PAGES.has(path)) return false; | |
| 48 | + | // A person's profile: theirs, not any workspace's. | |
| 49 | + | if (/^\/u\/[^/]+$/.test(path)) return false; | |
| 43 | 50 | if (path === "/oauth" || path.startsWith("/oauth/")) return false; | |
| 44 | 51 | // An invite link is the front door: the marketing frame, like /register. | |
| 45 | 52 | if (path.startsWith("/invite/")) return false; | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { CALENDAR_WEEKS, buildCalendar, dayLabel, lastDay, levelOf, totalLabel } from "./contribution-calendar.ts"; | |
| 5 | + | ||
| 6 | + | test("the calendar is 53 weeks of seven days, ending with today's week", () => { | |
| 7 | + | // 2026-10-08 is a Thursday. | |
| 8 | + | const calendar = buildCalendar([], "2026-10-08", "2025-10-09"); | |
| 9 | + | assert.equal(calendar.weeks.length, CALENDAR_WEEKS); | |
| 10 | + | for (const week of calendar.weeks) assert.equal(week.length, 7); | |
| 11 | + | const last = calendar.weeks.at(-1)!; | |
| 12 | + | assert.equal(last[4]?.date, "2026-10-08"); | |
| 13 | + | // Friday and Saturday are still to come. | |
| 14 | + | assert.equal(last[5], null); | |
| 15 | + | assert.equal(last[6], null); | |
| 16 | + | // Sunday at the top of every column. | |
| 17 | + | assert.equal(new Date(`${last[0]!.date}T00:00:00Z`).getUTCDay(), 0); | |
| 18 | + | // Days before the year starts are blank; the year has 365 days in it. | |
| 19 | + | const shown = calendar.weeks.flat().filter((day) => day != null); | |
| 20 | + | assert.equal(shown.length, 365); | |
| 21 | + | assert.equal(shown[0]!.date, "2025-10-09"); | |
| 22 | + | assert.equal(calendar.max, 0); | |
| 23 | + | assert.ok(shown.every((day) => day.count === 0 && day.level === 0)); | |
| 24 | + | }); | |
| 25 | + | ||
| 26 | + | test("each day is shaded in quarters of the busiest", () => { | |
| 27 | + | assert.equal(levelOf(0, 10), 0); | |
| 28 | + | assert.equal(levelOf(1, 10), 1); | |
| 29 | + | assert.equal(levelOf(3, 10), 2); | |
| 30 | + | assert.equal(levelOf(6, 10), 3); | |
| 31 | + | assert.equal(levelOf(10, 10), 4); | |
| 32 | + | assert.equal(levelOf(5, 0), 0); | |
| 33 | + | const calendar = buildCalendar( | |
| 34 | + | [ | |
| 35 | + | { date: "2026-10-01", count: 8 }, | |
| 36 | + | { date: "2026-10-02", count: 1 }, | |
| 37 | + | // Outside the year: neither shown nor the busiest. | |
| 38 | + | { date: "2024-01-01", count: 99 }, | |
| 39 | + | ], | |
| 40 | + | "2026-10-08", | |
| 41 | + | "2025-10-09", | |
| 42 | + | ); | |
| 43 | + | assert.equal(calendar.max, 8); | |
| 44 | + | const days = calendar.weeks.flat(); | |
| 45 | + | assert.deepEqual(days.find((day) => day?.date === "2026-10-01"), { date: "2026-10-01", count: 8, level: 4 }); | |
| 46 | + | assert.equal(days.find((day) => day?.date === "2026-10-02")?.level, 1); | |
| 47 | + | }); | |
| 48 | + | ||
| 49 | + | test("months are named over the week they start in", () => { | |
| 50 | + | const { months, weeks } = buildCalendar([], "2026-10-08", "2025-10-09"); | |
| 51 | + | // The year starts partway through October, which still gets its name: | |
| 52 | + | // November is three weeks on, room enough for both. | |
| 53 | + | assert.deepEqual(months[0], { label: "Oct", week: 0 }); | |
| 54 | + | assert.deepEqual( | |
| 55 | + | months.map((month) => month.label), | |
| 56 | + | ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct"], | |
| 57 | + | ); | |
| 58 | + | for (const { label, week } of months.slice(1)) { | |
| 59 | + | assert.ok(weeks[week]!.some((day) => day?.date.endsWith("-01")), label); | |
| 60 | + | } | |
| 61 | + | // Increasing, and never two in one column. | |
| 62 | + | for (let i = 1; i < months.length; i++) assert.ok(months[i]!.week > months[i - 1]!.week); | |
| 63 | + | }); | |
| 64 | + | ||
| 65 | + | test("a month that starts too close to the first column takes its place", () => { | |
| 66 | + | // From Tue 2025-10-21: October's column is the one before November's, | |
| 67 | + | // too narrow for its name. | |
| 68 | + | const squeezed = buildCalendar([], "2026-10-20", "2025-10-21"); | |
| 69 | + | assert.deepEqual(squeezed.months[0], { label: "Nov", week: 1 }); | |
| 70 | + | }); | |
| 71 | + | ||
| 72 | + | test("a year that starts early in a month keeps its first label", () => { | |
| 73 | + | // From Jan 2: January has room before February. | |
| 74 | + | const { months } = buildCalendar([], "2027-01-01", "2026-01-02"); | |
| 75 | + | assert.equal(months[0]!.label, "Jan"); | |
| 76 | + | assert.equal(months[1]!.label, "Feb"); | |
| 77 | + | }); | |
| 78 | + | ||
| 79 | + | test("a bad date draws nothing", () => { | |
| 80 | + | assert.deepEqual(buildCalendar([], "not a date"), { weeks: [], months: [], max: 0 }); | |
| 81 | + | }); | |
| 82 | + | ||
| 83 | + | test("hints and the heading read as sentences", () => { | |
| 84 | + | assert.equal(dayLabel(3, "2026-10-04"), "3 contributions on Oct 4, 2026"); | |
| 85 | + | assert.equal(dayLabel(1, "2026-10-04"), "1 contribution on Oct 4, 2026"); | |
| 86 | + | assert.equal(dayLabel(0, "2026-01-01"), "No contributions on Jan 1, 2026"); | |
| 87 | + | assert.equal(totalLabel(0), "0 contributions in the last year"); | |
| 88 | + | assert.equal(totalLabel(1), "1 contribution in the last year"); | |
| 89 | + | assert.equal(totalLabel(1204), "1,204 contributions in the last year"); | |
| 90 | + | assert.equal(lastDay("2025-10-09"), "2026-10-08"); | |
| 91 | + | assert.equal(lastDay("nope"), null); | |
| 92 | + | }); |
| 1 | + | /** | |
| 2 | + | * The contribution calendar on a profile, laid out: the last year as | |
| 3 | + | * columns of weeks (Sunday at the top), each day shaded by how much was | |
| 4 | + | * done that day, with the months named above the week each one starts in. | |
| 5 | + | * Pure, so the page and its tests agree; every date is UTC, as the work | |
| 6 | + | * service counts them (services/work/src/contributions.rs). | |
| 7 | + | */ | |
| 8 | + | ||
| 9 | + | /** How many weeks the calendar shows: enough for a whole year and the current week. */ | |
| 10 | + | export const CALENDAR_WEEKS = 53; | |
| 11 | + | ||
| 12 | + | /** The shades a day can take: 0 is nothing, 4 the busiest. */ | |
| 13 | + | export type Level = 0 | 1 | 2 | 3 | 4; | |
| 14 | + | ||
| 15 | + | /** One square: a day of the year, or null outside it (before it starts, after today). */ | |
| 16 | + | export type CalendarDay = { date: string; count: number; level: Level } | null; | |
| 17 | + | ||
| 18 | + | export type Calendar = { | |
| 19 | + | /** `CALENDAR_WEEKS` columns of seven days, Sunday first. */ | |
| 20 | + | weeks: CalendarDay[][]; | |
| 21 | + | /** Each month's name, over the first column it starts in. */ | |
| 22 | + | months: { label: string; week: number }[]; | |
| 23 | + | /** The most on any one day. */ | |
| 24 | + | max: number; | |
| 25 | + | }; | |
| 26 | + | ||
| 27 | + | const DAY_MS = 86_400_000; | |
| 28 | + | const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; | |
| 29 | + | ||
| 30 | + | /** `YYYY-MM-DD` as milliseconds at its UTC midnight, or NaN. */ | |
| 31 | + | function parse(date: string): number { | |
| 32 | + | const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); | |
| 33 | + | return match ? Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) : Number.NaN; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | function format(ms: number): string { | |
| 37 | + | return new Date(ms).toISOString().slice(0, 10); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /** The shade for `count` on a calendar whose busiest day had `max`: quarters of it. */ | |
| 41 | + | export function levelOf(count: number, max: number): Level { | |
| 42 | + | if (count <= 0 || max <= 0) return 0; | |
| 43 | + | return Math.min(4, Math.max(1, Math.ceil((count / max) * 4))) as Level; | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /** | |
| 47 | + | * The calendar ending with the week of `today` (`YYYY-MM-DD`). Days before | |
| 48 | + | * `from`, when given, and after `today` are left blank; days with nothing | |
| 49 | + | * in `days` are zero. | |
| 50 | + | */ | |
| 51 | + | export function buildCalendar(days: readonly { date: string; count: number }[], today: string, from?: string): Calendar { | |
| 52 | + | const end = parse(today); | |
| 53 | + | if (Number.isNaN(end)) return { weeks: [], months: [], max: 0 }; | |
| 54 | + | const first = from && !Number.isNaN(parse(from)) ? parse(from) : end - 364 * DAY_MS; | |
| 55 | + | const counts = new Map<string, number>(); | |
| 56 | + | for (const day of days) counts.set(day.date, (counts.get(day.date) ?? 0) + day.count); | |
| 57 | + | let max = 0; | |
| 58 | + | for (const [date, count] of counts) { | |
| 59 | + | const at = parse(date); | |
| 60 | + | if (at >= first && at <= end) max = Math.max(max, count); | |
| 61 | + | } | |
| 62 | + | // The Sunday that starts the first column. | |
| 63 | + | const start = end - new Date(end).getUTCDay() * DAY_MS - (CALENDAR_WEEKS - 1) * 7 * DAY_MS; | |
| 64 | + | const weeks: CalendarDay[][] = []; | |
| 65 | + | const months: Calendar["months"] = []; | |
| 66 | + | for (let week = 0; week < CALENDAR_WEEKS; week++) { | |
| 67 | + | const column: CalendarDay[] = []; | |
| 68 | + | for (let weekday = 0; weekday < 7; weekday++) { | |
| 69 | + | const at = start + (week * 7 + weekday) * DAY_MS; | |
| 70 | + | if (at < first || at > end) { | |
| 71 | + | column.push(null); | |
| 72 | + | continue; | |
| 73 | + | } | |
| 74 | + | const date = format(at); | |
| 75 | + | const count = counts.get(date) ?? 0; | |
| 76 | + | column.push({ date, count, level: levelOf(count, max) }); | |
| 77 | + | } | |
| 78 | + | weeks.push(column); | |
| 79 | + | // A month is named over the first column holding its first day, or over | |
| 80 | + | // the first column at all when the year starts partway through it. | |
| 81 | + | const named = column.find((day) => day?.date.endsWith("-01")) ?? (months.length === 0 ? column.find((day) => day != null) : null); | |
| 82 | + | if (named) months.push({ label: MONTHS[Number(named.date.slice(5, 7)) - 1]!, week }); | |
| 83 | + | } | |
| 84 | + | // A label squeezed in before the next one (too few columns to fit) goes. | |
| 85 | + | if (months.length > 1 && months[1]!.week - months[0]!.week < 3) months.shift(); | |
| 86 | + | return { weeks, months, max }; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /** What a day's hint says: "3 contributions on Oct 4, 2026". */ | |
| 90 | + | export function dayLabel(count: number, date: string): string { | |
| 91 | + | const at = parse(date); | |
| 92 | + | const when = Number.isNaN(at) | |
| 93 | + | ? date | |
| 94 | + | : new Date(at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); | |
| 95 | + | if (count === 0) return `No contributions on ${when}`; | |
| 96 | + | return `${count.toLocaleString("en-US")} ${count === 1 ? "contribution" : "contributions"} on ${when}`; | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /** The heading: "1,204 contributions in the last year". */ | |
| 100 | + | export function totalLabel(total: number): string { | |
| 101 | + | return `${total.toLocaleString("en-US")} ${total === 1 ? "contribution" : "contributions"} in the last year`; | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /** The day `from` (`YYYY-MM-DD`) plus 364: the last day of a year counted from it. */ | |
| 105 | + | export function lastDay(from: string): string | null { | |
| 106 | + | const at = parse(from); | |
| 107 | + | return Number.isNaN(at) ? null : format(at + 364 * DAY_MS); | |
| 108 | + | } |
| 245 | 245 | assert.match(section, /name="also_join"/); | |
| 246 | 246 | // The workspace and role fields are drawn only once the box is ticked, so nothing else is sent. | |
| 247 | 247 | assert.match(section, /\{alsoJoin && \(\s*<div[^]*?name="join"[^]*?name="join_role"/); | |
| 248 | − | assert.match(section, /<option value="" disabled>\s*Choose a workspace/); | |
| 248 | + | // No workspace is chosen for them: the select starts on its placeholder, and is required. | |
| 249 | + | assert.match(section, /name="join"\s*required\s*placeholder="Choose a workspace"/); | |
| 249 | 250 | assert.doesNotMatch(section, /bringInto\.chosen|Bring them into/); | |
| 250 | 251 | }); | |
| 251 | 252 |
| 108 | 108 | */ | |
| 109 | 109 | const READS = new Set( | |
| 110 | 110 | ( | |
| 111 | − | "account active_agents all_ids get list queue pulls_for_repos blame blob branches by_author by_repo catalog check_invite check_limit " + | |
| 111 | + | "account active_agents all_ids get list queue pulls_for_repos blame blob branches by_author by_repo contributions catalog check_invite check_limit " + | |
| 112 | 112 | "check_workspace_deletion check_workspace_rename collaborator_permission compare counts deleted deliveries " + | |
| 113 | 113 | "dependencies domains entitlements entity explore features git_access graph has_feature invoices ledger limit " + | |
| 114 | 114 | "limit_requests links log logs managed_pulls memories_by_id memory_context my_repo_invitations " + |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync } from "node:fs"; | |
| 3 | + | import { test } from "node:test"; | |
| 4 | + | ||
| 5 | + | import { ROADMAP } from "./roadmap.ts"; | |
| 6 | + | ||
| 7 | + | test("code is not docs: a project never gets a Docs tab", () => { | |
| 8 | + | // Docs is its own workspace mode, filtered by project there. | |
| 9 | + | assert.equal(ROADMAP.some((item) => item.section !== "Workspace" && /docs/i.test(item.key)), false); | |
| 10 | + | const nav = readFileSync(new URL("./project-nav.ts", import.meta.url), "utf8"); | |
| 11 | + | assert.doesNotMatch(nav, /label: "Docs"|soon\("Code"\)/); | |
| 12 | + | }); |
| 43 | 43 | { label: "Tags", path: "tags" }, | |
| 44 | 44 | { label: "Releases", path: "releases" }, | |
| 45 | 45 | { label: "Compare", path: "compare" }, | |
| 46 | − | ...soon("Code"), | |
| 46 | + | // No Docs tab: code is not docs. Docs is a workspace mode, filtered by project there. | |
| 47 | 47 | ], | |
| 48 | 48 | }, | |
| 49 | 49 | { |
| 71 | 71 | ], | |
| 72 | 72 | }, | |
| 73 | 73 | // --- Code --------------------------------------------------------------- | |
| 74 | − | { | |
| 75 | − | key: "docs", | |
| 76 | − | title: "Docs", | |
| 77 | − | section: "Code", | |
| 78 | − | summary: "Pages about the project that agents keep current as the code changes.", | |
| 79 | − | why: "A wiki goes stale the day it is written. g1t's docs live with the code, and when a change makes a page wrong, an agent proposes the fix in the same pull request.", | |
| 80 | − | plans: [ | |
| 81 | − | "Pages written in Markdown, kept in the repository", | |
| 82 | − | "Agents update pages a change makes wrong, in the same pull request", | |
| 83 | − | "Architecture pages drawn from the code itself", | |
| 84 | − | "Search across every project's docs", | |
| 85 | − | ], | |
| 86 | − | today: { label: "Files", path: "code" }, | |
| 87 | − | }, | |
| 74 | + | // Nothing: code is not docs. Docs is its own workspace mode, filterable | |
| 75 | + | // by project there, and a project never gets a Docs tab. | |
| 88 | 76 | ||
| 89 | 77 | // --- Agents ------------------------------------------------------------- | |
| 90 | 78 | // At work, Sessions and Memory are built (see project-nav.ts), and so is |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { SESSION_STATES, sessionChip } from "./session-card.ts"; | |
| 5 | + | ||
| 6 | + | test("a session card's state reads as what is happening", () => { | |
| 7 | + | assert.deepEqual(sessionChip("Working"), { tone: "accent", live: true }); | |
| 8 | + | assert.deepEqual(sessionChip("Needs approval"), { tone: "warn", live: false }); | |
| 9 | + | assert.deepEqual(sessionChip("Done"), { tone: "success", live: false }); | |
| 10 | + | assert.deepEqual(sessionChip("Failed"), { tone: "danger", live: false }); | |
| 11 | + | assert.deepEqual(sessionChip("Stopped"), { tone: "neutral", live: false }); | |
| 12 | + | assert.deepEqual(sessionChip("Queued"), { tone: "neutral", live: false }); | |
| 13 | + | assert.equal(sessionChip("Waiting on helpers").live, true); | |
| 14 | + | }); | |
| 15 | + | ||
| 16 | + | test("every state has a chip, whatever its case; an unknown one is quiet", () => { | |
| 17 | + | for (const state of SESSION_STATES) assert.ok(sessionChip(state.toUpperCase())); | |
| 18 | + | assert.deepEqual(sessionChip("Exploding"), { tone: "neutral", live: false }); | |
| 19 | + | assert.deepEqual(sessionChip(null), { tone: "neutral", live: false }); | |
| 20 | + | }); |
| 1 | + | /** | |
| 2 | + | * An agent session's live card in chat (`card.kind === "session"`): the | |
| 3 | + | * agent posts it when the session starts and changes it in place as the | |
| 4 | + | * session moves (`message.updated`), so its state chip is always current. | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | /** The states an agent session's card shows. */ | |
| 8 | + | export const SESSION_STATES = ["Queued", "Working", "Waiting on helpers", "Needs approval", "Done", "Stopped", "Failed"] as const; | |
| 9 | + | ||
| 10 | + | export type SessionState = (typeof SESSION_STATES)[number]; | |
| 11 | + | ||
| 12 | + | /** How a state reads: its badge tone, and whether it is happening now (a soft pulse). */ | |
| 13 | + | export type SessionChip = { tone: "neutral" | "accent" | "info" | "warn" | "success" | "danger"; live: boolean }; | |
| 14 | + | ||
| 15 | + | const CHIPS: Record<string, SessionChip> = { | |
| 16 | + | queued: { tone: "neutral", live: false }, | |
| 17 | + | working: { tone: "accent", live: true }, | |
| 18 | + | "waiting on helpers": { tone: "info", live: true }, | |
| 19 | + | "needs approval": { tone: "warn", live: false }, | |
| 20 | + | done: { tone: "success", live: false }, | |
| 21 | + | stopped: { tone: "neutral", live: false }, | |
| 22 | + | failed: { tone: "danger", live: false }, | |
| 23 | + | }; | |
| 24 | + | ||
| 25 | + | /** A session card's state chip; a state it does not know reads quietly. */ | |
| 26 | + | export function sessionChip(state: string | null | undefined): SessionChip { | |
| 27 | + | return CHIPS[String(state ?? "").trim().toLowerCase()] ?? { tone: "neutral", live: false }; | |
| 28 | + | } |
| 95 | 95 | assert.equal(modeOf("/", "acme"), "home"); | |
| 96 | 96 | assert.equal(modeOf("/acme", "acme"), "home"); | |
| 97 | 97 | assert.equal(modeOf("/acme/-/home", "acme"), "home"); | |
| 98 | − | assert.equal(modeOf("/explore", "acme"), "home"); | |
| 98 | + | // g1t's own public pages light no mode. | |
| 99 | + | assert.equal(modeOf("/explore", "acme"), "site"); | |
| 100 | + | assert.equal(modeOf("/u/ada", "acme"), "site"); | |
| 101 | + | assert.equal(modeOf("/search.data", "acme"), "site"); | |
| 99 | 102 | assert.equal(modeOf("/inbox", "acme"), "inbox"); | |
| 100 | 103 | assert.equal(modeOf("/settings/emails", "acme"), "account"); | |
| 101 | 104 | assert.equal(modeOf("/acme/-/chat/general", "acme"), "chat"); |
| 171 | 171 | * The rail's modes (docs/WORKSPACE.md, "Shell"). Home, Code, Chat, Docs | |
| 172 | 172 | * and Agents are where work happens; the Inbox spans them; Workspace is the | |
| 173 | 173 | * workspace itself: its people, money, policies and settings. `account` is | |
| 174 | − | * your own settings, under the avatar. Each has a sidebar of its own, or | |
| 174 | + | * your own settings, under the avatar. `site` is g1t's own public pages | |
| 175 | + | * (a profile, Explore, Search): no workspace's, so no mode is lit and no | |
| 176 | + | * mode's sidebar sits beside them. Each mode has a sidebar of its own, or | |
| 175 | 177 | * none, and which one is lit follows the address. | |
| 176 | 178 | */ | |
| 177 | − | export type ModeKey = "home" | "chat" | "docs" | "agents" | "code" | "inbox" | "workspace" | "account"; | |
| 179 | + | export type ModeKey = "home" | "chat" | "docs" | "agents" | "code" | "inbox" | "workspace" | "account" | "site"; | |
| 178 | 180 | ||
| 179 | 181 | /** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */ | |
| 180 | 182 | const PAGE_MODES: Record<string, ModeKey> = { | |
| ⋯ | |||
| 206 | 208 | emoji: "workspace", | |
| 207 | 209 | }; | |
| 208 | 210 | ||
| 209 | − | /** First segments that are g1t's own pages, never a workspace: none is Code's. */ | |
| 211 | + | /** First segments that are g1t's own pages, never a workspace: none is any mode's. */ | |
| 210 | 212 | const SITE_PAGES = new Set(["explore", "search", "support", "policies", "security", "status", "u", "invite", "workspaces"]); | |
| 211 | 213 | ||
| 212 | 214 | /** | |
| 213 | 215 | * The mode a path is in, for the workspace `slug`. A repository (anyone's) | |
| 214 | 216 | * and a new project are Code's; g1t's own pages (Explore, search, a | |
| 215 | − | * profile) sit under Home, which is never Code. | |
| 217 | + | * profile) are `site`: public, and no workspace's mode. | |
| 216 | 218 | */ | |
| 217 | 219 | export function modeOf(pathname: string, slug: string | null): ModeKey { | |
| 218 | 220 | const path = pagePath(pathname); | |
| ⋯ | |||
| 220 | 222 | if (path === "/inbox" || path.startsWith("/inbox/")) return "inbox"; | |
| 221 | 223 | if (path === "/settings" || path.startsWith("/settings/")) return "account"; | |
| 222 | 224 | const parts = path.split("/").filter(Boolean); | |
| 223 | − | if (SITE_PAGES.has(parts[0] ?? "")) return "home"; | |
| 225 | + | if (SITE_PAGES.has(parts[0] ?? "")) return "site"; | |
| 224 | 226 | if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) { | |
| 225 | 227 | if (parts.length === 1) return "home"; | |
| 226 | 228 | if (parts[1] === "-") { | |
| ⋯ | |||
| 251 | 253 | return `/${slug}/-/workspace`; | |
| 252 | 254 | case "account": | |
| 253 | 255 | return "/settings"; | |
| 256 | + | case "site": | |
| 257 | + | return "/explore"; | |
| 254 | 258 | } | |
| 255 | 259 | } | |
| 256 | 260 | ||
| 119 | 119 | route("-/packages", "routes/workspace/packages.tsx"), | |
| 120 | 120 | route("-/packages/:ecosystem/*", "routes/workspace/package.tsx"), | |
| 121 | 121 | route("-/settings", "routes/workspace/settings.tsx"), | |
| 122 | + | // What members may do in chat: channels, emoji, default channels. | |
| 123 | + | route("-/settings/chat", "routes/workspace/chat-settings.tsx"), | |
| 122 | 124 | route("-/repositories", "routes/workspace/repositories.tsx"), | |
| 123 | 125 | // Agents mode: the fleet's runs first, then each of the workspace's own | |
| 124 | 126 | // agents (docs/WORKSPACE.md). `new` is no agent's handle. |
| 8 | 8 | import { CommitAvatars, CommitNames } from "../../components/commit-person"; | |
| 9 | 9 | import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 10 | 10 | import { Hint } from "../../components/ui/hint"; | |
| 11 | + | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 11 | 12 | import { page } from "../../lib/meta"; | |
| 12 | 13 | import { showCommits } from "../../lib/commit-people.server"; | |
| 13 | 14 | import { repos } from "../../lib/services.server"; | |
| ⋯ | |||
| 73 | 74 | function RefPicker({ name, label, value, branches }: { name: string; label: string; value: string | null; branches: string[] }) { | |
| 74 | 75 | const options = value && !branches.includes(value) ? [value, ...branches] : branches; | |
| 75 | 76 | return ( | |
| 76 | − | <label className="inline-flex items-center gap-2 text-sm"> | |
| 77 | − | <span className="text-muted">{label}</span> | |
| 78 | − | <select | |
| 79 | − | name={name} | |
| 80 | − | defaultValue={value ?? ""} | |
| 81 | − | className="h-8 max-w-56 rounded-md border border-line bg-surface px-2 font-mono text-[0.8125rem] outline-none hover:border-line-strong focus:border-accent-dim" | |
| 82 | − | > | |
| 83 | − | {value == null && <option value="">Choose a branch</option>} | |
| 84 | − | {options.map((option) => ( | |
| 85 | − | <option key={option} value={option}> | |
| 86 | − | {option} | |
| 87 | − | </option> | |
| 88 | − | ))} | |
| 89 | − | </select> | |
| 90 | − | </label> | |
| 77 | + | <div className="inline-flex items-center gap-2 text-sm"> | |
| 78 | + | <span className="text-muted" id={`${name}-label`}> | |
| 79 | + | {label} | |
| 80 | + | </span> | |
| 81 | + | <Select key={value ?? ""} name={name} defaultValue={value ?? undefined}> | |
| 82 | + | <SelectTrigger size="sm" aria-labelledby={`${name}-label`} className="w-auto max-w-56 font-mono"> | |
| 83 | + | <SelectValue placeholder="Choose a branch" /> | |
| 84 | + | </SelectTrigger> | |
| 85 | + | <SelectContent> | |
| 86 | + | {options.map((option) => ( | |
| 87 | + | <SelectItem key={option} value={option} className="font-mono text-[0.8125rem]"> | |
| 88 | + | {option} | |
| 89 | + | </SelectItem> | |
| 90 | + | ))} | |
| 91 | + | </SelectContent> | |
| 92 | + | </Select> | |
| 93 | + | </div> | |
| 91 | 94 | ); | |
| 92 | 95 | } | |
| 93 | 96 | ||
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../components/ui"; | |
| 10 | 10 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 11 | + | import { SelectField } from "../../components/ui/select"; | |
| 11 | 12 | import { LabelChip } from "../../components/labels"; | |
| 12 | 13 | import { integrations, work } from "../../lib/services.server"; | |
| 13 | 14 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| ⋯ | |||
| 165 | 166 | </fieldset> | |
| 166 | 167 | {loaderData.canTriage && loaderData.milestones.length > 0 && ( | |
| 167 | 168 | <Field label="Milestone"> | |
| 168 | − | <select | |
| 169 | + | <SelectField | |
| 169 | 170 | name="milestone" | |
| 170 | 171 | defaultValue="" | |
| 171 | − | className="h-9 w-full rounded-md border border-line bg-bg px-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim" | |
| 172 | − | > | |
| 173 | − | <option value="">None</option> | |
| 174 | − | {loaderData.milestones.map((milestone) => ( | |
| 175 | − | <option key={milestone.number} value={milestone.number}> | |
| 176 | − | {milestone.title} | |
| 177 | − | {milestone.dueOn ? ` (due ${milestone.dueOn})` : ""} | |
| 178 | − | </option> | |
| 179 | − | ))} | |
| 180 | − | </select> | |
| 172 | + | options={[ | |
| 173 | + | { value: "", label: "None" }, | |
| 174 | + | ...loaderData.milestones.map((milestone) => ({ | |
| 175 | + | value: String(milestone.number), | |
| 176 | + | label: milestone.dueOn ? `${milestone.title} (due ${milestone.dueOn})` : milestone.title, | |
| 177 | + | })), | |
| 178 | + | ]} | |
| 179 | + | /> | |
| 181 | 180 | </Field> | |
| 182 | 181 | )} | |
| 183 | 182 | {loaderData.canAssign && ( | |
| 6 | 6 | import { page } from "../../lib/meta"; | |
| 7 | 7 | import { ActivationPrompt, CARD, SectionHeader } from "../../components/security-suite"; | |
| 8 | 8 | import { Hint } from "../../components/ui/hint"; | |
| 9 | + | import { SelectField } from "../../components/ui/select"; | |
| 9 | 10 | import { Switch } from "../../components/ui/switch"; | |
| 10 | 11 | import { securitySuite } from "../../lib/services.server"; | |
| 11 | 12 | import { assertSameOrigin, getViewer, managesSecurity, requireUser, unwrap } from "../../lib/session.server"; | |
| ⋯ | |||
| 58 | 59 | return saved.ok ? { ok: true } : { ok: false, error: saved.error.message }; | |
| 59 | 60 | } | |
| 60 | 61 | ||
| 61 | − | const SELECT = "h-9 w-full rounded-md border border-line bg-bg px-2.5 text-sm text-fg outline-none hover:border-line-strong focus:border-accent-dim sm:w-72"; | |
| 62 | + | const SELECT = "w-full sm:w-72"; | |
| 62 | 63 | ||
| 63 | 64 | export default function SecuritySettings({ loaderData, params }: Route.ComponentProps) { | |
| 64 | 65 | const { view, price, can, owner } = loaderData; | |
| ⋯ | |||
| 78 | 79 | <legend className="sr-only">Code scanning</legend> | |
| 79 | 80 | <p className="text-sm font-medium">Code scanning results</p> | |
| 80 | 81 | <p className="text-sm text-muted">The Code scanning check fails when a pull request brings new results on the lines it changes at this level.</p> | |
| 81 | − | <select name="codeScanningGate" defaultValue={settings.codeScanningGate} className={SELECT} aria-label="When code scanning fails"> | |
| 82 | − | {CODE_SCANNING_GATES.map((gate) => ( | |
| 83 | − | <option key={gate.gate} value={gate.gate}> | |
| 84 | − | {gate.label} | |
| 85 | − | </option> | |
| 86 | − | ))} | |
| 87 | − | </select> | |
| 82 | + | <SelectField | |
| 83 | + | name="codeScanningGate" | |
| 84 | + | defaultValue={settings.codeScanningGate} | |
| 85 | + | disabled={disabled} | |
| 86 | + | className={SELECT} | |
| 87 | + | aria-label="When code scanning fails" | |
| 88 | + | options={CODE_SCANNING_GATES.map((gate) => ({ value: gate.gate, label: gate.label }))} | |
| 89 | + | /> | |
| 88 | 90 | </fieldset> | |
| 89 | 91 | <fieldset disabled={disabled} className={`${CARD} space-y-3 p-4 disabled:opacity-60`}> | |
| 90 | 92 | <legend className="sr-only">Dependency review</legend> | |
| ⋯ | |||
| 100 | 102 | </label> | |
| 101 | 103 | <label className="block"> | |
| 102 | 104 | <span className="mb-1.5 block text-xs font-medium text-muted">Fail on vulnerabilities of</span> | |
| 103 | − | <select name="reviewFailOn" defaultValue={settings.reviewFailOn} className={SELECT}> | |
| 104 | − | <option value="critical">Critical severity</option> | |
| 105 | − | <option value="high">High severity or higher</option> | |
| 106 | − | <option value="medium">Medium severity or higher</option> | |
| 107 | − | <option value="low">Any severity</option> | |
| 108 | − | <option value="none">Never fail on vulnerabilities</option> | |
| 109 | − | </select> | |
| 105 | + | <SelectField | |
| 106 | + | name="reviewFailOn" | |
| 107 | + | defaultValue={settings.reviewFailOn} | |
| 108 | + | disabled={disabled} | |
| 109 | + | className={SELECT} | |
| 110 | + | options={[ | |
| 111 | + | { value: "critical", label: "Critical severity" }, | |
| 112 | + | { value: "high", label: "High severity or higher" }, | |
| 113 | + | { value: "medium", label: "Medium severity or higher" }, | |
| 114 | + | { value: "low", label: "Any severity" }, | |
| 115 | + | { value: "none", label: "Never fail on vulnerabilities" }, | |
| 116 | + | ]} | |
| 117 | + | /> | |
| 110 | 118 | </label> | |
| 111 | 119 | <label className="block"> | |
| 112 | 120 | <span className="mb-1.5 block text-xs font-medium text-muted">Licenses not allowed (SPDX ids)</span> | |
| 3 | 3 | * `/<workspace>` (as Docker Hub keeps people under `/u/`). | |
| 4 | 4 | * | |
| 5 | 5 | * The left column is who they are; the main column is their work, as | |
| 6 | − | * tabs: an overview, then their pull requests and issues with a filter | |
| 7 | − | * aside. Everything is filtered on the server by `work.byAuthor`, which | |
| 6 | + | * tabs: an overview (their contribution calendar first), then their pull | |
| 7 | + | * requests and issues with a filter aside. Everything is filtered on the server by `work.byAuthor`, which | |
| 8 | 8 | * only ever returns work on repositories the viewer may read, and the | |
| 9 | 9 | * workspaces shown are only those the viewer could know about anyway (see | |
| 10 | 10 | * `profile_workspaces` in services/identity/src/profiles.rs). | |
| ⋯ | |||
| 23 | 23 | import type { ReactNode } from "react"; | |
| 24 | 24 | import { Form, Link, redirect, useNavigate, useSearchParams } from "react-router"; | |
| 25 | 25 | ||
| 26 | − | import type { Authored, AuthoredItem, AuthoredSort, AuthoredState, Profile, StarredRepo } from "@g1t/contracts"; | |
| 26 | + | import type { Authored, AuthoredItem, AuthoredSort, AuthoredState, Contributions, Profile, StarredRepo } from "@g1t/contracts"; | |
| 27 | 27 | ||
| 28 | 28 | import type { Route } from "./+types/user"; | |
| 29 | 29 | import { page } from "../lib/meta"; | |
| 30 | + | import { ContributionCalendar } from "../components/contribution-calendar"; | |
| 30 | 31 | import { Avatar, Button, ButtonLink, EmptyState, TimeAgo } from "../components/ui"; | |
| 31 | 32 | import { RadioGroup, RadioOption } from "../components/ui/radio-group"; | |
| 32 | 33 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; | |
| 33 | 34 | import { IssueIcon, PullIcon } from "../components/work-icons"; | |
| 35 | + | import { lastDay } from "../lib/contribution-calendar"; | |
| 34 | 36 | import { notFound } from "../lib/not-found.server"; | |
| 35 | 37 | import { identity, repos, work } from "../lib/services.server"; | |
| 36 | 38 | import { getViewer } from "../lib/session.server"; | |
| ⋯ | |||
| 96 | 98 | .then((publicIn) => identity.profileWorkspaces(username, viewer, publicIn)); | |
| 97 | 99 | // The card is drawn for no one in particular, so its version uses what | |
| 98 | 100 | // everyone sees; for a signed-out viewer that is these counts already. | |
| 99 | − | const [authored, shown, starred] = await Promise.all([ | |
| 101 | + | const [authored, shown, starred, year] = await Promise.all([ | |
| 100 | 102 | work.byAuthor(username, viewer, filter), | |
| 101 | 103 | workspaces, | |
| 102 | 104 | // What they starred that the viewer can see, on its own tab. | |
| 103 | 105 | tab === "stars" ? repos.starred(username, viewer).catch(() => [] as StarredRepo[]) : ([] as StarredRepo[]), | |
| 106 | + | // Their year, on the overview: counted only where the viewer can see. | |
| 107 | + | tab === "overview" | |
| 108 | + | ? work | |
| 109 | + | .contributions(username, viewer) | |
| 110 | + | .then((result) => (result.ok ? result.value : null)) | |
| 111 | + | .catch(() => null as Contributions | null) | |
| 112 | + | : null, | |
| 104 | 113 | ]); | |
| 105 | 114 | const activity = authored.ok ? authored.value : EMPTY; | |
| 115 | + | // The calendar ends today, as the work service counted it (UTC). | |
| 116 | + | const today = (year && lastDay(year.from)) || new Date().toISOString().slice(0, 10); | |
| 106 | 117 | return { | |
| 107 | 118 | profile, | |
| 108 | 119 | tab, | |
| ⋯ | |||
| 111 | 122 | failed: !authored.ok, | |
| 112 | 123 | workspaces: shown, | |
| 113 | 124 | starred, | |
| 125 | + | contributions: year ? { ...year, today } : null, | |
| 114 | 126 | isSelf: viewer?.username === profile.username, | |
| 115 | 127 | publicCounts: viewer ? null : activity.counts, | |
| 116 | 128 | }; | |
| ⋯ | |||
| 324 | 336 | ||
| 325 | 337 | type Data = Route.ComponentProps["loaderData"]; | |
| 326 | 338 | ||
| 327 | − | function Overview({ profile, activity, failed }: Data) { | |
| 339 | + | function Overview({ profile, activity, failed, contributions }: Data) { | |
| 328 | 340 | const { counts, items } = activity; | |
| 329 | 341 | const who = profile.name ?? profile.username; | |
| 330 | 342 | return ( | |
| 331 | 343 | <div className="space-y-8"> | |
| 344 | + | {contributions && ( | |
| 345 | + | <ContributionCalendar | |
| 346 | + | days={contributions.days} | |
| 347 | + | total={contributions.total} | |
| 348 | + | today={contributions.today} | |
| 349 | + | from={contributions.from} | |
| 350 | + | /> | |
| 351 | + | )} | |
| 332 | 352 | <dl className="grid grid-cols-1 gap-3 sm:grid-cols-3"> | |
| 333 | 353 | <Stat | |
| 334 | 354 | label="Pull requests merged" | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | 6 | import { AuditTable } from "../../components/audit"; | |
| 7 | 7 | import { Button, ButtonLink, EmptyState, Field, Input } from "../../components/ui"; | |
| 8 | + | import { SelectField } from "../../components/ui/select"; | |
| 8 | 9 | import { type AuditFilters, filterHref, parseFilters, toQuery } from "../../lib/audit"; | |
| 9 | 10 | import { auditPage, auditRetention } from "../../lib/audit.server"; | |
| 10 | 11 | import { repos } from "../../lib/services.server"; | |
| ⋯ | |||
| 73 | 74 | ); | |
| 74 | 75 | } | |
| 75 | 76 | ||
| 76 | − | const SELECT = | |
| 77 | − | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors hover:border-line-strong focus:border-accent-dim"; | |
| 77 | + | /** As tall as the inputs beside it. */ | |
| 78 | + | const SELECT = "h-auto py-2"; | |
| 78 | 79 | ||
| 79 | 80 | export default function WorkspaceAudit({ loaderData }: Route.ComponentProps) { | |
| 80 | 81 | const { workspace, role, filters, entries, next, projects, retention } = loaderData; | |
| ⋯ | |||
| 101 | 102 | <FilterField label="Action" name="action" value={filters.action} placeholder="git.push" list="audit-actions" /> | |
| 102 | 103 | <FilterField label="Project" name="project" value={filters.project} placeholder="Any" list="audit-projects" /> | |
| 103 | 104 | <Field label="Outcome"> | |
| 104 | − | <select name="outcome" defaultValue={filters.outcome} className={SELECT}> | |
| 105 | − | <option value="">Any</option> | |
| 106 | − | <option value="allowed">Allowed</option> | |
| 107 | − | <option value="denied">Denied</option> | |
| 108 | − | </select> | |
| 105 | + | <SelectField | |
| 106 | + | key={`outcome-${filters.outcome}`} | |
| 107 | + | name="outcome" | |
| 108 | + | defaultValue={filters.outcome} | |
| 109 | + | className={SELECT} | |
| 110 | + | options={[ | |
| 111 | + | { value: "", label: "Any" }, | |
| 112 | + | { value: "allowed", label: "Allowed" }, | |
| 113 | + | { value: "denied", label: "Denied" }, | |
| 114 | + | ]} | |
| 115 | + | /> | |
| 109 | 116 | </Field> | |
| 110 | 117 | <Field label="Who"> | |
| 111 | − | <select name="kind" defaultValue={filters.kind} className={SELECT}> | |
| 112 | − | <option value="">Anyone</option> | |
| 113 | − | <option value="agent">Agents</option> | |
| 114 | − | <option value="person">People</option> | |
| 115 | − | <option value="workspace">Workspace tokens</option> | |
| 116 | − | </select> | |
| 118 | + | <SelectField | |
| 119 | + | key={`kind-${filters.kind}`} | |
| 120 | + | name="kind" | |
| 121 | + | defaultValue={filters.kind} | |
| 122 | + | className={SELECT} | |
| 123 | + | options={[ | |
| 124 | + | { value: "", label: "Anyone" }, | |
| 125 | + | { value: "agent", label: "Agents" }, | |
| 126 | + | { value: "person", label: "People" }, | |
| 127 | + | { value: "workspace", label: "Workspace tokens" }, | |
| 128 | + | ]} | |
| 129 | + | /> | |
| 117 | 130 | </Field> | |
| 118 | 131 | <Field label="From"> | |
| 119 | 132 | <Input type="date" name="from" defaultValue={filters.from} /> | |
| 1 | + | import { Hash } from "lucide-react"; | |
| 2 | + | import { useEffect, useState } from "react"; | |
| 3 | + | import { Form, Link, data, useNavigation } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { ChannelManagers, ChatAllowed, ChatSettings, EmojiUpload } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import type { Route } from "./+types/chat-settings"; | |
| 8 | + | import { Button, ErrorText } from "../../components/ui"; | |
| 9 | + | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 10 | + | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; | |
| 11 | + | import { page } from "../../lib/meta"; | |
| 12 | + | import { chat } from "../../lib/services.server"; | |
| 13 | + | import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server"; | |
| 14 | + | ||
| 15 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 16 | + | return page(args, { title: `Chat settings · ${params.owner} · g1t` }); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /** | |
| 20 | + | * Settings, Chat: what members may do in the workspace's chat. Every | |
| 21 | + | * member can read it, so nobody has to guess why a button is off; only | |
| 22 | + | * owners change it. The chat service enforces each setting. | |
| 23 | + | */ | |
| 24 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 25 | + | const viewer = requireUser(context, request); | |
| 26 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 27 | + | const slug = params.owner.toLowerCase(); | |
| 28 | + | const view = await chat.chatSettings(slug, viewer).catch(() => null); | |
| 29 | + | if (!view?.ok) return { slug, view: null, error: view ? view.error.message : "Chat didn't answer. Try again in a moment." }; | |
| 30 | + | return { slug, view: view.value, error: null }; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 34 | + | assertSameOrigin(request); | |
| 35 | + | const viewer = requireUser(context, request); | |
| 36 | + | const slug = params.owner.toLowerCase(); | |
| 37 | + | const form = await request.formData(); | |
| 38 | + | const who = (key: string): ChatAllowed => (form.get(key) === "owners" ? "owners" : "members"); | |
| 39 | + | const change: Partial<ChatSettings> = { | |
| 40 | + | public_channels: who("public_channels"), | |
| 41 | + | private_channels: who("private_channels"), | |
| 42 | + | manage_channels: (form.get("manage_channels") === "owners" ? "owners" : "channel_owners") as ChannelManagers, | |
| 43 | + | emoji_upload: (form.get("emoji_upload") === "admins" ? "admins" : "members") as EmojiUpload, | |
| 44 | + | default_channels: form.getAll("default_channels").map(String), | |
| 45 | + | }; | |
| 46 | + | try { | |
| 47 | + | const saved = await chat.setChatSettings(slug, viewer, change); | |
| 48 | + | return saved.ok ? { ok: true as const, settings: saved.value } : { ok: false as const, error: saved.error.message }; | |
| 49 | + | } catch (error) { | |
| 50 | + | console.error("chat settings: the chat service did not answer", error); | |
| 51 | + | return { ok: false as const, error: "Chat didn't answer. Try again in a moment." }; | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | const SECTION = "border-t border-line pt-8 first:border-t-0 first:pt-0"; | |
| 56 | + | ||
| 57 | + | export default function ChatSettingsPage({ loaderData, actionData }: Route.ComponentProps) { | |
| 58 | + | const { slug, view } = loaderData; | |
| 59 | + | const navigation = useNavigation(); | |
| 60 | + | const saving = navigation.state !== "idle" && navigation.formMethod === "POST"; | |
| 61 | + | const [settings, setSettings] = useState<ChatSettings | null>(view?.settings ?? null); | |
| 62 | + | // What the service saved, once it answers. | |
| 63 | + | useEffect(() => { | |
| 64 | + | if (actionData?.ok) setSettings(actionData.settings); | |
| 65 | + | }, [actionData]); | |
| 66 | + | if (!view || !settings) { | |
| 67 | + | return ( | |
| 68 | + | <div className="max-w-2xl"> | |
| 69 | + | <Header /> | |
| 70 | + | <ErrorText>{loaderData.error}</ErrorText> | |
| 71 | + | </div> | |
| 72 | + | ); | |
| 73 | + | } | |
| 74 | + | const owner = view.can.manage_settings; | |
| 75 | + | const saved = actionData?.ok ? actionData.settings : view.settings; | |
| 76 | + | const changed = JSON.stringify(settings) !== JSON.stringify(saved); | |
| 77 | + | const set = <K extends keyof ChatSettings>(key: K, value: ChatSettings[K]) => setSettings((now) => (now ? { ...now, [key]: value } : now)); | |
| 78 | + | const defaults = new Set(settings.default_channels); | |
| 79 | + | return ( | |
| 80 | + | <div className="max-w-2xl"> | |
| 81 | + | <Header /> | |
| 82 | + | {!owner && ( | |
| 83 | + | <p className="mb-8 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-muted"> | |
| 84 | + | Only workspace owners can change these. They are shown so you know what members can do here. | |
| 85 | + | </p> | |
| 86 | + | )} | |
| 87 | + | <Form method="post" className="space-y-8"> | |
| 88 | + | <fieldset disabled={!owner} className="space-y-8"> | |
| 89 | + | <section className={SECTION}> | |
| 90 | + | <h2 className="font-medium">Creating channels</h2> | |
| 91 | + | <p className="mt-1.5 text-xs text-faint">Who can create channels. Whoever creates one becomes its first owner.</p> | |
| 92 | + | <div className="mt-5 grid gap-6 sm:grid-cols-2"> | |
| 93 | + | <Choice | |
| 94 | + | name="public_channels" | |
| 95 | + | title="Public channels" | |
| 96 | + | value={settings.public_channels} | |
| 97 | + | onChange={(value) => set("public_channels", value as ChatAllowed)} | |
| 98 | + | options={[ | |
| 99 | + | { value: "members", label: "Any member", description: "The default." }, | |
| 100 | + | { value: "owners", label: "Owners only", description: "Members join and read public channels, but ask an owner for a new one." }, | |
| 101 | + | ]} | |
| 102 | + | disabled={!owner} | |
| 103 | + | /> | |
| 104 | + | <Choice | |
| 105 | + | name="private_channels" | |
| 106 | + | title="Private channels" | |
| 107 | + | value={settings.private_channels} | |
| 108 | + | onChange={(value) => set("private_channels", value as ChatAllowed)} | |
| 109 | + | options={[ | |
| 110 | + | { value: "members", label: "Any member", description: "The default." }, | |
| 111 | + | { value: "owners", label: "Owners only", description: "Only owners can make a channel that is kept to the people invited." }, | |
| 112 | + | ]} | |
| 113 | + | disabled={!owner} | |
| 114 | + | /> | |
| 115 | + | </div> | |
| 116 | + | </section> | |
| 117 | + | ||
| 118 | + | <section className={SECTION}> | |
| 119 | + | <h2 className="font-medium">Renaming and archiving channels</h2> | |
| 120 | + | <p className="mt-1.5 text-xs text-faint"> | |
| 121 | + | Who can rename a channel, archive it, and bring it back. Any member of a channel can change its topic. #general is never renamed or | |
| 122 | + | archived. | |
| 123 | + | </p> | |
| 124 | + | <div className="mt-5"> | |
| 125 | + | <Choice | |
| 126 | + | name="manage_channels" | |
| 127 | + | title="Who can rename and archive" | |
| 128 | + | hideTitle | |
| 129 | + | value={settings.manage_channels} | |
| 130 | + | onChange={(value) => set("manage_channels", value as ChannelManagers)} | |
| 131 | + | options={[ | |
| 132 | + | { value: "channel_owners", label: "Channel owners and workspace owners", description: "Whoever created a channel looks after it. The default." }, | |
| 133 | + | { value: "owners", label: "Workspace owners only" }, | |
| 134 | + | ]} | |
| 135 | + | disabled={!owner} | |
| 136 | + | /> | |
| 137 | + | </div> | |
| 138 | + | </section> | |
| 139 | + | ||
| 140 | + | <section className={SECTION}> | |
| 141 | + | <h2 className="font-medium">Custom emoji</h2> | |
| 142 | + | <p className="mt-1.5 text-xs text-faint"> | |
| 143 | + | Who can add the workspace's own emoji and aliases. Whoever added one, and owners, can remove it.{" "} | |
| 144 | + | <Link to={`/${slug}/-/emoji`} className="text-muted underline decoration-line-strong underline-offset-2 hover:text-fg"> | |
| 145 | + | Manage emoji | |
| 146 | + | </Link> | |
| 147 | + | </p> | |
| 148 | + | <div className="mt-5"> | |
| 149 | + | <Choice | |
| 150 | + | name="emoji_upload" | |
| 151 | + | title="Who can add emoji" | |
| 152 | + | hideTitle | |
| 153 | + | value={settings.emoji_upload} | |
| 154 | + | onChange={(value) => set("emoji_upload", value as EmojiUpload)} | |
| 155 | + | options={[ | |
| 156 | + | { value: "members", label: "Any member", description: "The default." }, | |
| 157 | + | { value: "admins", label: "Owners only", description: "Members use the workspace's emoji; only owners add them." }, | |
| 158 | + | ]} | |
| 159 | + | disabled={!owner} | |
| 160 | + | /> | |
| 161 | + | </div> | |
| 162 | + | </section> | |
| 163 | + | ||
| 164 | + | <section className={SECTION}> | |
| 165 | + | <h2 className="font-medium">Default channels</h2> | |
| 166 | + | <p className="mt-1.5 text-xs text-faint"> | |
| 167 | + | The public channels someone new is put in the first time they open Chat. They can leave any of them. People already here are not | |
| 168 | + | moved. | |
| 169 | + | </p> | |
| 170 | + | {view.channels.length === 0 ? ( | |
| 171 | + | <p className="mt-5 text-sm text-muted">No public channels yet.</p> | |
| 172 | + | ) : ( | |
| 173 | + | <ul className="mt-5 grid gap-2.5 sm:grid-cols-2"> | |
| 174 | + | {view.channels.map((channel) => ( | |
| 175 | + | <li key={channel.id}> | |
| 176 | + | {defaults.has(channel.id) && <input type="hidden" name="default_channels" value={channel.id} />} | |
| 177 | + | <CheckboxOption | |
| 178 | + | checked={defaults.has(channel.id)} | |
| 179 | + | disabled={!owner} | |
| 180 | + | onCheckedChange={(on) => | |
| 181 | + | set("default_channels", on === true ? [...settings.default_channels, channel.id] : settings.default_channels.filter((id) => id !== channel.id)) | |
| 182 | + | } | |
| 183 | + | label={ | |
| 184 | + | <span className="inline-flex items-center gap-1"> | |
| 185 | + | <Hash size={13} className="text-faint" /> | |
| 186 | + | {channel.name} | |
| 187 | + | </span> | |
| 188 | + | } | |
| 189 | + | description={channel.topic ?? undefined} | |
| 190 | + | /> | |
| 191 | + | </li> | |
| 192 | + | ))} | |
| 193 | + | </ul> | |
| 194 | + | )} | |
| 195 | + | </section> | |
| 196 | + | </fieldset> | |
| 197 | + | ||
| 198 | + | {owner && ( | |
| 199 | + | <div className="flex items-center gap-3 border-t border-line pt-6"> | |
| 200 | + | <Button type="submit" variant="accent" disabled={saving || !changed}> | |
| 201 | + | {saving ? "Saving…" : "Save"} | |
| 202 | + | </Button> | |
| 203 | + | {actionData && !actionData.ok && <ErrorText>{actionData.error}</ErrorText>} | |
| 204 | + | {actionData?.ok && !changed && ( | |
| 205 | + | <p role="status" className="text-xs text-muted"> | |
| 206 | + | Saved. | |
| 207 | + | </p> | |
| 208 | + | )} | |
| 209 | + | </div> | |
| 210 | + | )} | |
| 211 | + | </Form> | |
| 212 | + | </div> | |
| 213 | + | ); | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | function Header() { | |
| 217 | + | return ( | |
| 218 | + | <header className="mb-8 border-b border-line pb-6"> | |
| 219 | + | <h1 className="text-2xl font-semibold tracking-tight">Chat</h1> | |
| 220 | + | <p className="mt-1.5 text-sm text-muted">What members can do in the workspace's chat: channels, emoji, and where someone new starts.</p> | |
| 221 | + | </header> | |
| 222 | + | ); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /** One setting's choices, posted as `name`. */ | |
| 226 | + | function Choice({ | |
| 227 | + | name, | |
| 228 | + | title, | |
| 229 | + | hideTitle, | |
| 230 | + | value, | |
| 231 | + | onChange, | |
| 232 | + | options, | |
| 233 | + | disabled, | |
| 234 | + | }: { | |
| 235 | + | name: string; | |
| 236 | + | title: string; | |
| 237 | + | hideTitle?: boolean; | |
| 238 | + | value: string; | |
| 239 | + | onChange: (value: string) => void; | |
| 240 | + | options: { value: string; label: string; description?: string }[]; | |
| 241 | + | disabled?: boolean; | |
| 242 | + | }) { | |
| 243 | + | return ( | |
| 244 | + | <div> | |
| 245 | + | {!hideTitle && <h3 className="mb-2.5 text-sm font-medium text-fg-soft">{title}</h3>} | |
| 246 | + | <RadioGroup name={name} value={value} onValueChange={onChange} aria-label={title} disabled={disabled}> | |
| 247 | + | {options.map((option) => ( | |
| 248 | + | <RadioOption key={option.value} value={option.value} label={option.label} description={option.description} disabled={disabled} /> | |
| 249 | + | ))} | |
| 250 | + | </RadioGroup> | |
| 251 | + | </div> | |
| 252 | + | ); | |
| 253 | + | } |
| 50 | 50 | member?: string; | |
| 51 | 51 | message_id?: string; | |
| 52 | 52 | emoji?: string; | |
| 53 | + | archived?: boolean; | |
| 53 | 54 | }; | |
| 54 | 55 | ||
| 55 | 56 | export async function action({ params, context, request }: Route.ActionArgs) { | |
| ⋯ | |||
| 97 | 98 | const made = await chat.createChannel(slug, viewer, { name, topic: sent.topic?.trim() || null, private: Boolean(sent.private) }); | |
| 98 | 99 | return made.ok ? { ok: true, value: { channel: made.value, to: channelPath(slug, made.value) } } : made; | |
| 99 | 100 | } | |
| 101 | + | case "update_channel": { | |
| 102 | + | // A rename, a new topic, archiving or unarchiving; the chat service checks who may. | |
| 103 | + | const change: { name?: string; topic?: string | null; archived?: boolean } = {}; | |
| 104 | + | if (typeof sent.name === "string") { | |
| 105 | + | const name = channelName(sent.name); | |
| 106 | + | if (!name) return { ok: false, error: { code: "invalid", message: "Give the channel a name." } }; | |
| 107 | + | if ((RESERVED_CHANNEL_NAMES as readonly string[]).includes(name)) { | |
| 108 | + | return { ok: false, error: { code: "invalid", message: `#${name} is taken by g1t. Try another name.` } }; | |
| 109 | + | } | |
| 110 | + | change.name = name; | |
| 111 | + | } | |
| 112 | + | if (typeof sent.topic === "string") change.topic = sent.topic.trim() || null; | |
| 113 | + | if (typeof sent.archived === "boolean") change.archived = sent.archived; | |
| 114 | + | const updated = await chat.updateChannel(slug, channel, viewer, change); | |
| 115 | + | return updated.ok ? { ok: true, value: { channel: updated.value, to: channelPath(slug, updated.value) } } : updated; | |
| 116 | + | } | |
| 100 | 117 | case "dm": { | |
| 101 | 118 | const members = await principalsFrom(slug, viewer, sent.members ?? []); | |
| 102 | 119 | if (members.length === 0) return { ok: false, error: { code: "invalid", message: "Choose who to message." } }; | |
| 1 | − | import { Hash, Search } from "lucide-react"; | |
| 1 | + | import { Archive, Hash, Lock, Search } from "lucide-react"; | |
| 2 | 2 | import { useMemo, useState } from "react"; | |
| 3 | 3 | import { Link, useNavigate } from "react-router"; | |
| 4 | − | ||
| 5 | − | import type { Channel } from "@g1t/contracts"; | |
| 6 | 4 | ||
| 7 | 5 | import type { Route } from "./+types/browse"; | |
| 8 | 6 | import { ChatUnavailable } from "../../../components/chat/empty"; | |
| 9 | − | import { CreateChannelButton, useChatSend } from "../../../components/chat/actions"; | |
| 7 | + | import { CreateChannelButton, OWNERS_ONLY_CHANNELS, useChannelCreation, useChatSend } from "../../../components/chat/actions"; | |
| 10 | 8 | import { channelPath } from "../../../lib/chat"; | |
| 11 | 9 | import { sidebarOrNull } from "../../../lib/chat.server"; | |
| 12 | 10 | import { page } from "../../../lib/meta"; | |
| ⋯ | |||
| 17 | 15 | return page(args, { title: `Browse channels · ${params.owner} · g1t` }); | |
| 18 | 16 | } | |
| 19 | 17 | ||
| 20 | − | /** Every public channel in the workspace, joined or not. */ | |
| 18 | + | /** | |
| 19 | + | * Every public channel in the workspace, joined or not, and the private | |
| 20 | + | * ones the viewer is in (the chat service leaves out the rest). With | |
| 21 | + | * `?archived=1`, the archived ones instead. | |
| 22 | + | */ | |
| 21 | 23 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 22 | 24 | const viewer = requireUser(context, request); | |
| 23 | 25 | const slug = params.owner.toLowerCase(); | |
| 24 | − | const [open, sidebar] = await Promise.all([chat.browse(slug, viewer).catch(() => null), sidebarOrNull(slug, viewer)]); | |
| 25 | − | if (!open?.ok) return { slug, unavailable: true as const }; | |
| 26 | − | const mine = new Set((sidebar?.entries ?? []).map((entry) => entry.channel.id)); | |
| 27 | − | const listed = new Map<string, Channel>(); | |
| 28 | − | for (const channel of open.value) listed.set(channel.id, channel); | |
| 29 | − | for (const entry of sidebar?.entries ?? []) if (entry.channel.kind === "channel") listed.set(entry.channel.id, entry.channel); | |
| 30 | − | const channels = [...listed.values()].filter((channel) => !channel.archived_at).sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")); | |
| 31 | − | return { slug, unavailable: false as const, channels, joined: [...mine] }; | |
| 26 | + | const archived = new URL(request.url).searchParams.get("archived") === "1"; | |
| 27 | + | const [listed, sidebar] = await Promise.all([chat.browse(slug, viewer, { archived }).catch(() => null), sidebarOrNull(slug, viewer)]); | |
| 28 | + | if (!listed?.ok) return { slug, unavailable: true as const }; | |
| 29 | + | const joined = (sidebar?.entries ?? []).map((entry) => entry.channel.id); | |
| 30 | + | return { slug, unavailable: false as const, archived, channels: listed.value, joined }; | |
| 32 | 31 | } | |
| 33 | 32 | ||
| 34 | 33 | export default function Browse({ loaderData }: Route.ComponentProps) { | |
| ⋯ | |||
| 36 | 35 | const navigate = useNavigate(); | |
| 37 | 36 | const send = useChatSend(loaderData.slug); | |
| 38 | 37 | const [joining, setJoining] = useState<string | null>(null); | |
| 38 | + | const may = useChannelCreation(); | |
| 39 | 39 | const shown = useMemo(() => { | |
| 40 | 40 | if (loaderData.unavailable) return []; | |
| 41 | 41 | const q = query.trim().toLowerCase().replace(/^#/, ""); | |
| ⋯ | |||
| 47 | 47 | <div className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-8 lg:py-12"> | |
| 48 | 48 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 49 | 49 | <div> | |
| 50 | − | <h1 className="text-2xl font-semibold tracking-tight">Browse channels</h1> | |
| 51 | − | <p className="mt-1 text-sm text-muted">Every public channel in {loaderData.slug}. Anyone in the workspace can read and join them.</p> | |
| 50 | + | <h1 className="text-2xl font-semibold tracking-tight">{loaderData.archived ? "Archived channels" : "Browse channels"}</h1> | |
| 51 | + | <p className="mt-1 text-sm text-muted"> | |
| 52 | + | {loaderData.archived | |
| 53 | + | ? "Channels no one posts in any more. Their history stays readable; whoever may archive a channel may bring it back." | |
| 54 | + | : `Every public channel in ${loaderData.slug}, which anyone in the workspace can read and join, and the private ones you're in.`} | |
| 55 | + | </p> | |
| 56 | + | {!loaderData.archived && !may.any && <p className="mt-1 text-xs text-faint">{OWNERS_ONLY_CHANNELS}</p>} | |
| 57 | + | </div> | |
| 58 | + | <div className="flex items-center gap-2"> | |
| 59 | + | <Link | |
| 60 | + | to={loaderData.archived ? `/${loaderData.slug}/-/chat/browse` : `/${loaderData.slug}/-/chat/browse?archived=1`} | |
| 61 | + | className="inline-flex h-9 items-center gap-2 rounded-md px-3 text-sm text-muted transition-colors hover:bg-surface hover:text-fg" | |
| 62 | + | > | |
| 63 | + | {loaderData.archived ? <Hash size={15} /> : <Archive size={15} />} | |
| 64 | + | {loaderData.archived ? "Current channels" : "Archived"} | |
| 65 | + | </Link> | |
| 66 | + | {!loaderData.archived && <CreateChannelButton slug={loaderData.slug} variant="button" />} | |
| 52 | 67 | </div> | |
| 53 | − | <CreateChannelButton slug={loaderData.slug} variant="button" /> | |
| 54 | 68 | </div> | |
| 55 | 69 | <label className="mt-6 flex h-10 items-center gap-2 rounded-lg border border-line bg-surface px-3 text-sm focus-within:border-accent-dim"> | |
| 56 | 70 | <Search size={15} className="text-faint" /> | |
| ⋯ | |||
| 63 | 77 | /> | |
| 64 | 78 | </label> | |
| 65 | 79 | <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 66 | − | {shown.length === 0 && <li className="px-4 py-10 text-center text-sm text-muted">No channel matches.</li>} | |
| 80 | + | {shown.length === 0 && ( | |
| 81 | + | <li className="px-4 py-10 text-center text-sm text-muted"> | |
| 82 | + | {query ? "No channel matches." : loaderData.archived ? "No archived channels." : "No channels yet."} | |
| 83 | + | </li> | |
| 84 | + | )} | |
| 67 | 85 | {shown.map((channel) => { | |
| 68 | 86 | const isIn = joined.has(channel.id); | |
| 69 | 87 | return ( | |
| 70 | 88 | <li key={channel.id} className="flex items-center gap-3 px-4 py-3"> | |
| 71 | − | <Hash size={16} className="shrink-0 text-faint" /> | |
| 89 | + | {channel.private ? ( | |
| 90 | + | <Lock size={15} className="shrink-0 text-faint" aria-label="Private" /> | |
| 91 | + | ) : ( | |
| 92 | + | <Hash size={16} className="shrink-0 text-faint" aria-label="Public" /> | |
| 93 | + | )} | |
| 72 | 94 | <div className="min-w-0 grow"> | |
| 73 | 95 | <Link to={channelPath(loaderData.slug, channel)} className="font-medium hover:text-accent"> | |
| 74 | 96 | {channel.name} | |
| 75 | 97 | </Link> | |
| 98 | + | {channel.private && <span className="ml-2 text-xs text-faint">Private</span>} | |
| 76 | 99 | {channel.topic && <p className="truncate text-sm text-muted">{channel.topic}</p>} | |
| 77 | 100 | </div> | |
| 78 | − | {isIn ? ( | |
| 101 | + | {loaderData.archived ? ( | |
| 102 | + | <span className="text-xs text-faint">Archived</span> | |
| 103 | + | ) : isIn ? ( | |
| 79 | 104 | <span className="text-xs text-faint">Joined</span> | |
| 80 | 105 | ) : ( | |
| 81 | 106 | <button | |
| 30 | 30 | older: string | null; | |
| 31 | 31 | /** Whether the viewer is in it: a public channel can be read before joining. */ | |
| 32 | 32 | joined: boolean; | |
| 33 | + | /** Whether the viewer may rename, archive and unarchive it (the workspace's chat settings). */ | |
| 34 | + | can_manage?: boolean; | |
| 33 | 35 | }; | |
| 34 | 36 | ||
| 35 | 37 | /** | |
| ⋯ | |||
| 84 | 86 | messages: history.ok ? history.value.messages : [], | |
| 85 | 87 | older: history.ok ? history.value.older : null, | |
| 86 | 88 | joined, | |
| 89 | + | can_manage: detail.value.can_manage === true, | |
| 87 | 90 | }; | |
| 88 | 91 | } | |
| 89 | 92 | ||
| 38 | 38 | } from "../../components/ui/dropdown-menu"; | |
| 39 | 39 | import { Hint } from "../../components/ui/hint"; | |
| 40 | 40 | import { forgetWorkspace } from "../../lib/workspace-choice"; | |
| 41 | − | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 41 | + | import { Select, SelectContent, SelectField, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 42 | 42 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; | |
| 43 | 43 | import { inviteLink, inviteState, moreInvitesMailto, workspaceInviteCopy } from "../../lib/invites"; | |
| 44 | 44 | import { registrationMode } from "../../lib/registration.server"; | |
| ⋯ | |||
| 397 | 397 | </div> | |
| 398 | 398 | <label className="block"> | |
| 399 | 399 | <span className="mb-1.5 block text-sm font-medium text-muted">Role</span> | |
| 400 | − | <select | |
| 400 | + | <SelectField | |
| 401 | 401 | name="role" | |
| 402 | 402 | defaultValue="member" | |
| 403 | − | className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim sm:w-auto" | |
| 404 | − | > | |
| 405 | − | <option value="member">Member</option> | |
| 406 | − | <option value="owner">Owner</option> | |
| 407 | − | </select> | |
| 403 | + | className="h-auto w-full py-2 sm:w-auto sm:min-w-32" | |
| 404 | + | options={[ | |
| 405 | + | { value: "member", label: "Member" }, | |
| 406 | + | { value: "owner", label: "Owner" }, | |
| 407 | + | ]} | |
| 408 | + | /> | |
| 408 | 409 | </label> | |
| 409 | 410 | <div className="sm:pt-[1.625rem]"> | |
| 410 | 411 | <SubmitButton match={{ action: "add" }} pending="Inviting…"> | |
| 6 | 6 | import { host } from "../../components/deploy"; | |
| 7 | 7 | import { PinButton } from "../../components/pin-button"; | |
| 8 | 8 | import { EmptyState, Pill, TimeAgo, notACredential } from "../../components/ui"; | |
| 9 | + | import { SelectField } from "../../components/ui/select"; | |
| 9 | 10 | import { page as pageMeta } from "../../lib/meta"; | |
| 10 | 11 | import { | |
| 11 | 12 | LANGUAGE_OF, | |
| ⋯ | |||
| 85 | 86 | ||
| 86 | 87 | const CONTROL = | |
| 87 | 88 | "h-9 rounded-md border border-line bg-surface px-2.5 text-sm text-fg outline-none transition-colors hover:border-line-strong focus:border-accent-dim"; | |
| 89 | + | /** A filter's select, on the same surface as the other controls. */ | |
| 90 | + | const FILTER = "w-auto bg-surface"; | |
| 88 | 91 | ||
| 89 | 92 | /** Its language, its kind when it is not an app, and its state, as small words. */ | |
| 90 | 93 | function Facts({ project }: { project: Item }) { | |
| ⋯ | |||
| 331 | 334 | / | |
| 332 | 335 | </kbd> | |
| 333 | 336 | </div> | |
| 334 | − | <select name="visibility" aria-label="Visibility" defaultValue={query.visibility} onChange={() => apply()} className={CONTROL}> | |
| 335 | − | <option value="all">All visibility</option> | |
| 336 | − | <option value="public">{option("Public", facets.visibility.public)}</option> | |
| 337 | − | <option value="private">{option("Private", facets.visibility.private)}</option> | |
| 338 | − | </select> | |
| 339 | − | <select name="kind" aria-label="Kind" defaultValue={query.kind} onChange={() => apply()} className={CONTROL}> | |
| 340 | − | <option value="all">All kinds</option> | |
| 341 | − | <option value="app">{option("Apps", facets.kind.app)}</option> | |
| 342 | − | <option value="library">{option("Libraries", facets.kind.library)}</option> | |
| 343 | − | {(facets.kind.tool > 0 || query.kind === "tool") && <option value="tool">{option("Tools", facets.kind.tool)}</option>} | |
| 344 | − | {(facets.kind.docs > 0 || query.kind === "docs") && <option value="docs">{option("Docs", facets.kind.docs)}</option>} | |
| 345 | − | {(facets.kind.other > 0 || query.kind === "other") && <option value="other">{option("Other", facets.kind.other)}</option>} | |
| 346 | − | </select> | |
| 337 | + | <SelectField | |
| 338 | + | key={`visibility-${query.visibility}`} | |
| 339 | + | name="visibility" | |
| 340 | + | aria-label="Visibility" | |
| 341 | + | defaultValue={query.visibility} | |
| 342 | + | afterChange={() => apply()} | |
| 343 | + | className={FILTER} | |
| 344 | + | options={[ | |
| 345 | + | { value: "all", label: "All visibility" }, | |
| 346 | + | { value: "public", label: option("Public", facets.visibility.public) }, | |
| 347 | + | { value: "private", label: option("Private", facets.visibility.private) }, | |
| 348 | + | ]} | |
| 349 | + | /> | |
| 350 | + | <SelectField | |
| 351 | + | key={`kind-${query.kind}`} | |
| 352 | + | name="kind" | |
| 353 | + | aria-label="Kind" | |
| 354 | + | defaultValue={query.kind} | |
| 355 | + | afterChange={() => apply()} | |
| 356 | + | className={FILTER} | |
| 357 | + | options={[ | |
| 358 | + | { value: "all", label: "All kinds" }, | |
| 359 | + | { value: "app", label: option("Apps", facets.kind.app) }, | |
| 360 | + | { value: "library", label: option("Libraries", facets.kind.library) }, | |
| 361 | + | ...(facets.kind.tool > 0 || query.kind === "tool" ? [{ value: "tool", label: option("Tools", facets.kind.tool) }] : []), | |
| 362 | + | ...(facets.kind.docs > 0 || query.kind === "docs" ? [{ value: "docs", label: option("Docs", facets.kind.docs) }] : []), | |
| 363 | + | ...(facets.kind.other > 0 || query.kind === "other" ? [{ value: "other", label: option("Other", facets.kind.other) }] : []), | |
| 364 | + | ]} | |
| 365 | + | /> | |
| 347 | 366 | {(facets.languages.length > 0 || query.language) && ( | |
| 348 | − | <select name="language" aria-label="Language" defaultValue={query.language ?? ""} onChange={() => apply()} className={CONTROL}> | |
| 349 | − | <option value="">Any language</option> | |
| 350 | − | {facets.languages.map((language) => ( | |
| 351 | − | <option key={language.name} value={language.name}> | |
| 352 | − | {option(language.name, language.count)} | |
| 353 | − | </option> | |
| 354 | − | ))} | |
| 355 | − | </select> | |
| 367 | + | <SelectField | |
| 368 | + | key={`language-${query.language ?? ""}`} | |
| 369 | + | name="language" | |
| 370 | + | aria-label="Language" | |
| 371 | + | defaultValue={query.language ?? ""} | |
| 372 | + | afterChange={() => apply()} | |
| 373 | + | className={FILTER} | |
| 374 | + | options={[ | |
| 375 | + | { value: "", label: "Any language" }, | |
| 376 | + | ...facets.languages.map((language) => ({ value: language.name, label: option(language.name, language.count) })), | |
| 377 | + | ]} | |
| 378 | + | /> | |
| 356 | 379 | )} | |
| 357 | 380 | {(facets.archived > 0 || query.archived !== "hide") && ( | |
| 358 | − | <select name="archived" aria-label="Archived" defaultValue={query.archived} onChange={() => apply()} className={CONTROL}> | |
| 359 | − | <option value="hide">Without archived</option> | |
| 360 | − | <option value="include">With archived</option> | |
| 361 | − | <option value="only">{option("Only archived", facets.archived)}</option> | |
| 362 | − | </select> | |
| 381 | + | <SelectField | |
| 382 | + | key={`archived-${query.archived}`} | |
| 383 | + | name="archived" | |
| 384 | + | aria-label="Archived" | |
| 385 | + | defaultValue={query.archived} | |
| 386 | + | afterChange={() => apply()} | |
| 387 | + | className={FILTER} | |
| 388 | + | options={[ | |
| 389 | + | { value: "hide", label: "Without archived" }, | |
| 390 | + | { value: "include", label: "With archived" }, | |
| 391 | + | { value: "only", label: option("Only archived", facets.archived) }, | |
| 392 | + | ]} | |
| 393 | + | /> | |
| 363 | 394 | )} | |
| 364 | 395 | {member && ( | |
| 365 | 396 | <label className={`${CONTROL} flex cursor-pointer items-center gap-2 select-none`}> | |
| ⋯ | |||
| 414 | 445 | )} | |
| 415 | 446 | <div className="ml-auto flex items-center gap-2"> | |
| 416 | 447 | {/* Part of the filters' form, beside the view it orders. */} | |
| 417 | − | <select form="project-filters" name="sort" aria-label="Sort" defaultValue={query.sort} onChange={() => apply()} className={`${CONTROL} h-8`}> | |
| 418 | − | {SORTS.map((sort) => ( | |
| 419 | − | <option key={sort.value} value={sort.value}> | |
| 420 | − | {sort.label} | |
| 421 | − | </option> | |
| 422 | − | ))} | |
| 423 | − | </select> | |
| 448 | + | <SelectField | |
| 449 | + | key={`sort-${query.sort}`} | |
| 450 | + | form="project-filters" | |
| 451 | + | name="sort" | |
| 452 | + | aria-label="Sort" | |
| 453 | + | defaultValue={query.sort} | |
| 454 | + | afterChange={() => apply()} | |
| 455 | + | size="sm" | |
| 456 | + | className={FILTER} | |
| 457 | + | align="end" | |
| 458 | + | options={SORTS.map((sort) => ({ value: sort.value, label: sort.label }))} | |
| 459 | + | /> | |
| 424 | 460 | <nav aria-label="View" className="flex items-center gap-0.5 rounded-lg border border-line bg-bg p-0.5"> | |
| 425 | 461 | {( | |
| 426 | 462 | [ | |
| 2538 | 2538 | pub repos: Vec<AuthoredRepo>, | |
| 2539 | 2539 | } | |
| 2540 | 2540 | ||
| 2541 | + | /// How many days `contributions` covers: today and the 364 before it. | |
| 2542 | + | pub const CONTRIBUTION_DAYS: u64 = 365; | |
| 2543 | + | ||
| 2544 | + | /// `contributions`: what a person did each day of the last year (issues | |
| 2545 | + | /// and pull requests opened, reviews given), only on repositories `viewer` | |
| 2546 | + | /// may read, for the calendar on their profile. Returns | |
| 2547 | + | /// `Outcome<Contributions>`; not found for an account that does not exist. | |
| 2548 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 2549 | + | pub struct ContributionsArgs { | |
| 2550 | + | pub username: String, | |
| 2551 | + | pub viewer: Viewer, | |
| 2552 | + | } | |
| 2553 | + | ||
| 2554 | + | /// One day with something on it. Days with nothing are left out. | |
| 2555 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 2556 | + | pub struct ContributionDay { | |
| 2557 | + | /// `YYYY-MM-DD`, UTC. | |
| 2558 | + | pub date: String, | |
| 2559 | + | pub count: u32, | |
| 2560 | + | } | |
| 2561 | + | ||
| 2562 | + | /// A person's year, as far as the viewer may see. | |
| 2563 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 2564 | + | pub struct Contributions { | |
| 2565 | + | /// Oldest first. | |
| 2566 | + | pub days: Vec<ContributionDay>, | |
| 2567 | + | pub total: u32, | |
| 2568 | + | /// The first day counted, `YYYY-MM-DD`; the last is today. | |
| 2569 | + | pub from: String, | |
| 2570 | + | } | |
| 2571 | + | ||
| 2541 | 2572 | #[cfg(test)] | |
| 2542 | 2573 | mod required_tests { | |
| 2543 | 2574 | use super::*; |
| 151 | 151 | can_manage: boolean; | |
| 152 | 152 | }; | |
| 153 | 153 | ||
| 154 | + | /** Who may do something in a workspace's chat: every member, or only its owners. */ | |
| 155 | + | export type ChatAllowed = "members" | "owners"; | |
| 156 | + | ||
| 157 | + | /** | |
| 158 | + | * Who may rename and archive a channel: its owners (whoever made it) and | |
| 159 | + | * the workspace's owners (the default), or the workspace's owners only. | |
| 160 | + | */ | |
| 161 | + | export type ChannelManagers = "channel_owners" | "owners"; | |
| 162 | + | ||
| 163 | + | /** | |
| 164 | + | * A workspace's chat settings, which its owners choose (Workspace, | |
| 165 | + | * Settings, Chat). The chat service enforces each one; the page only | |
| 166 | + | * hides what the viewer may not do. | |
| 167 | + | */ | |
| 168 | + | export type ChatSettings = { | |
| 169 | + | /** Who may create public channels. */ | |
| 170 | + | public_channels: ChatAllowed; | |
| 171 | + | /** Who may create private channels. */ | |
| 172 | + | private_channels: ChatAllowed; | |
| 173 | + | /** Who may rename, archive and unarchive channels. */ | |
| 174 | + | manage_channels: ChannelManagers; | |
| 175 | + | /** Who may add custom emoji (`admins` is the workspace's owners). */ | |
| 176 | + | emoji_upload: EmojiUpload; | |
| 177 | + | /** | |
| 178 | + | * The public channels someone new is put in the first time they open | |
| 179 | + | * Chat, by id. #general unless the owners chose otherwise. | |
| 180 | + | */ | |
| 181 | + | default_channels: string[]; | |
| 182 | + | }; | |
| 183 | + | ||
| 184 | + | /** What the viewer may do in a workspace's chat, from its settings and their role. */ | |
| 185 | + | export type ChatPermissions = { | |
| 186 | + | create_public_channels: boolean; | |
| 187 | + | create_private_channels: boolean; | |
| 188 | + | add_emoji: boolean; | |
| 189 | + | /** Whether they may change the workspace's chat settings: owners only. */ | |
| 190 | + | manage_settings: boolean; | |
| 191 | + | }; | |
| 192 | + | ||
| 193 | + | /** The chat settings page: the settings, what the viewer may do, and the public channels to choose defaults from. */ | |
| 194 | + | export type ChatSettingsView = { | |
| 195 | + | settings: ChatSettings; | |
| 196 | + | can: ChatPermissions; | |
| 197 | + | channels: Channel[]; | |
| 198 | + | }; | |
| 199 | + | ||
| 200 | + | /** A change to a channel: its name, its topic, or whether it is archived. Anything left out stays. */ | |
| 201 | + | export type ChannelChange = { name?: string; topic?: string | null; archived?: boolean }; | |
| 202 | + | ||
| 154 | 203 | /** An image for a new emoji, as base64. Its type is read from its bytes, never taken from here. */ | |
| 155 | 204 | export type EmojiFile = { data: string }; | |
| 156 | 205 | ||
| ⋯ | |||
| 175 | 224 | entries: ChatSidebarEntry[]; | |
| 176 | 225 | /** Public channels in the workspace the viewer has not joined. */ | |
| 177 | 226 | browsable: number; | |
| 227 | + | /** What the viewer may do, from the workspace's chat settings: the page hides what they may not. */ | |
| 228 | + | can: ChatPermissions; | |
| 178 | 229 | }; | |
| 179 | 230 | ||
| 180 | 231 | export type MessagePage = { | |
| ⋯ | |||
| 248 | 299 | | { type: "message.deleted"; channel_id: string; id: string } | |
| 249 | 300 | | { type: "typing"; channel_id: string; member: MemberProfile; until: string } | |
| 250 | 301 | | { type: "read"; channel_id: string; principal: Principal; last_read_id: string } | |
| 302 | + | | { type: "channel.updated"; channel: Channel } | |
| 251 | 303 | | { type: "reaction.added" | "reaction.removed"; channel_id: string; message_id: string; emoji: string; member: MemberProfile }; | |
| 252 | 304 | ||
| 253 | 305 | /** | |
| ⋯ | |||
| 258 | 310 | */ | |
| 259 | 311 | export const CHAT_VIEWER_HEADER = "x-g1t-chat-viewer"; | |
| 260 | 312 | ||
| 313 | + | /** A channel with its members, and whether the viewer may rename or archive it. */ | |
| 314 | + | export type ChannelDetail = { channel: Channel; members: ChannelMember[]; can_manage: boolean }; | |
| 315 | + | ||
| 261 | 316 | export type ChatApi = { | |
| 262 | 317 | sidebar(workspace: string, viewer: User): Promise<Result<ChatSidebar>>; | |
| 263 | 318 | channel( | |
| 264 | 319 | workspace: string, | |
| 265 | 320 | channelId: string, | |
| 266 | 321 | viewer: User, | |
| 267 | − | ): Promise<Result<{ channel: Channel; members: ChannelMember[] }>>; | |
| 322 | + | ): Promise<Result<ChannelDetail>>; | |
| 268 | 323 | /** The same, found by its name in the workspace (`#general` or `general`), as the site's URLs name channels. */ | |
| 269 | 324 | channelByName( | |
| 270 | 325 | workspace: string, | |
| 271 | 326 | name: string, | |
| 272 | 327 | viewer: User, | |
| 273 | − | ): Promise<Result<{ channel: Channel; members: ChannelMember[] }>>; | |
| 274 | − | /** Public channels, for Browse channels. */ | |
| 275 | − | browse(workspace: string, viewer: User): Promise<Result<Channel[]>>; | |
| 328 | + | ): Promise<Result<ChannelDetail>>; | |
| 329 | + | /** | |
| 330 | + | * Browse channels: every public channel, and the private ones the viewer | |
| 331 | + | * is in. With `archived`, the archived ones instead. | |
| 332 | + | */ | |
| 333 | + | browse(workspace: string, viewer: User, options?: { archived?: boolean }): Promise<Result<Channel[]>>; | |
| 334 | + | /** Makes a channel. Who may make a public or a private one is the workspace's setting. */ | |
| 276 | 335 | createChannel(workspace: string, viewer: User, input: NewChannel): Promise<Result<Channel>>; | |
| 277 | 336 | /** | |
| 337 | + | * Renames, archives or unarchives a channel (the workspace's | |
| 338 | + | * `manage_channels` setting says who may), or changes its topic (any | |
| 339 | + | * member). #general is never renamed or archived. Everyone looking at it | |
| 340 | + | * gets `channel.updated`. | |
| 341 | + | */ | |
| 342 | + | updateChannel(workspace: string, channelId: string, viewer: User, change: ChannelChange): Promise<Result<Channel>>; | |
| 343 | + | /** The workspace's chat settings, for any member; only owners may change them. */ | |
| 344 | + | chatSettings(workspace: string, viewer: User): Promise<Result<ChatSettingsView>>; | |
| 345 | + | /** Owners only: changes some of the workspace's chat settings, and answers all of them. */ | |
| 346 | + | setChatSettings(workspace: string, viewer: User, change: Partial<ChatSettings>): Promise<Result<ChatSettings>>; | |
| 347 | + | /** | |
| 278 | 348 | * The direct message between the viewer and these members, created on | |
| 279 | 349 | * first use. The same set of members always gets the same channel. | |
| 280 | 350 | */ | |
| ⋯ | |||
| 318 | 388 | agentId: string, | |
| 319 | 389 | message: AgentPostMessage, | |
| 320 | 390 | ): Promise<Result<ChatMessage>>; | |
| 391 | + | /** | |
| 392 | + | * Changes a message an agent posted: its body, its card, or both. Only | |
| 393 | + | * the agents service calls this, to keep a session's live card current. | |
| 394 | + | * Changing a message wakes nobody. | |
| 395 | + | */ | |
| 396 | + | updateAsAgent( | |
| 397 | + | workspace: string, | |
| 398 | + | channelId: string, | |
| 399 | + | agentId: string, | |
| 400 | + | id: string, | |
| 401 | + | change: { body?: string; card?: MessageCard | null }, | |
| 402 | + | ): Promise<Result<ChatMessage>>; | |
| 321 | 403 | /** Shows "is typing" for an agent while it works on a reply. */ | |
| 322 | 404 | agentTyping(workspace: string, channelId: string, agentId: string): Promise<Result<null>>; | |
| 323 | 405 | /** | |
| ⋯ | |||
| 398 | 480 | sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }), | |
| 399 | 481 | channel: (workspace, channelId, viewer) => call("channel", { workspace, channel_id: channelId, viewer }), | |
| 400 | 482 | channelByName: (workspace, name, viewer) => call("channel_by_name", { workspace, name, viewer }), | |
| 401 | − | browse: (workspace, viewer) => call("browse", { workspace, viewer }), | |
| 483 | + | browse: (workspace, viewer, options) => call("browse", { workspace, viewer, archived: options?.archived ?? false }), | |
| 402 | 484 | createChannel: (workspace, viewer, input) => call("create_channel", { workspace, viewer, input }), | |
| 485 | + | updateChannel: (workspace, channelId, viewer, change) => call("update_channel", { workspace, channel_id: channelId, viewer, change }), | |
| 486 | + | chatSettings: (workspace, viewer) => call("chat_settings", { workspace, viewer }), | |
| 487 | + | setChatSettings: (workspace, viewer, change) => call("set_chat_settings", { workspace, viewer, change }), | |
| 403 | 488 | openDm: (workspace, viewer, members) => call("open_dm", { workspace, viewer, members }), | |
| 404 | 489 | join: (workspace, channelId, viewer) => call("join", { workspace, channel_id: channelId, viewer }), | |
| 405 | 490 | leave: (workspace, channelId, viewer) => call("leave", { workspace, channel_id: channelId, viewer }), | |
| ⋯ | |||
| 423 | 508 | call("set_preferences", { workspace, channel_id: channelId, viewer, prefs }), | |
| 424 | 509 | postAsAgent: (workspace, channelId, agentId, message) => | |
| 425 | 510 | call("post_as_agent", { workspace, channel_id: channelId, agent_id: agentId, message }), | |
| 511 | + | updateAsAgent: (workspace, channelId, agentId, id, change) => | |
| 512 | + | call("update_as_agent", { workspace, channel_id: channelId, agent_id: agentId, id, change }), | |
| 426 | 513 | agentTyping: (workspace, channelId, agentId) => | |
| 427 | 514 | call("agent_typing", { workspace, channel_id: channelId, agent_id: agentId }), | |
| 428 | 515 | audience: (workspace, channelId) => call("audience", { workspace, channel_id: channelId }), | |
| 530 | 530 | call("merge_pull", { actor, repo, number, ...options }), | |
| 531 | 531 | listActivePulls: (viewer) => call("list_active_pulls", { viewer }), | |
| 532 | 532 | byAuthor: (username, viewer, filter = {}) => call("by_author", { username, viewer, ...filter }), | |
| 533 | + | contributions: (username, viewer) => call("contributions", { username, viewer }), | |
| 533 | 534 | startPlan: (actor, repo, brief) => call("start_plan", { actor, repo, brief }), | |
| 534 | 535 | failPlan: (planId, token, error) => call("report_plan", { planId, token, error }), | |
| 535 | 536 | getPlan: (repo, viewer, id) => call("get_plan", { repo, viewer, id }), |
| 1066 | 1066 | * on repositories the viewer may read. Not found for no such account. | |
| 1067 | 1067 | */ | |
| 1068 | 1068 | byAuthor(username: string, viewer: Viewer, filter?: AuthoredFilter): Promise<Result<Authored>>; | |
| 1069 | + | /** | |
| 1070 | + | * What a person did each day of the last year (issues and pull requests | |
| 1071 | + | * opened, reviews given), only on repositories the viewer may read: the | |
| 1072 | + | * calendar on their profile. Not found for no such account. | |
| 1073 | + | */ | |
| 1074 | + | contributions(username: string, viewer: Viewer): Promise<Result<Contributions>>; | |
| 1069 | 1075 | ||
| 1070 | 1076 | /** | |
| 1071 | 1077 | * Records an outcome to plan for. Members only. For the runner service, | |
| ⋯ | |||
| 1282 | 1288 | /** The repositories they worked in that the viewer may read, most work first. */ | |
| 1283 | 1289 | repos: { repo: RepoPath; count: number }[]; | |
| 1284 | 1290 | }; | |
| 1291 | + | ||
| 1292 | + | // Mirrors `Contributions` in `crates/contracts/src/work.rs`. | |
| 1293 | + | ||
| 1294 | + | /** How many days `contributions` covers: today and the 364 before it. */ | |
| 1295 | + | export const CONTRIBUTION_DAYS = 365; | |
| 1296 | + | ||
| 1297 | + | /** One day with something on it; days with nothing are left out. */ | |
| 1298 | + | export type ContributionDay = { /** `YYYY-MM-DD`, UTC. */ date: string; count: number }; | |
| 1299 | + | ||
| 1300 | + | /** A person's year, as far as the viewer may see. */ | |
| 1301 | + | export type Contributions = { | |
| 1302 | + | /** Oldest first. */ | |
| 1303 | + | days: ContributionDay[]; | |
| 1304 | + | total: number; | |
| 1305 | + | /** The first day counted, `YYYY-MM-DD`; the last is today (UTC). */ | |
| 1306 | + | from: string; | |
| 1307 | + | }; | |
| 1 | + | -- What a workspace's owners decide members may do in chat (src/settings.ts). | |
| 2 | + | -- Every column has the default a workspace without a row gets, so | |
| 3 | + | -- existing rows (made for `emoji_upload`) keep working unchanged. | |
| 4 | + | ||
| 5 | + | -- Who may create public channels, and private ones: every member, or only owners. | |
| 6 | + | ALTER TABLE chat_settings ADD COLUMN public_channels TEXT NOT NULL DEFAULT 'members' CHECK (public_channels IN ('members', 'owners')); | |
| 7 | + | ALTER TABLE chat_settings ADD COLUMN private_channels TEXT NOT NULL DEFAULT 'members' CHECK (private_channels IN ('members', 'owners')); | |
| 8 | + | ||
| 9 | + | -- Who may rename, archive and unarchive a channel: its owners and the | |
| 10 | + | -- workspace's owners, or the workspace's owners only. | |
| 11 | + | ALTER TABLE chat_settings ADD COLUMN manage_channels TEXT NOT NULL DEFAULT 'channel_owners' CHECK (manage_channels IN ('channel_owners', 'owners')); | |
| 12 | + | ||
| 13 | + | -- The public channels someone new is put in the first time they open | |
| 14 | + | -- Chat, as a JSON array of channel ids. NULL: #general. | |
| 15 | + | ALTER TABLE chat_settings ADD COLUMN default_channels TEXT; |
| 28 | 28 | type AgentPostMessage, | |
| 29 | 29 | type AskerAccess, | |
| 30 | 30 | type Channel, | |
| 31 | + | type ChannelChange, | |
| 32 | + | type ChannelDetail, | |
| 31 | 33 | type ChannelMember, | |
| 32 | 34 | type ChatAudience, | |
| 33 | 35 | type ChatLiveEvent, | |
| ⋯ | |||
| 37 | 39 | type EmojiFile, | |
| 38 | 40 | type EmojiList, | |
| 39 | 41 | type EmojiUpload, | |
| 42 | + | type ChatSettings, | |
| 43 | + | type ChatSettingsView, | |
| 40 | 44 | type ChatSidebar, | |
| 41 | 45 | type ChatSidebarEntry, | |
| 42 | 46 | ||
| ⋯ | |||
| 74 | 78 | import { AGENT_TYPING_MS, historyOf, historySize, messageBody, meterDay, pageOf, pageSize } from "./messages.ts"; | |
| 75 | 79 | import { GENERAL, MAX_DM_MEMBERS, channelName, dmKey, dmMembers } from "./names.ts"; | |
| 76 | 80 | import { ROOM_MEMBER_HEADER, type ChannelRoom, type RoomMember } from "./room.ts"; | |
| 81 | + | import { mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf, type SettingsRow } from "./settings.ts"; | |
| 77 | 82 | import { dmTitle, sidebarOrder, tally, type UnreadRow } from "./unread.ts"; | |
| 78 | 83 | // Live notifications and counts (services/notify). | |
| 79 | 84 | import { notifyMessage, notifyMuted, notifyRead } from "./notify.ts"; | |
| ⋯ | |||
| 232 | 237 | private readonly people = new Map<string, Promise<Map<string, Member>>>(); | |
| 233 | 238 | private readonly usernames = new Map<string, string>(); | |
| 234 | 239 | private readonly agents = new Map<string, WorkspaceAgent | null>(); | |
| 240 | + | private readonly settingsRows = new Map<string, Promise<SettingsRow | null>>(); | |
| 235 | 241 | ||
| 236 | 242 | /** `defer` runs work after the answer is sent: the request's waitUntil. */ | |
| 237 | 243 | constructor( | |
| ⋯ | |||
| 505 | 511 | .prepare("INSERT OR IGNORE INTO general_joined (workspace_id, principal, joined_at) VALUES (?, ?, ?)") | |
| 506 | 512 | .bind(workspace.id, me, at), | |
| 507 | 513 | ]; | |
| 508 | − | if (general && !general.private && !general.archived_at) { | |
| 509 | − | statements.push(this.joinStatement(general.id, me, general.created_by === me ? "owner" : "member", at)); | |
| 514 | + | // The workspace's default channels (#general unless its owners chose | |
| 515 | + | // others): public and not archived only, whatever was kept. | |
| 516 | + | const settings = await this.settings(workspace, general?.id ?? null); | |
| 517 | + | const defaults = settings.default_channels.length | |
| 518 | + | ? await this.db | |
| 519 | + | .prepare( | |
| 520 | + | "SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL AND id IN (SELECT value FROM json_each(?))", | |
| 521 | + | ) | |
| 522 | + | .bind(workspace.id, JSON.stringify(settings.default_channels)) | |
| 523 | + | .all<ChannelRow>() | |
| 524 | + | : { results: [] as ChannelRow[] }; | |
| 525 | + | for (const channel of defaults.results) { | |
| 526 | + | statements.push(this.joinStatement(channel.id, me, channel.created_by === me ? "owner" : "member", at)); | |
| 510 | 527 | } | |
| 511 | 528 | await this.db.batch(statements); | |
| 512 | 529 | } | |
| ⋯ | |||
| 598 | 615 | mentions: count.mentions, | |
| 599 | 616 | }; | |
| 600 | 617 | }); | |
| 601 | − | return ok({ entries: sidebarOrder(entries), browsable: browsable?.n ?? 0 }); | |
| 618 | + | const settings = await this.settings(workspace); | |
| 619 | + | return ok({ entries: sidebarOrder(entries), browsable: browsable?.n ?? 0, can: permissionsFor(settings, roleOf(a.viewer, slug)) }); | |
| 602 | 620 | } | |
| 603 | 621 | ||
| 604 | 622 | // ── Channels ──────────────────────────────────────────────────────────── | |
| 605 | 623 | ||
| 606 | − | async channel(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<{ channel: Channel; members: ChannelMember[] }>> { | |
| 624 | + | async channel(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<ChannelDetail>> { | |
| 607 | 625 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); | |
| 608 | 626 | if (!found.ok) return found; | |
| 609 | − | const { slug, workspace, channel } = found.value; | |
| 610 | − | const rows = await this.db | |
| 611 | − | .prepare("SELECT * FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal") | |
| 612 | − | .bind(channel.id) | |
| 613 | − | .all<MemberRow>(); | |
| 627 | + | const { slug, workspace, channel, member } = found.value; | |
| 628 | + | const [rows, settings] = await Promise.all([ | |
| 629 | + | this.db | |
| 630 | + | .prepare("SELECT * FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal") | |
| 631 | + | .bind(channel.id) | |
| 632 | + | .all<MemberRow>(), | |
| 633 | + | this.settings(workspace), | |
| 634 | + | ]); | |
| 614 | 635 | const profiles = await this.profiles(slug, workspace, rows.results.map((r) => r.principal)); | |
| 615 | 636 | return ok({ | |
| 616 | 637 | channel: toChannel(channel), | |
| 638 | + | can_manage: channel.kind === "channel" && mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null), | |
| 617 | 639 | members: rows.results.map((row) => ({ | |
| 618 | 640 | channel_id: row.channel_id, | |
| 619 | 641 | member: profiles.get(row.principal)!, | |
| ⋯ | |||
| 627 | 649 | } | |
| 628 | 650 | ||
| 629 | 651 | /** A channel by its name, as the site's URLs name them; read like `channel`. */ | |
| 630 | − | async channelByName(a: { workspace: string; name: string; viewer: Viewer }): Promise<Result<{ channel: Channel; members: ChannelMember[] }>> { | |
| 652 | + | async channelByName(a: { workspace: string; name: string; viewer: Viewer }): Promise<Result<ChannelDetail>> { | |
| 631 | 653 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 632 | 654 | if (!found.ok) return found; | |
| 633 | 655 | const named = channelName(a.name ?? ""); | |
| ⋯ | |||
| 641 | 663 | return this.channel({ workspace: a.workspace, channel_id: row.id, viewer: a.viewer }); | |
| 642 | 664 | } | |
| 643 | 665 | ||
| 644 | − | async browse(a: { workspace: string; viewer: Viewer }): Promise<Result<Channel[]>> { | |
| 666 | + | /** | |
| 667 | + | * Every public channel, and the private ones the viewer is in; a private | |
| 668 | + | * one they are not in stays unseen. With `archived`, the archived ones. | |
| 669 | + | */ | |
| 670 | + | async browse(a: { workspace: string; viewer: Viewer; archived?: boolean }): Promise<Result<Channel[]>> { | |
| 645 | 671 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 646 | 672 | if (!found.ok) return found; | |
| 647 | 673 | const rows = await this.db | |
| 648 | 674 | .prepare( | |
| 649 | − | "SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL ORDER BY name", | |
| 675 | + | `SELECT * FROM channels c | |
| 676 | + | WHERE c.workspace_id = ?1 AND c.kind = 'channel' | |
| 677 | + | AND (c.archived_at IS NULL) = (?3 = 0) | |
| 678 | + | AND (c.private = 0 OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2)) | |
| 679 | + | ORDER BY c.name`, | |
| 650 | 680 | ) | |
| 651 | − | .bind(found.value.id) | |
| 681 | + | .bind(found.value.id, userKey(a.viewer!), a.archived === true ? 1 : 0) | |
| 652 | 682 | .all<ChannelRow>(); | |
| 653 | 683 | return ok(rows.results.map(toChannel)); | |
| 654 | 684 | } | |
| ⋯ | |||
| 661 | 691 | if (!named.ok) return fail("invalid", named.message); | |
| 662 | 692 | const topic = typeof a.input?.topic === "string" ? a.input.topic.trim() : ""; | |
| 663 | 693 | if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`); | |
| 694 | + | const isPrivate = !!a.input?.private; | |
| 695 | + | if (!mayCreateChannel(await this.settings(workspace), roleOf(a.viewer, a.workspace), isPrivate)) { | |
| 696 | + | return fail("forbidden", `Only owners can create ${isPrivate ? "private" : "public"} channels in this workspace.`); | |
| 697 | + | } | |
| 664 | 698 | const taken = await this.db | |
| 665 | 699 | .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ?") | |
| 666 | 700 | .bind(workspace.id, named.name) | |
| ⋯ | |||
| 673 | 707 | kind: "channel", | |
| 674 | 708 | name: named.name, | |
| 675 | 709 | topic: topic || null, | |
| 676 | − | private: a.input?.private ? 1 : 0, | |
| 710 | + | private: isPrivate ? 1 : 0, | |
| 677 | 711 | dm_key: null, | |
| 678 | 712 | created_by: me, | |
| 679 | 713 | created_at: now(), | |
| ⋯ | |||
| 811 | 845 | return ok(null); | |
| 812 | 846 | } | |
| 813 | 847 | ||
| 848 | + | // ── What owners decide (src/settings.ts) ──────────────────────────────── | |
| 849 | + | ||
| 850 | + | /** The row of a workspace's chat settings, read once per request. */ | |
| 851 | + | private settingsRow(workspace: Workspace): Promise<SettingsRow | null> { | |
| 852 | + | let found = this.settingsRows.get(workspace.id); | |
| 853 | + | if (!found) { | |
| 854 | + | found = this.db | |
| 855 | + | .prepare("SELECT emoji_upload, public_channels, private_channels, manage_channels, default_channels FROM chat_settings WHERE workspace_id = ?") | |
| 856 | + | .bind(workspace.id) | |
| 857 | + | .first<SettingsRow>(); | |
| 858 | + | this.settingsRows.set(workspace.id, found); | |
| 859 | + | } | |
| 860 | + | return found; | |
| 861 | + | } | |
| 862 | + | ||
| 863 | + | /** | |
| 864 | + | * A workspace's chat settings. Default channels never chosen are | |
| 865 | + | * #general, looked up unless `generalId` is given. | |
| 866 | + | */ | |
| 867 | + | private async settings(workspace: Workspace, generalId?: string | null): Promise<ChatSettings> { | |
| 868 | + | const row = await this.settingsRow(workspace); | |
| 869 | + | if (row?.default_channels != null || generalId !== undefined) return settingsOf(row, generalId ?? null); | |
| 870 | + | const general = await this.db | |
| 871 | + | .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ? AND kind = 'channel'") | |
| 872 | + | .bind(workspace.id, GENERAL) | |
| 873 | + | .first<{ id: string }>(); | |
| 874 | + | return settingsOf(row, general?.id ?? null); | |
| 875 | + | } | |
| 876 | + | ||
| 877 | + | async chatSettings(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSettingsView>> { | |
| 878 | + | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 879 | + | if (!found.ok) return found; | |
| 880 | + | const workspace = found.value; | |
| 881 | + | const [settings, channels] = await Promise.all([ | |
| 882 | + | this.settings(workspace), | |
| 883 | + | this.db | |
| 884 | + | .prepare("SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL ORDER BY name") | |
| 885 | + | .bind(workspace.id) | |
| 886 | + | .all<ChannelRow>(), | |
| 887 | + | ]); | |
| 888 | + | return ok({ settings, can: permissionsFor(settings, roleOf(a.viewer, a.workspace)), channels: channels.results.map(toChannel) }); | |
| 889 | + | } | |
| 890 | + | ||
| 891 | + | async setChatSettings(a: { workspace: string; viewer: Viewer; change: Partial<ChatSettings> }): Promise<Result<ChatSettings>> { | |
| 892 | + | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 893 | + | if (!found.ok) return found; | |
| 894 | + | const workspace = found.value; | |
| 895 | + | if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change the workspace's chat settings."); | |
| 896 | + | const checked = settingsChange(a.change); | |
| 897 | + | if (!checked.ok) return fail("invalid", checked.message); | |
| 898 | + | const change = checked.change; | |
| 899 | + | if (change.default_channels) { | |
| 900 | + | // Only the workspace's public channels that are not archived: a private | |
| 901 | + | // one would put people somewhere they were never invited. | |
| 902 | + | const rows = await this.db | |
| 903 | + | .prepare( | |
| 904 | + | "SELECT id FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL AND id IN (SELECT value FROM json_each(?))", | |
| 905 | + | ) | |
| 906 | + | .bind(workspace.id, JSON.stringify(change.default_channels)) | |
| 907 | + | .all<{ id: string }>(); | |
| 908 | + | const open = new Set(rows.results.map((r) => r.id)); | |
| 909 | + | if (change.default_channels.some((id) => !open.has(id))) return fail("invalid", "Default channels must be public channels that are not archived."); | |
| 910 | + | } | |
| 911 | + | const next: ChatSettings = { ...(await this.settings(workspace)), ...change }; | |
| 912 | + | const row = rowFor(next); | |
| 913 | + | await this.db | |
| 914 | + | .prepare( | |
| 915 | + | `INSERT INTO chat_settings (workspace_id, emoji_upload, public_channels, private_channels, manage_channels, default_channels) | |
| 916 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6) | |
| 917 | + | ON CONFLICT (workspace_id) DO UPDATE SET emoji_upload = ?2, public_channels = ?3, private_channels = ?4, manage_channels = ?5, default_channels = ?6`, | |
| 918 | + | ) | |
| 919 | + | .bind(workspace.id, row.emoji_upload, row.public_channels, row.private_channels, row.manage_channels, row.default_channels) | |
| 920 | + | .run(); | |
| 921 | + | this.settingsRows.delete(workspace.id); | |
| 922 | + | return ok(next); | |
| 923 | + | } | |
| 924 | + | ||
| 925 | + | /** | |
| 926 | + | * Renames, archives or unarchives a channel, or changes its topic. The | |
| 927 | + | * workspace's `manage_channels` setting says who may do the first three; | |
| 928 | + | * any member may change the topic. #general stays #general, and stays. | |
| 929 | + | */ | |
| 930 | + | async updateChannel(a: { workspace: string; channel_id: string; viewer: Viewer; change: ChannelChange }): Promise<Result<Channel>> { | |
| 931 | + | // Archived or not, a channel is found the same way; a private one only by its members. | |
| 932 | + | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); | |
| 933 | + | if (!found.ok) return found; | |
| 934 | + | const { slug, workspace, channel, member } = found.value; | |
| 935 | + | if (channel.kind === "dm") return fail("invalid", "A direct message has no name or topic to change."); | |
| 936 | + | const change = a.change ?? {}; | |
| 937 | + | const next: ChannelRow = { ...channel }; | |
| 938 | + | if (change.name !== undefined || change.archived !== undefined) { | |
| 939 | + | const settings = await this.settings(workspace); | |
| 940 | + | if (!mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null)) { | |
| 941 | + | return fail( | |
| 942 | + | "forbidden", | |
| 943 | + | settings.manage_channels === "owners" | |
| 944 | + | ? "Only workspace owners can rename or archive channels here." | |
| 945 | + | : "Only this channel's owners and workspace owners can rename or archive it.", | |
| 946 | + | ); | |
| 947 | + | } | |
| 948 | + | if (channel.name === GENERAL) return fail("invalid", "#general is where everyone is: it can't be renamed or archived."); | |
| 949 | + | } | |
| 950 | + | if (change.name !== undefined) { | |
| 951 | + | const named = channelName(String(change.name ?? "")); | |
| 952 | + | if (!named.ok) return fail("invalid", named.message); | |
| 953 | + | if (named.name !== channel.name) { | |
| 954 | + | const taken = await this.db | |
| 955 | + | .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ? AND id != ?") | |
| 956 | + | .bind(workspace.id, named.name, channel.id) | |
| 957 | + | .first(); | |
| 958 | + | if (taken || named.name === GENERAL) return fail("conflict", `#${named.name} already exists.`); | |
| 959 | + | } | |
| 960 | + | next.name = named.name; | |
| 961 | + | } | |
| 962 | + | if (change.topic !== undefined) { | |
| 963 | + | if (!member) return fail("forbidden", `Join #${channel.name} first.`); | |
| 964 | + | const topic = typeof change.topic === "string" ? change.topic.trim() : ""; | |
| 965 | + | if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`); | |
| 966 | + | next.topic = topic || null; | |
| 967 | + | } | |
| 968 | + | if (change.archived !== undefined) { | |
| 969 | + | next.archived_at = change.archived ? (channel.archived_at ?? now()) : null; | |
| 970 | + | } else if (channel.archived_at && (change.name !== undefined || change.topic !== undefined)) { | |
| 971 | + | return fail("invalid", "This channel is archived. Unarchive it first."); | |
| 972 | + | } | |
| 973 | + | try { | |
| 974 | + | await this.db | |
| 975 | + | .prepare("UPDATE channels SET name = ?, topic = ?, archived_at = ? WHERE id = ?") | |
| 976 | + | .bind(next.name, next.topic, next.archived_at, channel.id) | |
| 977 | + | .run(); | |
| 978 | + | } catch (error) { | |
| 979 | + | if (String(error).includes("UNIQUE")) return fail("conflict", `#${next.name} already exists.`); | |
| 980 | + | throw error; | |
| 981 | + | } | |
| 982 | + | const updated = toChannel(next); | |
| 983 | + | this.broadcast(channel.id, { type: "channel.updated", channel: updated }); | |
| 984 | + | return ok(updated); | |
| 985 | + | } | |
| 986 | + | ||
| 814 | 987 | // ── Messages ──────────────────────────────────────────────────────────── | |
| 815 | 988 | ||
| 816 | 989 | /** | |
| ⋯ | |||
| 1166 | 1339 | } | |
| 1167 | 1340 | ||
| 1168 | 1341 | /** | |
| 1342 | + | * Changes a message the agent posted (a session's live card, say): its | |
| 1343 | + | * body, its card, or both. Only the agent's own messages; it wakes | |
| 1344 | + | * nobody, and everyone in the conversation sees it change. | |
| 1345 | + | */ | |
| 1346 | + | async updateAsAgent(a: { | |
| 1347 | + | workspace: string; | |
| 1348 | + | channel_id: string; | |
| 1349 | + | agent_id: string; | |
| 1350 | + | id: string; | |
| 1351 | + | change: { body?: string; card?: MessageCard | null }; | |
| 1352 | + | }): Promise<Result<ChatMessage>> { | |
| 1353 | + | const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id); | |
| 1354 | + | if (!found.ok) return found; | |
| 1355 | + | const { place, agent } = found.value; | |
| 1356 | + | const row = await this.messageRow(place.channel.id, String(a.id ?? "")); | |
| 1357 | + | if (!row || row.deleted_at) return fail("not_found", "No such message."); | |
| 1358 | + | if (row.author !== principalKey({ kind: "agent", id: agent.id })) return fail("forbidden", "An agent can change only its own messages."); | |
| 1359 | + | const change = a.change ?? {}; | |
| 1360 | + | const card = change.card === undefined ? (row.card ? (JSON.parse(row.card) as MessageCard) : null) : change.card === null ? null : cleanCard(change.card); | |
| 1361 | + | if (change.card && !card) return fail("invalid", "A card needs a kind and a title."); | |
| 1362 | + | const body = messageBody(change.body === undefined ? row.body : change.body, !!card); | |
| 1363 | + | if (!body.ok) return fail("invalid", body.message); | |
| 1364 | + | const cardJson = card ? JSON.stringify(card) : null; | |
| 1365 | + | const at = now(); | |
| 1366 | + | // A card's live state is not an edit a person made: no "edited" mark for it. | |
| 1367 | + | const edited = change.body !== undefined && change.body !== row.body ? at : row.edited_at; | |
| 1368 | + | await this.db | |
| 1369 | + | .prepare("UPDATE messages SET body = ?, card = ?, kind = ?, edited_at = ? WHERE id = ?") | |
| 1370 | + | .bind(body.body, cardJson, card ? "card" : "text", edited, row.id) | |
| 1371 | + | .run(); | |
| 1372 | + | const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, card: cardJson, kind: card ? "card" : "text", edited_at: edited }]); | |
| 1373 | + | this.broadcast(place.channel.id, { type: "message.updated", message }); | |
| 1374 | + | return ok(message); | |
| 1375 | + | } | |
| 1376 | + | ||
| 1377 | + | /** | |
| 1169 | 1378 | * What an agent reads before replying, oldest first: a thread (its root, | |
| 1170 | 1379 | * then its latest replies), or the channel's latest top-level messages. | |
| 1171 | 1380 | * Only where the agent is a member, so it reads only what was said where | |
| ⋯ | |||
| 1421 | 1630 | // ── A workspace's own emoji (src/emoji.ts) ────────────────────────────── | |
| 1422 | 1631 | ||
| 1423 | 1632 | private async emojiUpload(workspace: Workspace): Promise<EmojiUpload> { | |
| 1424 | − | const row = await this.db.prepare("SELECT emoji_upload FROM chat_settings WHERE workspace_id = ?").bind(workspace.id).first<{ emoji_upload: EmojiUpload }>(); | |
| 1425 | − | return row?.emoji_upload === "admins" ? "admins" : "members"; | |
| 1633 | + | return settingsOf(await this.settingsRow(workspace)).emoji_upload; | |
| 1426 | 1634 | } | |
| 1427 | 1635 | ||
| 1428 | 1636 | private async toEmoji(slug: string, workspace: Workspace, rows: EmojiRow[]): Promise<CustomEmoji[]> { | |
| ⋯ | |||
| 1574 | 1782 | if (!found.ok) return found; | |
| 1575 | 1783 | if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change who adds emoji."); | |
| 1576 | 1784 | if (a.value !== "members" && a.value !== "admins") return fail("invalid", "Choose members or owners."); | |
| 1577 | − | await this.db | |
| 1578 | − | .prepare( | |
| 1579 | − | "INSERT INTO chat_settings (workspace_id, emoji_upload) VALUES (?1, ?2) ON CONFLICT (workspace_id) DO UPDATE SET emoji_upload = ?2", | |
| 1580 | − | ) | |
| 1581 | − | .bind(found.value.id, a.value) | |
| 1582 | − | .run(); | |
| 1583 | − | return ok(a.value); | |
| 1785 | + | // The same setting as Settings → Chat (`setChatSettings`). | |
| 1786 | + | const set = await this.setChatSettings({ workspace: a.workspace, viewer: a.viewer, change: { emoji_upload: a.value } }); | |
| 1787 | + | return set.ok ? ok(set.value.emoji_upload) : set; | |
| 1584 | 1788 | } | |
| 1585 | 1789 | ||
| 1586 | 1790 | // ── The live socket ───────────────────────────────────────────────────── | |
| ⋯ | |||
| 1638 | 1842 | return Response.json(await service.browse(args)); | |
| 1639 | 1843 | case "create_channel": | |
| 1640 | 1844 | return Response.json(await service.createChannel(args)); | |
| 1845 | + | case "update_channel": | |
| 1846 | + | return Response.json(await service.updateChannel(args)); | |
| 1847 | + | case "chat_settings": | |
| 1848 | + | return Response.json(await service.chatSettings(args)); | |
| 1849 | + | case "set_chat_settings": | |
| 1850 | + | return Response.json(await service.setChatSettings(args)); | |
| 1641 | 1851 | case "open_dm": | |
| 1642 | 1852 | return Response.json(await service.openDm(args)); | |
| 1643 | 1853 | case "join": | |
| ⋯ | |||
| 1660 | 1870 | return Response.json(await service.setPreferences(args)); | |
| 1661 | 1871 | case "post_as_agent": | |
| 1662 | 1872 | return Response.json(await service.postAsAgent(args)); | |
| 1873 | + | case "update_as_agent": | |
| 1874 | + | return Response.json(await service.updateAsAgent(args)); | |
| 1663 | 1875 | case "agent_typing": | |
| 1664 | 1876 | return Response.json(await service.agentTyping(args)); | |
| 1665 | 1877 | case "history_for_agent": | |
| 1 | + | import { test } from "node:test"; | |
| 2 | + | import assert from "node:assert/strict"; | |
| 3 | + | ||
| 4 | + | import { MAX_DEFAULT_CHANNELS, mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf } from "./settings.ts"; | |
| 5 | + | ||
| 6 | + | test("a workspace without settings lets every member make channels, and new people land in #general", () => { | |
| 7 | + | const settings = settingsOf(null, "chn_general"); | |
| 8 | + | assert.deepEqual(settings, { | |
| 9 | + | public_channels: "members", | |
| 10 | + | private_channels: "members", | |
| 11 | + | manage_channels: "channel_owners", | |
| 12 | + | emoji_upload: "members", | |
| 13 | + | default_channels: ["chn_general"], | |
| 14 | + | }); | |
| 15 | + | assert.deepEqual(settingsOf(null).default_channels, []); | |
| 16 | + | }); | |
| 17 | + | ||
| 18 | + | test("default channels once chosen are kept as chosen, even none", () => { | |
| 19 | + | const row = { emoji_upload: "admins", public_channels: "owners", private_channels: "members", manage_channels: "owners", default_channels: "[]" }; | |
| 20 | + | const settings = settingsOf(row, "chn_general"); | |
| 21 | + | assert.deepEqual(settings.default_channels, []); | |
| 22 | + | assert.equal(settings.emoji_upload, "admins"); | |
| 23 | + | assert.equal(settings.public_channels, "owners"); | |
| 24 | + | assert.equal(settings.manage_channels, "owners"); | |
| 25 | + | assert.deepEqual(settingsOf({ ...row, default_channels: '["a","b"]' }).default_channels, ["a", "b"]); | |
| 26 | + | // Anything unreadable is the defaults, never an error. | |
| 27 | + | assert.deepEqual(settingsOf({ ...row, default_channels: "{nope" }).default_channels, []); | |
| 28 | + | assert.equal(settingsOf({ ...row, public_channels: "everyone" }).public_channels, "members"); | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | test("who may make a channel follows its visibility's setting", () => { | |
| 32 | + | const settings = settingsOf({ emoji_upload: null, public_channels: "members", private_channels: "owners", manage_channels: null, default_channels: null }); | |
| 33 | + | assert.equal(mayCreateChannel(settings, "member", false), true); | |
| 34 | + | assert.equal(mayCreateChannel(settings, "member", true), false); | |
| 35 | + | assert.equal(mayCreateChannel(settings, "owner", true), true); | |
| 36 | + | assert.equal(mayCreateChannel(settings, null, false), false); | |
| 37 | + | }); | |
| 38 | + | ||
| 39 | + | test("renaming and archiving: workspace owners always, a channel's owners unless kept to workspace owners", () => { | |
| 40 | + | const open = settingsOf(null); | |
| 41 | + | assert.equal(mayManageChannel(open, "owner", null), true); | |
| 42 | + | assert.equal(mayManageChannel(open, "member", "owner"), true); | |
| 43 | + | assert.equal(mayManageChannel(open, "member", "member"), false); | |
| 44 | + | assert.equal(mayManageChannel(open, null, "owner"), false); | |
| 45 | + | const strict = { ...open, manage_channels: "owners" as const }; | |
| 46 | + | assert.equal(mayManageChannel(strict, "member", "owner"), false); | |
| 47 | + | assert.equal(mayManageChannel(strict, "owner", "member"), true); | |
| 48 | + | }); | |
| 49 | + | ||
| 50 | + | test("what a role may do, for the page", () => { | |
| 51 | + | const settings = { ...settingsOf(null), public_channels: "owners" as const, emoji_upload: "admins" as const }; | |
| 52 | + | assert.deepEqual(permissionsFor(settings, "member"), { | |
| 53 | + | create_public_channels: false, | |
| 54 | + | create_private_channels: true, | |
| 55 | + | add_emoji: false, | |
| 56 | + | manage_settings: false, | |
| 57 | + | }); | |
| 58 | + | assert.deepEqual(permissionsFor(settings, "owner"), { | |
| 59 | + | create_public_channels: true, | |
| 60 | + | create_private_channels: true, | |
| 61 | + | add_emoji: true, | |
| 62 | + | manage_settings: true, | |
| 63 | + | }); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test("a change is checked field by field; what is left out stays", () => { | |
| 67 | + | assert.deepEqual(settingsChange({ public_channels: "owners" }), { ok: true, change: { public_channels: "owners" } }); | |
| 68 | + | assert.deepEqual(settingsChange({ default_channels: ["a", "a", "b"] }), { ok: true, change: { default_channels: ["a", "b"] } }); | |
| 69 | + | assert.equal(settingsChange({ public_channels: "admins" }).ok, false); | |
| 70 | + | assert.equal(settingsChange({ manage_channels: "members" }).ok, false); | |
| 71 | + | assert.equal(settingsChange({ emoji_upload: "owners" }).ok, false); | |
| 72 | + | assert.equal(settingsChange({ default_channels: "general" }).ok, false); | |
| 73 | + | assert.equal(settingsChange({ default_channels: Array.from({ length: MAX_DEFAULT_CHANNELS + 1 }, (_, i) => `c${i}`) }).ok, false); | |
| 74 | + | assert.equal(settingsChange(null).ok, false); | |
| 75 | + | }); | |
| 76 | + | ||
| 77 | + | test("settings are kept with their default channels as JSON", () => { | |
| 78 | + | assert.deepEqual(rowFor({ ...settingsOf(null), default_channels: ["x"] }), { | |
| 79 | + | emoji_upload: "members", | |
| 80 | + | public_channels: "members", | |
| 81 | + | private_channels: "members", | |
| 82 | + | manage_channels: "channel_owners", | |
| 83 | + | default_channels: '["x"]', | |
| 84 | + | }); | |
| 85 | + | }); |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.