Skip to content
569 linesCodeBlameRaw
1import {
2 Activity,
3 BellOff,
4 BellRing,
5 BookOpen,
6 CircleUserRound,
7 Ellipsis,
8 Keyboard,
9 LayoutGrid,
10 LifeBuoy,
11 LogOut,
12 Moon,
13 Settings,
14 Smile,
15 Store,
16 Sun,
17} from "lucide-react";
18import { Dialog as Primitive } from "radix-ui";
19import { type ReactNode, useEffect, useRef, useState } from "react";
20import { Link, useLocation, useNavigate, useSubmit } from "react-router";
21
22import { type Abilities, type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
23
24import { appIcon } from "./apps";
25import { CountBadge, ShortcutsDialog } from "./dock";
26import { Mark } from "./logo";
27import { Avatar } from "./ui";
28import { TabStrip } from "./ui/tab-strip";
29import { StatusDialog } from "./presence";
30import { type AppKey, type PinnableApp, appOf } from "../lib/apps";
31import { setPresence, useOwnPresence } from "../lib/notify-client";
32import { dndOn, liveStatus, pauseUntil, untilLabel } from "../lib/presence";
33import { STATUS_URL } from "../lib/status";
34import { type ModeKey, modeHome, modeOf } from "../lib/workspace-nav";
35import { PROJECT_PAGE_LINKS, type ProjectPage, projectPageAt, projectPages } from "../lib/chrome";
36import { ROADMAP, type RoadmapItem } from "../lib/roadmap";
37
38/**
39 * The phone's layout (below 768px): a bottom bar of apps with More for
40 * the rest, the mode's sidebar as a drawer from the left, and each tab a
41 * list that pushes its detail full screen. A computer has the dock.
42 */
43
44/** Whether a path is one conversation, which takes the whole screen on a phone. */
45export function isConversation(pathname: string): boolean {
46 return /^\/[^/]+\/-\/chat\/(?!browse\/?$)[^/]+/.test(pathname);
47}
48
49/**
50 * Follows the visual viewport, which shrinks when the keyboard opens: its
51 * height and top go on the page as `--vv-height` and `--vv-top`, what
52 * the keyboard covers as `--keyboard-inset`, and
53 * `data-keyboard="open"` on <html> while the keyboard is up, so a
54 * composer can sit just above it and the tab bar can step aside.
55 */
56export function useVisualViewport() {
57 useEffect(() => {
58 const viewport = window.visualViewport;
59 if (!viewport) return;
60 const root = document.documentElement;
61 const update = () => {
62 root.style.setProperty("--vv-height", `${viewport.height}px`);
63 root.style.setProperty("--vv-top", `${viewport.offsetTop}px`);
64 // How much of the bottom the keyboard covers, for a sheet to sit on it.
65 root.style.setProperty("--keyboard-inset", `${Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)}px`);
66 const keyboard = window.innerHeight - viewport.height > 140;
67 if (keyboard) root.dataset.keyboard = "open";
68 else delete root.dataset.keyboard;
69 };
70 update();
71 viewport.addEventListener("resize", update);
72 viewport.addEventListener("scroll", update);
73 return () => {
74 viewport.removeEventListener("resize", update);
75 viewport.removeEventListener("scroll", update);
76 };
77 }, []);
78}
79
80/**
81 * Swipe from the left edge to go back, as on a phone's own apps: a touch
82 * that starts within 28px of the edge and travels 80px right, more across
83 * than down. Going back is the browser's own back, so history stays true.
84 */
85export function useSwipeBack(onBack: () => void) {
86 const start = useRef<{ x: number; y: number } | null>(null);
87 return {
88 onTouchStart: (event: React.TouchEvent) => {
89 const touch = event.touches[0];
90 start.current = touch && touch.clientX < 28 ? { x: touch.clientX, y: touch.clientY } : null;
91 },
92 onTouchMove: (event: React.TouchEvent) => {
93 const touch = event.touches[0];
94 if (!start.current || !touch) return;
95 const dx = touch.clientX - start.current.x;
96 const dy = Math.abs(touch.clientY - start.current.y);
97 if (dx > 80 && dx > dy * 1.5) {
98 start.current = null;
99 onBack();
100 }
101 },
102 onTouchEnd: () => {
103 start.current = null;
104 },
105 };
106}
107
108/** Back, as the browser would; to `fallback` when this page was the first one opened. */
109export function useBack(fallback: string) {
110 const navigate = useNavigate();
111 return () => {
112 const index = (window.history.state as { idx?: number } | null)?.idx ?? 0;
113 if (index > 0) navigate(-1);
114 else navigate(fallback, { replace: true });
115 };
116}
117
118type Tab = { key: ModeKey; label: string; badge?: { count: number; loud: boolean } };
119
120/**
121 * The bottom bar of a phone, floating 8px from the edges: Today, Chat,
122 * Notifications, Agents and Code (for a member with Code access), each
123 * with what is unread, and More. It steps aside while the keyboard is up
124 * and inside a conversation. The tab you are in, tapped again, opens its
125 * mode's menu (its sidebar).
126 */
127export function BottomBar({
128 user,
129 workspace,
130 pins,
131 unread,
132 onReselect,
133}: {
134 user: User;
135 workspace: Membership;
136 /** The apps pinned to this person's dock here, for the More sheet. */
137 pins: PinnableApp[];
138 unread: { notifications: number; chat: number; mentions: number };
139 /** Tapping the tab you are already in: the shell opens that mode's menu (its sidebar). */
140 onReselect?: () => void;
141}) {
142 const { pathname } = useLocation();
143 const [more, setMore] = useState(false);
144 useEffect(() => setMore(false), [pathname]);
145 const slug = workspace.slug;
146 const code = hasCodeAccess(workspace);
147 const mode = modeOf(pathname, slug);
148 const tabs: Tab[] = [
149 { key: "today", label: "Today" },
150 { key: "chat", label: "Chat", badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 } },
151 { key: "notifications", label: "Notifications", badge: { count: unread.notifications, loud: true } },
152 { key: "agents", label: "Agents" },
153 ...(code ? [{ key: "code" as const, label: "Code" }] : []),
154 ];
155 const inTabs = tabs.some((tab) => tab.key === mode);
156 if (isConversation(pathname)) return null;
157 return (
158 <>
159 <nav
160 aria-label="Apps"
161 className="fixed right-2 bottom-[calc(0.5rem+env(safe-area-inset-bottom))] left-2 z-40 h-16 rounded-2xl border border-line bg-dock px-1 shadow-[0_1px_0_rgba(255,255,255,0.03)_inset,0_8px_24px_rgba(0,0,0,0.35)] md:hidden in-data-[keyboard=open]:hidden"
162 >
163 <ul className="grid h-full" style={{ gridTemplateColumns: `repeat(${tabs.length + 1}, minmax(0, 1fr))` }}>
164 {tabs.map((tab) => {
165 const current = mode === tab.key && !more;
166 const count = tab.badge?.count ?? 0;
167 const to = modeHome(tab.key, slug);
168 return (
169 <li key={tab.key} className="min-w-0">
170 <Link
171 to={to}
172 prefetch="intent"
173 aria-current={current ? "page" : undefined}
174 aria-label={count > 0 ? `${tab.label}, ${count} unread` : tab.label}
175 onClick={(event) => {
176 // The tab you are in, tapped again: its menu, as a phone's own apps do.
177 // Chat's first page on a phone is its sidebar already.
178 if (!current || !onReselect || (tab.key === "chat" && pathname.replace(/\/$/, "") === to)) return;
179 event.preventDefault();
180 onReselect();
181 }}
182 className={BAR_ITEM}
183 >
184 {current && <TopBar />}
185 <BarIcon current={current}>
186 {appIcon(tab.key as AppKey, 20)}
187 {tab.badge && <CountBadge count={count} loud={tab.badge.loud} />}
188 </BarIcon>
189 <span className={`w-full truncate text-[0.625rem] leading-tight font-medium max-[380px]:text-[0.5625rem] max-[380px]:tracking-[-0.02em] ${current ? "text-fg" : "text-faint"}`}>{tab.label}</span>
190 </Link>
191 </li>
192 );
193 })}
194 <li className="min-w-0">
195 <button type="button" aria-expanded={more} aria-haspopup="dialog" onClick={() => setMore(true)} className={BAR_ITEM}>
196 {(more || !inTabs) && <TopBar />}
197 <BarIcon current={more || !inTabs}>
198 <Ellipsis size={20} />
199 </BarIcon>
200 <span className={`w-full truncate text-[0.625rem] leading-tight font-medium ${more || !inTabs ? "text-fg" : "text-faint"}`}>More</span>
201 </button>
202 </li>
203 </ul>
204 </nav>
205 <MoreSheet open={more} onOpenChange={setMore} user={user} workspace={workspace} pins={pins} />
206 </>
207 );
208}
209
210const BAR_ITEM = "relative flex h-full w-full flex-col items-center justify-center gap-0.5 rounded-xl outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent active:bg-raised/60";
211
212/** The bar on the bottom bar's top edge, over the current tab. */
213function TopBar() {
214 return <span aria-hidden="true" className="absolute -top-px left-1/2 h-[3px] w-[22px] -translate-x-1/2 rounded-b-[3px] bg-fg" />;
215}
216
217function BarIcon({ current, children }: { current: boolean; children: ReactNode }) {
218 return (
219 <span className={`relative flex size-8 items-center justify-center rounded-[10px] transition-colors ${current ? "bg-raised text-fg shadow-[inset_0_0_0_1px_var(--color-line-strong)]" : "text-muted"}`}>
220 {children}
221 </span>
222 );
223}
224
225/** A big tile in the More sheet. */
226function MoreTile({ to, icon, label, onClick, disabled, note }: { to?: string; icon: ReactNode; label: string; onClick?: () => void; disabled?: boolean; note?: string }) {
227 const inner = (
228 <>
229 <span className={`flex size-11 items-center justify-center rounded-xl ${disabled ? "bg-raised/60 text-faint" : "bg-raised text-fg"}`}>{icon}</span>
230 <span className={`line-clamp-2 w-full text-xs leading-tight max-[380px]:text-[0.6875rem] ${disabled ? "text-faint" : "text-fg-soft"}`}>{label}</span>
231 {note && <span className="-mt-0.5 rounded-full bg-line px-1.5 text-[0.625rem] font-medium text-muted">{note}</span>}
232 </>
233 );
234 const className = "flex min-w-0 flex-col items-center gap-1.5 rounded-xl px-1 py-2.5 text-center outline-none focus-visible:ring-2 focus-visible:ring-accent active:bg-raised/60";
235 if (to) {
236 return (
237 <Link to={to} className={className}>
238 {inner}
239 </Link>
240 );
241 }
242 return (
243 <button type="button" onClick={onClick} disabled={disabled} aria-disabled={disabled} className={className}>
244 {inner}
245 </button>
246 );
247}
248
249/**
250 * More, from the bottom bar: a rounded panel just above it, with g1t's
251 * mark at its top and a grid of big tiles: all apps, the Marketplace
252 * (coming), your pinned apps, Artifacts, People, Workspace, and "You and
253 * help", which opens your account's sheet.
254 */
255function MoreSheet({ open, onOpenChange, user, workspace, pins }: { open: boolean; onOpenChange: (open: boolean) => void; user: User; workspace: Membership; pins: PinnableApp[] }) {
256 const slug = workspace.slug;
257 const code = hasCodeAccess(workspace);
258 const [account, setAccount] = useState(false);
259 const shown = pins.filter((key) => code || !appOf(key).code);
260 return (
261 <>
262 <Primitive.Root open={open} onOpenChange={onOpenChange}>
263 <Primitive.Portal>
264 <Primitive.Overlay className="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out md:hidden" />
265 <Primitive.Content
266 aria-describedby={undefined}
267 onOpenAutoFocus={(event) => event.preventDefault()}
268 className="fixed right-2 bottom-[calc(4.875rem+env(safe-area-inset-bottom))] left-2 z-50 max-h-[calc(100dvh-7rem)] overflow-y-auto rounded-[18px] border border-line-strong bg-dock px-3 pt-2 pb-3.5 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out md:hidden"
269 >
270 <div aria-hidden="true" className="mx-auto mt-0.5 mb-2 h-1 w-9 rounded-full bg-line-strong" />
271 <div className="mb-2 flex items-center gap-2 px-1">
272 <Mark tight className="h-4 w-auto" />
273 <Primitive.Title className="text-sm font-semibold">More</Primitive.Title>
274 <span className="ml-auto truncate font-mono text-xs text-faint">{slug}</span>
275 </div>
276 <div className="grid grid-cols-4 gap-1.5">
277 <MoreTile to={`/${slug}/-/apps`} icon={<LayoutGrid size={20} />} label="All apps" />
278 <MoreTile icon={<Store size={20} />} label="Marketplace" disabled note="Coming" />
279 {shown.map((key) => (
280 <MoreTile key={key} to={appOf(key).path(slug)} icon={appIcon(key, 20)} label={appOf(key).name} />
281 ))}
282 <MoreTile to={appOf("artifacts").path(slug)} icon={appIcon("artifacts", 20)} label="Artifacts" />
283 <MoreTile to={appOf("people").path(slug)} icon={appIcon("people", 20)} label="People" />
284 <MoreTile to={appOf("workspace").path(slug)} icon={appIcon("workspace", 20)} label="Workspace" />
285 <MoreTile
286 icon={<Avatar name={user.username} image={user.avatar} size={24} />}
287 label="You and help"
288 onClick={() => {
289 onOpenChange(false);
290 setAccount(true);
291 }}
292 />
293 </div>
294 </Primitive.Content>
295 </Primitive.Portal>
296 </Primitive.Root>
297 <AccountSheet open={account} onOpenChange={setAccount} user={user} />
298 </>
299 );
300}
301/**
302 * A sheet that rises from the bottom of a phone, with a handle to drag it
303 * back down. Escape, a tap outside or a drag of more than 90px closes it.
304 */
305export function BottomSheet({
306 open,
307 onOpenChange,
308 title,
309 children,
310}: {
311 open: boolean;
312 onOpenChange: (open: boolean) => void;
313 title: string;
314 children: ReactNode;
315}) {
316 const [drag, setDrag] = useState(0);
317 const from = useRef<number | null>(null);
318 useEffect(() => {
319 if (!open) setDrag(0);
320 }, [open]);
321 const handlers = {
322 onPointerDown: (event: React.PointerEvent) => {
323 from.current = event.clientY;
324 (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
325 },
326 onPointerMove: (event: React.PointerEvent) => {
327 if (from.current == null) return;
328 setDrag(Math.max(0, event.clientY - from.current));
329 },
330 onPointerUp: () => {
331 if (drag > 90) onOpenChange(false);
332 else setDrag(0);
333 from.current = null;
334 },
335 };
336 return (
337 <Primitive.Root open={open} onOpenChange={onOpenChange}>
338 <Primitive.Portal>
339 <Primitive.Overlay className="fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" />
340 <Primitive.Content
341 aria-describedby={undefined}
342 onOpenAutoFocus={(event) => event.preventDefault()}
343 style={{ transform: drag ? `translateY(${drag}px)` : undefined, transition: from.current == null ? "transform 0.2s ease-out" : "none" }}
344 className="fixed inset-x-0 bottom-0 z-50 flex max-h-[88dvh] flex-col rounded-t-2xl border-t border-line-strong bg-surface pb-[env(safe-area-inset-bottom)] text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-[sheet-in-bottom_0.24s_cubic-bezier(0.16,1,0.3,1)]"
345 >
346 <div {...handlers} className="flex shrink-0 cursor-grab touch-none justify-center pt-2.5 pb-2" aria-hidden="true">
347 <span className="h-1.5 w-10 rounded-full bg-line-strong" />
348 </div>
349 <Primitive.Title className="sr-only">{title}</Primitive.Title>
350 <div className="min-h-0 grow overflow-y-auto overscroll-contain px-3 pb-3">{children}</div>
351 </Primitive.Content>
352 </Primitive.Portal>
353 </Primitive.Root>
354 );
355}
356
357/** A row in a bottom sheet: 48px tall, an icon, words, and what sits at its end. */
358export function SheetRow({ to, href, icon, children, end, onClick }: { to?: string; href?: string; icon: ReactNode; children: ReactNode; end?: ReactNode; onClick?: () => void }) {
359 const className = "flex h-12 w-full items-center gap-3 rounded-xl px-3 text-left text-[0.9375rem] text-fg-soft transition-colors active:bg-raised";
360 const inner = (
361 <>
362 <span className="flex w-6 shrink-0 justify-center text-muted [&_svg]:size-5">{icon}</span>
363 <span className="min-w-0 grow truncate">{children}</span>
364 {end}
365 </>
366 );
367 if (to) {
368 return (
369 <Link to={to} className={className} onClick={onClick}>
370 {inner}
371 </Link>
372 );
373 }
374 if (href) {
375 return (
376 <a href={href} className={className}>
377 {inner}
378 </a>
379 );
380 }
381 return (
382 <button type="button" onClick={onClick} className={className}>
383 {inner}
384 </button>
385 );
386}
387
388function SheetGroup({ title, children }: { title: string; children: ReactNode }) {
389 return (
390 <section className="mt-2 border-t border-line pt-2">
391 <h3 className="px-3 pt-1 pb-1 text-xs font-medium text-faint">{title}</h3>
392 {children}
393 </section>
394 );
395}
396
397/** Your status, away and pausing notifications, as the account menu has them on a computer. */
398function OwnPresenceRows({ onEdit }: { onEdit: () => void }) {
399 const me = useOwnPresence();
400 const now = Date.now();
401 const status = liveStatus(me?.status, now);
402 const paused = dndOn(me, now);
403 const away = me?.away_manual === true;
404 return (
405 <>
406 <SheetRow icon={status?.emoji ? <span className="text-lg leading-none">{status.emoji}</span> : <Smile />} onClick={onEdit}>
407 {status ? status.text || "Status set" : "Set a status"}
408 </SheetRow>
409 <SheetRow icon={away ? <Sun /> : <Moon />} onClick={() => void setPresence({ away: !away }, { away_manual: !away, presence: away ? "active" : "away" })}>
410 {away ? "Set yourself active" : "Set yourself away"}
411 </SheetRow>
412 {paused ? (
413 <SheetRow
414 icon={<BellRing />}
415 end={me?.dnd_until ? <span className="text-xs text-faint">{untilLabel(me.dnd_until, new Date(now))}</span> : null}
416 onClick={() => void setPresence({ dnd_until: null }, { dnd_until: null })}
417 >
418 Resume notifications
419 </SheetRow>
420 ) : (
421 <SheetRow
422 icon={<BellOff />}
423 onClick={() => {
424 const until = pauseUntil("1h", new Date());
425 void setPresence({ dnd_until: until }, { dnd_until: until });
426 }}
427 >
428 Pause notifications for 1 hour
429 </SheetRow>
430 )}
431 </>
432 );
433}
434
435/**
436 * You and help, on a phone: your status, your profile and settings, the
437 * documentation, support, status and the keyboard's shortcuts, and signing
438 * out, as the account menu has them on a computer.
439 */
440export function AccountSheet({ open, onOpenChange, user }: { open: boolean; onOpenChange: (open: boolean) => void; user: User }) {
441 const submit = useSubmit();
442 const [editing, setEditing] = useState(false);
443 const [keys, setKeys] = useState(false);
444 return (
445 <>
446 <BottomSheet open={open} onOpenChange={onOpenChange} title="You and help">
447 <div className="flex items-center gap-3 px-3 pt-1 pb-3">
448 <Avatar name={user.username} image={user.avatar} size={44} />
449 <div className="min-w-0">
450 <p className="truncate text-base font-semibold">@{shownUsername(user)}</p>
451 </div>
452 </div>
453 <SheetGroup title="You">
454 <OwnPresenceRows
455 onEdit={() => {
456 onOpenChange(false);
457 setEditing(true);
458 }}
459 />
460 <SheetRow to={`/u/${user.username}`} icon={<CircleUserRound />}>
461 Your profile
462 </SheetRow>
463 <SheetRow to="/settings" icon={<Settings />}>
464 Your settings
465 </SheetRow>
466 </SheetGroup>
467 <SheetGroup title="Help">
468 <SheetRow href="https://docs.g1t.sh/" icon={<BookOpen />}>
469 Documentation
470 </SheetRow>
471 <SheetRow to="/support" icon={<LifeBuoy />}>
472 Support
473 </SheetRow>
474 <SheetRow href={STATUS_URL} icon={<Activity />}>
475 Status
476 </SheetRow>
477 <SheetRow
478 icon={<Keyboard />}
479 onClick={() => {
480 onOpenChange(false);
481 setKeys(true);
482 }}
483 >
484 Keyboard shortcuts
485 </SheetRow>
486 </SheetGroup>
487 <SheetGroup title="Account">
488 <SheetRow icon={<LogOut />} onClick={() => submit(null, { method: "post", action: "/logout" })}>
489 Sign out
490 </SheetRow>
491 </SheetGroup>
492 </BottomSheet>
493 <StatusDialog open={editing} onOpenChange={setEditing} />
494 <ShortcutsDialog open={keys} onOpenChange={setKeys} />
495 </>
496 );
497}
498
499/** Which project page a roadmap page (`soon/<key>`) sits under, by its section. */
500const SOON_UNDER: Partial<Record<RoadmapItem["section"], ProjectPage>> = {
501 Code: "code",
502 Issues: "issues",
503 Agents: "agents",
504 Deployments: "deployments",
505 Observability: "observability",
506 Security: "security",
507 Insights: "insights",
508};
509const SOON_PAGES: Record<string, ProjectPage> = Object.fromEntries(
510 ROADMAP.flatMap((item) => (SOON_UNDER[item.section] ? [[item.key, SOON_UNDER[item.section]!] as const] : [])),
511);
512
513/**
514 * A project's pages on a phone, under its name: the sidebar's own list
515 * (lib/chrome.ts) as a row of tabs that scrolls sideways, the current one
516 * kept in view, so Issues and Pull requests are one tap away. From 768px
517 * the sidebar has them.
518 */
519export function ProjectStrip({
520 base,
521 member,
522 can,
523 settings,
524 counts,
525 always = false,
526}: {
527 /** `/<namespace>/<name>`. */
528 base: string;
529 member: boolean;
530 can?: Partial<Abilities>;
531 /** Whether they see its settings (lib/access.ts `seesSettings`), as the sidebar decides. */
532 settings?: boolean;
533 counts?: { issues: number; pulls: number };
534 /** On a computer too: for a visitor, who has no sidebar to list them. */
535 always?: boolean;
536}) {
537 const { pathname } = useLocation();
538 const rest = pathname.toLowerCase().startsWith(base.toLowerCase()) ? pathname.slice(base.length) : "";
539 const at = projectPageAt(rest, SOON_PAGES);
540 const pages: (ProjectPage | "overview")[] = ["overview", ...projectPages(member, can).filter((page) => page !== "observability" && page !== "settings"),
541 ...(settings ?? member ? (["settings"] as const) : []),
542 ];
543 const pill = (current: boolean) =>
544 `flex h-9 items-center gap-1.5 rounded-full px-3.5 text-sm transition-colors ${
545 current ? "bg-raised font-medium text-fg ring-1 ring-line-strong" : "text-muted active:bg-raised/60"
546 }`;
547 return (
548 <TabStrip label="Project" className={`-mx-4 gap-1 px-4 ${always ? "" : "md:hidden"}`}>
549 {pages.map((page) => {
550 const current = at === page;
551 const link = page === "overview" ? { label: "Overview", path: "" } : PROJECT_PAGE_LINKS[page];
552 const count = page === "issues" ? counts?.issues : page === "pulls" ? counts?.pulls : undefined;
553 return (
554 <Link
555 key={page}
556 to={link.path ? `${base}/${link.path}` : base}
557 prefetch="intent"
558 aria-current={current ? "page" : undefined}
559 data-active={current || undefined}
560 className={pill(current)}
561 >
562 {link.label}
563 {count != null && count > 0 && <span className="rounded-full bg-line px-1.5 text-xs tabular-nums text-muted">{count}</span>}
564 </Link>
565 );
566 })}
567 </TabStrip>
568 );
569}